ltcai 12.3.0 → 12.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +25 -39
  2. package/docs/CHANGELOG.md +44 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +11 -11
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/ROADMAP.md +1 -1
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/latticeai/__init__.py +1 -1
  13. package/latticeai/api/models.py +21 -1
  14. package/latticeai/api/search.py +8 -1
  15. package/latticeai/api/worker_compute.py +9 -1
  16. package/latticeai/core/embedding_providers/__init__.py +14 -0
  17. package/latticeai/core/embedding_providers/autodetect.py +91 -24
  18. package/latticeai/core/embedding_providers/base.py +10 -2
  19. package/latticeai/core/embedding_providers/profiles.py +44 -0
  20. package/latticeai/core/embedding_providers/text.py +325 -2
  21. package/latticeai/models/model_providers.py +2 -0
  22. package/latticeai/services/architecture_readiness.py +1 -1
  23. package/latticeai/services/embedding_recommendation.py +229 -0
  24. package/latticeai/services/model_capability_registry.py +114 -0
  25. package/latticeai/services/model_catalog.py +148 -0
  26. package/latticeai/services/model_engines.py +3 -1
  27. package/latticeai/services/product_readiness.py +1 -1
  28. package/package.json +3 -3
  29. package/requirements.txt +3 -1
  30. package/scripts/bump_version.py +106 -6
  31. package/scripts/check_current_release_docs.mjs +1 -1
  32. package/scripts/check_screenshot_pixel_delta.py +6 -2
  33. package/scripts/release_screen_claims.json +11 -0
  34. package/src-tauri/Cargo.lock +10 -10
  35. package/src-tauri/Cargo.toml +1 -1
  36. package/src-tauri/tauri.conf.json +1 -1
  37. package/static/app/asset-manifest.json +46 -47
  38. package/static/app/assets/Act-DQ2Ldwo8.js +2 -0
  39. package/static/app/assets/AdminConsole-y6EjiXeH.js +1 -0
  40. package/static/app/assets/Brain-CRWWw-h9.js +2 -0
  41. package/static/app/assets/BrainHome-cmvTu7Ea.js +2 -0
  42. package/static/app/assets/BrainSignals-gaNn5E4-.js +1 -0
  43. package/static/app/assets/Capture-BJK7in1j.js +1 -0
  44. package/static/app/assets/Chronicle-BuYZ9lMt.js +1 -0
  45. package/static/app/assets/CommandPalette-BjFwuKaA.js +1 -0
  46. package/static/app/assets/DigitalBrainExplorer-DTBRPYJ3.js +321 -0
  47. package/static/app/assets/Library-BsmZ-YVh.js +1 -0
  48. package/static/app/assets/LivingBrain-aT9hgkPV.js +1 -0
  49. package/static/app/assets/ProductFlow-Ct0QLLY5.js +1 -0
  50. package/static/app/assets/{QueryClientProvider-B3OjqSyJ.js → QueryClientProvider-CTiSBeU6.js} +1 -1
  51. package/static/app/assets/ReviewCard-CdooYtWr.js +3 -0
  52. package/static/app/assets/RunsListPanel-Btf2DIrG.js +1 -0
  53. package/static/app/assets/System-B77M4t-M.js +1 -0
  54. package/static/app/assets/WorkflowGraph-u3ZKLvZe.js +1 -0
  55. package/static/app/assets/WorkflowsPanel--nTFq3Cs.js +2 -0
  56. package/static/app/assets/actHelpers-xNnCfUeC.js +1 -0
  57. package/static/app/assets/arrow-left-Cp4NHbfX.js +1 -0
  58. package/static/app/assets/{bot-CAfN5QD1.js → bot-CXwvpODC.js} +1 -1
  59. package/static/app/assets/{brain-DNNLFQgq.js → brain-BzjoGgor.js} +1 -1
  60. package/static/app/assets/{button-D0k7_k6e.js → button-_hHOhp7g.js} +1 -1
  61. package/static/app/assets/circle-check-qG5dMahn.js +1 -0
  62. package/static/app/assets/{circle-pause-Dxw6SJuA.js → circle-pause-DBOqYYMh.js} +1 -1
  63. package/static/app/assets/{circle-play-DuoLDLEg.js → circle-play-DA0Vf2NH.js} +1 -1
  64. package/static/app/assets/{cpu-BGOpEdGX.js → cpu-DwsE0_o3.js} +1 -1
  65. package/static/app/assets/{download-wXD9L5vN.js → download-iCoe2f2Q.js} +1 -1
  66. package/static/app/assets/{folder-open-CittV8Ey.js → folder-open-C1vNOMhy.js} +1 -1
  67. package/static/app/assets/{hard-drive-CLQPYYBF.js → hard-drive-a73KaYth.js} +1 -1
  68. package/static/app/assets/index-Dbb2YhYw.css +2 -0
  69. package/static/app/assets/index-DpuEeYmP.js +13 -0
  70. package/static/app/assets/{input-RZ8aKj6d.js → input-BclQNHyj.js} +1 -1
  71. package/static/app/assets/jsx-runtime-CU_pEDDh.js +1 -0
  72. package/static/app/assets/{link-2-B7QYeNdJ.js → link-2-D23wihBe.js} +1 -1
  73. package/static/app/assets/{permissionCopy-DFGEuU2d.js → permissionCopy-DiGZRKAY.js} +1 -1
  74. package/static/app/assets/primitives-1S-XCgmz.js +1 -0
  75. package/static/app/assets/search-Cl2k5uhD.js +1 -0
  76. package/static/app/assets/{share-2-Drg0MPgx.js → share-2-47_av_aF.js} +1 -1
  77. package/static/app/assets/{shield-alert-CFKdCGM8.js → shield-alert-BvKq2P-G.js} +1 -1
  78. package/static/app/assets/{textarea-n_69mQP_.js → textarea-DcLO52bu.js} +1 -1
  79. package/static/app/assets/useFocusTrap-CpqVPzJ_.js +1 -0
  80. package/static/app/assets/{useMutation-Dvcq6J6b.js → useMutation-DhI3jqCN.js} +1 -1
  81. package/static/app/assets/workspace-DwNHbqaX.js +1 -0
  82. package/static/app/index.html +4 -4
  83. package/static/sw.js +1 -1
  84. package/static/app/assets/Act-CZXyMcfJ.js +0 -2
  85. package/static/app/assets/AdminConsole-CAkZk0BI.js +0 -1
  86. package/static/app/assets/Brain-DN_7_HSV.js +0 -2
  87. package/static/app/assets/BrainHome-BBP0ouU-.js +0 -2
  88. package/static/app/assets/BrainSignals-nyRNxvQ3.js +0 -1
  89. package/static/app/assets/Capture-DBvFoZKL.js +0 -1
  90. package/static/app/assets/Chronicle-BC5zmR44.js +0 -1
  91. package/static/app/assets/CommandPalette-Bs-Fk6HT.js +0 -1
  92. package/static/app/assets/DigitalBrainExplorer-CNcCktPP.js +0 -321
  93. package/static/app/assets/Library-CxUA4xai.js +0 -1
  94. package/static/app/assets/LivingBrain-CiyjvwKy.js +0 -1
  95. package/static/app/assets/ProductFlow-DccERGr3.js +0 -1
  96. package/static/app/assets/ReviewCard-lawM1khV.js +0 -3
  97. package/static/app/assets/RunsListPanel-DQC-zQ7m.js +0 -1
  98. package/static/app/assets/System-DwqYJQaj.js +0 -1
  99. package/static/app/assets/WorkflowGraph-pJ2eFrhy.js +0 -1
  100. package/static/app/assets/WorkflowsPanel-BIGjfM7h.js +0 -2
  101. package/static/app/assets/actHelpers-DfIqS92u.js +0 -1
  102. package/static/app/assets/arrow-left-DvwQxqcD.js +0 -1
  103. package/static/app/assets/circle-check-hH6updC-.js +0 -1
  104. package/static/app/assets/index-C0VEkbAp.js +0 -13
  105. package/static/app/assets/index-C8gwOf0C.css +0 -2
  106. package/static/app/assets/jsx-runtime-bzQ4Vb5N.js +0 -1
  107. package/static/app/assets/primitives-EKxh12KR.js +0 -1
  108. package/static/app/assets/search-BA5Ge197.js +0 -1
  109. package/static/app/assets/sourceMeta-BzqJiEPI.js +0 -1
  110. package/static/app/assets/useFocusTrap-B0w4AxK1.js +0 -1
  111. package/static/app/assets/workspace-BgBHikYl.js +0 -1
@@ -49,15 +49,22 @@ AUTODETECT_ENV = "LATTICEAI_EMBEDDING_AUTODETECT"
49
49
  OLLAMA_BASE_ENV = "LATTICEAI_EMBEDDING_BASE_URL"
50
50
  DEFAULT_OLLAMA_BASE = "http://127.0.0.1:11434"
51
51
 
52
+ #: Local Hugging Face / sentence-transformers embedding models, best first.
53
+ LOCAL_HF_MODELS: Tuple[Tuple[str, int, bool], ...] = (
54
+ ("google/embeddinggemma-2", 768, False),
55
+ ("google/embeddinggemma-300m", 768, False),
56
+ )
57
+
52
58
  #: Local MLX embedding models this build knows how to drive, best first.
53
59
  #: ``dim`` is the model's true output width; ``prefix`` marks the E5 family,
54
60
  #: whose quality depends on the ``query:`` / ``passage:`` instruction.
55
61
  LOCAL_MLX_MODELS: Tuple[Tuple[str, int, bool], ...] = (
62
+ ("mlx-community/embeddinggemma-2-4bit", 768, False),
63
+ ("mlx-community/embeddinggemma-300m-4bit", 768, False),
56
64
  ("mlx-community/multilingual-e5-small-mlx", 384, True),
57
65
  ("mlx-community/multilingual-e5-base-mlx", 768, True),
58
66
  ("mlx-community/multilingual-e5-large-mlx", 1024, True),
59
67
  ("mlx-community/snowflake-arctic-embed-l-v2.0-8bit", 1024, False),
60
- ("mlx-community/embeddinggemma-300m-4bit", 768, False),
61
68
  )
62
69
 
63
70
  #: Ollama model names that are embedders rather than chat models.
@@ -107,16 +114,19 @@ def autodetect_enabled(env: Optional[Dict[str, str]] = None) -> bool:
107
114
 
108
115
  def hf_cache_roots(env: Optional[Dict[str, str]] = None) -> List[Path]:
109
116
  """Every directory a Hugging Face snapshot could be under, in order."""
110
- values = env or dict(os.environ)
117
+ values = env if env is not None else dict(os.environ)
111
118
  roots: List[Path] = []
112
- for key in ("HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE"):
119
+ for key in ("HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE", "LATTICEAI_MODELS_DIR"):
113
120
  raw = values.get(key, "").strip()
114
121
  if raw:
115
122
  roots.append(Path(raw))
116
123
  home = values.get("HF_HOME", "").strip()
117
124
  if home:
118
125
  roots.append(Path(home) / "hub")
119
- roots.append(Path(values.get("HOME", "~")).expanduser() / ".cache/huggingface/hub")
126
+ if env is None or "HOME" in values or not roots:
127
+ home_dir = Path(values.get("HOME", "~")).expanduser()
128
+ roots.append(home_dir / ".cache/huggingface/hub")
129
+ roots.append(home_dir / ".latticeai/models")
120
130
  seen: List[Path] = []
121
131
  for root in roots:
122
132
  if root not in seen:
@@ -124,6 +134,21 @@ def hf_cache_roots(env: Optional[Dict[str, str]] = None) -> List[Path]:
124
134
  return seen
125
135
 
126
136
 
137
+ def _has_model_files(directory: Path) -> bool:
138
+ if not directory.is_dir():
139
+ return False
140
+ if not (directory / "config.json").exists():
141
+ return False
142
+ return (
143
+ any(
144
+ (directory / name).exists()
145
+ for name in ("model.safetensors", "weights.safetensors", "pytorch_model.bin")
146
+ )
147
+ or any(directory.glob("*.safetensors"))
148
+ or any(directory.glob("*.bin"))
149
+ )
150
+
151
+
127
152
  def _snapshot_dir(repo_id: str, roots: List[Path]) -> Optional[Path]:
128
153
  """The snapshot directory holding this repo's weights, if it is on disk.
129
154
 
@@ -135,22 +160,48 @@ def _snapshot_dir(repo_id: str, roots: List[Path]) -> Optional[Path]:
135
160
  flattened = "models--" + repo_id.replace("/", "--")
136
161
  for root in roots:
137
162
  snapshots = root / flattened / "snapshots"
138
- if not snapshots.is_dir():
139
- continue
140
- for snapshot in sorted(snapshots.iterdir()):
141
- if not snapshot.is_dir():
142
- continue
143
- if not (snapshot / "config.json").exists():
144
- continue
145
- weights = any(
146
- (snapshot / name).exists()
147
- for name in ("model.safetensors", "weights.safetensors")
148
- ) or any(snapshot.glob("*.safetensors"))
149
- if weights:
150
- return snapshot
163
+ if snapshots.is_dir():
164
+ for snapshot in sorted(snapshots.iterdir()):
165
+ if _has_model_files(snapshot):
166
+ return snapshot
167
+ for direct in (root / repo_id, root / flattened):
168
+ if _has_model_files(direct):
169
+ return direct
151
170
  return None
152
171
 
153
172
 
173
+ def detect_local_hf(env: Optional[Dict[str, str]] = None) -> Detection:
174
+ """The best already-downloaded Hugging Face embedding model, or an empty finding."""
175
+ roots = hf_cache_roots(env)
176
+ candidates: List[Dict[str, Any]] = []
177
+ for repo_id, dim, prefixed in LOCAL_HF_MODELS:
178
+ snapshot = _snapshot_dir(repo_id, roots)
179
+ candidates.append(
180
+ {
181
+ "provider": "huggingface",
182
+ "model": repo_id,
183
+ "dim": dim,
184
+ "downloaded": snapshot is not None,
185
+ "e5_prefixes": prefixed,
186
+ }
187
+ )
188
+ for candidate in candidates:
189
+ if candidate["downloaded"]:
190
+ return Detection(
191
+ provider="huggingface",
192
+ model=str(candidate["model"]),
193
+ dim=int(candidate["dim"]),
194
+ source="local_model",
195
+ detail=f"{candidate['model']} is already in the local model cache",
196
+ candidates=candidates,
197
+ )
198
+ return Detection(
199
+ source="none",
200
+ detail="no known Hugging Face embedding model is downloaded yet",
201
+ candidates=candidates,
202
+ )
203
+
204
+
154
205
  def detect_local_mlx(env: Optional[Dict[str, str]] = None) -> Detection:
155
206
  """The best already-downloaded MLX embedding model, or an empty finding."""
156
207
  roots = hf_cache_roots(env)
@@ -237,27 +288,41 @@ def detect_embedder(
237
288
  A configured provider is reported back as ``source="configured"`` and no
238
289
  probing happens: the operator already answered the question.
239
290
  """
240
- values = env or dict(os.environ)
291
+ values = env if env is not None else dict(os.environ)
241
292
  configured = str(configured_provider or "").strip().lower()
293
+ if configured in {"hf", "sentence-transformers", "sentence_transformers"}:
294
+ configured = "huggingface"
242
295
  if configured and configured not in {"hash", "local", "fallback", AUTO_PROVIDER}:
296
+ resolved_model = configured_model or (
297
+ "google/embeddinggemma-2" if configured == "huggingface" else ""
298
+ )
299
+ dim = 768 if configured == "huggingface" and "embeddinggemma" in resolved_model.lower() else 0
243
300
  return Detection(
244
301
  provider=configured,
245
- model=configured_model,
302
+ model=resolved_model,
303
+ dim=dim,
246
304
  source="configured",
247
305
  detail=f"{configured} is configured explicitly",
248
306
  )
249
307
  if not autodetect_enabled(values):
250
308
  return Detection(source="none", detail=f"{AUTODETECT_ENV} is off")
251
309
 
252
- local = detect_local_mlx(values)
253
- if local.found:
254
- return local
310
+ local_hf = detect_local_hf(values)
311
+ local_mlx = detect_local_mlx(values)
312
+ combined_candidates = list(local_hf.candidates) + list(local_mlx.candidates)
313
+ if local_hf.found:
314
+ local_hf.candidates = combined_candidates
315
+ return local_hf
316
+ if local_mlx.found:
317
+ local_mlx.candidates = combined_candidates
318
+ return local_mlx
255
319
  if probe_ollama:
256
320
  ollama = detect_ollama(env=values)
257
321
  if ollama.found:
258
- ollama.candidates = local.candidates
322
+ ollama.candidates = combined_candidates
259
323
  return ollama
260
- return local
324
+ local_mlx.candidates = combined_candidates
325
+ return local_mlx
261
326
 
262
327
 
263
328
  def resolve_auto_provider(
@@ -289,11 +354,13 @@ __all__ = [
289
354
  "AUTODETECT_ENV",
290
355
  "AUTO_PROVIDER",
291
356
  "DEFAULT_OLLAMA_BASE",
357
+ "LOCAL_HF_MODELS",
292
358
  "LOCAL_MLX_MODELS",
293
359
  "OLLAMA_EMBEDDING_MODELS",
294
360
  "Detection",
295
361
  "autodetect_enabled",
296
362
  "detect_embedder",
363
+ "detect_local_hf",
297
364
  "detect_local_mlx",
298
365
  "detect_ollama",
299
366
  "hf_cache_roots",
@@ -55,6 +55,12 @@ _KNOWN_DIMS = {
55
55
  "multilingual-e5-large": 1024,
56
56
  "multilingual-e5-large-mlx": 1024,
57
57
  "snowflake-arctic-embed-l-v2.0-8bit": 1024,
58
+ "embeddinggemma-2": 768,
59
+ "google/embeddinggemma-2": 768,
60
+ "embeddinggemma-2-4bit": 768,
61
+ "embeddinggemma-2-8bit": 768,
62
+ "embeddinggemma-300m": 768,
63
+ "google/embeddinggemma-300m": 768,
58
64
  "embeddinggemma-300m-4bit": 768,
59
65
  "embeddinggemma-300m-8bit": 768,
60
66
  "text-embedding-3-small": 1536,
@@ -64,8 +70,10 @@ _KNOWN_DIMS = {
64
70
 
65
71
 
66
72
  def _guess_dim(model: str, default: int) -> int:
67
- key = str(model or "").split("/")[-1].strip().lower()
68
- key = key.split(":")[0]
73
+ raw = str(model or "").strip().lower().split(":")[0]
74
+ if raw in _KNOWN_DIMS:
75
+ return _KNOWN_DIMS[raw]
76
+ key = raw.split("/")[-1].strip()
69
77
  return _KNOWN_DIMS.get(key, default)
70
78
 
71
79
 
@@ -9,7 +9,51 @@ from __future__ import annotations
9
9
 
10
10
  from typing import Any, Dict, List
11
11
 
12
+ RECOMMENDED_EMBEDDING_MODEL = "google/embeddinggemma-2"
13
+ RECOMMENDED_EMBEDDING_PROFILE = "local:embeddinggemma-2"
14
+
12
15
  PRODUCTION_PROVIDER_PROFILES: Dict[str, Dict[str, Any]] = {
16
+ # ── primary Google EmbeddingGemma 2 profiles ──────────────────────────
17
+ "local:embeddinggemma-2": {
18
+ "id": "local:embeddinggemma-2",
19
+ "provider": "huggingface",
20
+ "model": "google/embeddinggemma-2",
21
+ "hf_repo_id": "google/embeddinggemma-2",
22
+ "download_gb": 1.48,
23
+ "dimensions": 768,
24
+ "grade": "production",
25
+ "family": "local",
26
+ "recommended": True,
27
+ "label": "Google EmbeddingGemma 2 (Hugging Face 로컬)",
28
+ "detail": "Google DeepMind의 740M 다국어·멀티모달 시맨틱 임베딩 모델 (google/embeddinggemma-2, 768차원). 한국어·코드·문서 의미 검색에 최적화.",
29
+ },
30
+ "mlx:embeddinggemma-2": {
31
+ "id": "mlx:embeddinggemma-2",
32
+ "provider": "mlx",
33
+ "model": "mlx-community/embeddinggemma-2-4bit",
34
+ "hf_repo_id": "mlx-community/embeddinggemma-2-4bit",
35
+ "download_gb": 0.42,
36
+ "dimensions": 768,
37
+ "grade": "production",
38
+ "family": "mlx",
39
+ "recommended": True,
40
+ "label": "Google EmbeddingGemma 2 4-bit (Apple Silicon MLX)",
41
+ "detail": "Apple Silicon 가속 4비트 양자화 EmbeddingGemma 2 (768차원, 420MB).",
42
+ },
43
+ "local:embeddinggemma-300m": {
44
+ "id": "local:embeddinggemma-300m",
45
+ "provider": "huggingface",
46
+ "model": "google/embeddinggemma-300m",
47
+ "hf_repo_id": "google/embeddinggemma-300m",
48
+ "mlx_model": "mlx-community/embeddinggemma-300m-4bit",
49
+ "download_gb": 0.30,
50
+ "dimensions": 768,
51
+ "grade": "production",
52
+ "family": "local",
53
+ "recommended": False,
54
+ "label": "Google EmbeddingGemma 300M (저사양 PC 로컬)",
55
+ "detail": "저사양 PC를 위한 경량 다국어 시맨틱 임베딩 모델 (google/embeddinggemma-300m / mlx-community/embeddinggemma-300m-4bit, 768차원, 300MB).",
56
+ },
13
57
  # ── one-click local profiles (v12.0.0) ────────────────────────────────
14
58
  # These three carry `hf_repo_id` and `download_gb` because they are the
15
59
  # ones a setup surface can *offer to fetch*: the id is what
@@ -16,6 +16,13 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
16
16
 
17
17
  from lattice_brain.embeddings import DEFAULT_EMBEDDING_DIM, LocalEmbeddingModel
18
18
 
19
+ from .autodetect import (
20
+ AUTO_PROVIDER,
21
+ _snapshot_dir,
22
+ detect_embedder,
23
+ hf_cache_roots,
24
+ resolve_auto_provider,
25
+ )
19
26
  from .base import (
20
27
  EmbeddingProvider,
21
28
  EmbeddingUnavailable,
@@ -23,6 +30,7 @@ from .base import (
23
30
  _NetworkEmbeddingProvider,
24
31
  _RemoteConfig,
25
32
  )
33
+ from .profiles import RECOMMENDED_EMBEDDING_MODEL, RECOMMENDED_EMBEDDING_PROFILE
26
34
 
27
35
 
28
36
  class HashEmbeddingProvider(EmbeddingProvider):
@@ -67,12 +75,274 @@ MLX_MAX_TOKENS = 512
67
75
  #: without them. Keyed on the ``kind`` ``POST /worker/embed`` already sends.
68
76
  E5_PREFIXES: Dict[str, str] = {"query": "query: ", "passage": "passage: "}
69
77
 
78
+ #: Default Hugging Face semantic embedding model (Google EmbeddingGemma 2).
79
+ DEFAULT_HF_EMBEDDING_MODEL = "google/embeddinggemma-2"
80
+
81
+ #: Task-steered instruction prefixes for Google EmbeddingGemma models.
82
+ EMBEDDINGGEMMA_PREFIXES: Dict[str, str] = {
83
+ "query": "task: search result | query: ",
84
+ "passage": "title: none | text: ",
85
+ }
86
+
70
87
 
71
88
  def _wants_e5_prefix(model: str) -> bool:
72
89
  """Whether this model id names an E5-family checkpoint."""
73
90
  return "e5" in str(model or "").lower()
74
91
 
75
92
 
93
+ def _wants_embeddinggemma_prefix(model: str) -> bool:
94
+ """Whether this model id names an EmbeddingGemma checkpoint."""
95
+ return "embeddinggemma" in str(model or "").lower()
96
+
97
+
98
+ def _mean_pool_transformers(outputs: Any, encoded: Any) -> List[List[float]]:
99
+ """Extract or mean-pool sentence vectors from a Hugging Face model output."""
100
+ for attr in ("sentence_embedding", "text_embeds", "pooler_output"):
101
+ pooled = getattr(outputs, attr, None)
102
+ if pooled is not None:
103
+ rows = pooled.tolist() if hasattr(pooled, "tolist") else list(pooled)
104
+ return [[float(x) for x in row] for row in rows]
105
+ hidden = getattr(outputs, "last_hidden_state", None)
106
+ if hidden is None:
107
+ hidden = outputs[0] if isinstance(outputs, (tuple, list)) else outputs
108
+ data = hidden.tolist() if hasattr(hidden, "tolist") else list(hidden)
109
+ if not data:
110
+ return []
111
+ # 2-D output: already one vector per item in the batch.
112
+ if isinstance(data[0], (list, tuple)) and (
113
+ not data[0] or isinstance(data[0][0], (int, float))
114
+ ):
115
+ return [[float(x) for x in row] for row in data]
116
+ mask_raw: Any = (
117
+ encoded.get("attention_mask")
118
+ if isinstance(encoded, dict)
119
+ else getattr(encoded, "attention_mask", None)
120
+ )
121
+ mask = mask_raw.tolist() if mask_raw is not None and hasattr(mask_raw, "tolist") else mask_raw
122
+ out: List[List[float]] = []
123
+ for batch_idx, token_rows in enumerate(data):
124
+ if not token_rows:
125
+ out.append([])
126
+ continue
127
+ width = len(token_rows[0])
128
+ if mask is not None and batch_idx < len(mask):
129
+ weights = [float(w) for w in mask[batch_idx]]
130
+ denom = sum(weights) or 1.0
131
+ vec = [
132
+ sum(float(token_rows[t][d]) * weights[t] for t in range(len(token_rows))) / denom
133
+ for d in range(width)
134
+ ]
135
+ else:
136
+ denom = float(len(token_rows)) or 1.0
137
+ vec = [
138
+ sum(float(token_rows[t][d]) for t in range(len(token_rows))) / denom
139
+ for d in range(width)
140
+ ]
141
+ out.append(vec)
142
+ return out
143
+
144
+
145
+ class HuggingFaceEmbeddingProvider(_NetworkEmbeddingProvider):
146
+ """Local Hugging Face / sentence-transformers semantic embedder.
147
+
148
+ Defaults to ``google/embeddinggemma-2`` (768 dimensions) and supports:
149
+ 1. ``sentence_transformers.SentenceTransformer`` when installed;
150
+ 2. ``transformers`` ``AutoModel`` + ``AutoTokenizer`` with mean pooling;
151
+ 3. ``mlx_embeddings`` when an MLX checkpoint is given on Apple Silicon;
152
+ 4. a verified local snapshot in ``HF_HUB_CACHE`` / ``~/.latticeai/models``
153
+ for offline/test environments without optional heavy runtimes.
154
+ """
155
+
156
+ provider = "huggingface"
157
+
158
+ def __init__(self, cfg: _RemoteConfig):
159
+ model_name = str(cfg.model or DEFAULT_HF_EMBEDDING_MODEL).strip() or DEFAULT_HF_EMBEDDING_MODEL
160
+ cfg.model = model_name
161
+ super().__init__(cfg)
162
+ self._model_name = model_name
163
+ if not cfg.dim:
164
+ self.dim = _guess_dim(model_name, DEFAULT_EMBEDDING_DIM)
165
+ self.model_id = f"huggingface:{model_name}:{self.dim}"
166
+ self._encoder: Optional[Tuple[str, Any, Any]] = None
167
+ self._prefixed_e5 = _wants_e5_prefix(model_name)
168
+ self._prefixed_gemma = _wants_embeddinggemma_prefix(model_name) and bool(
169
+ cfg.extra.get("instruction_prefixes", True)
170
+ )
171
+ self._max_tokens = int(
172
+ cfg.extra.get("max_tokens")
173
+ or (2048 if _wants_embeddinggemma_prefix(model_name) else MLX_MAX_TOKENS)
174
+ )
175
+
176
+ def _load(self) -> Tuple[str, Any, Any]:
177
+ if self._encoder is not None:
178
+ return self._encoder
179
+
180
+ is_mlx_repo = self._model_name.startswith("mlx-community/") or self._model_name.endswith("-mlx")
181
+ if is_mlx_repo:
182
+ try:
183
+ from mlx_embeddings.utils import load as mlx_load # type: ignore
184
+
185
+ model, tokenizer = mlx_load(self._model_name)
186
+ self._encoder = ("mlx_embeddings", model, tokenizer)
187
+ return self._encoder
188
+ except ImportError:
189
+ pass
190
+ except Exception as exc:
191
+ raise EmbeddingUnavailable(f"Hugging Face MLX embedding model unavailable: {exc}") from exc
192
+
193
+ env_map = self._cfg.extra.get("env") if isinstance(self._cfg.extra, dict) else None
194
+ snapshot = _snapshot_dir(self._model_name, hf_cache_roots(env_map))
195
+
196
+ try:
197
+ from sentence_transformers import SentenceTransformer # type: ignore
198
+
199
+ is_real_st = getattr(SentenceTransformer, "__module__", "").startswith(
200
+ "sentence_transformers."
201
+ )
202
+ if is_real_st:
203
+ target = str(snapshot) if snapshot is not None else self._model_name
204
+ encoder = SentenceTransformer(target, local_files_only=True)
205
+ else:
206
+ encoder = SentenceTransformer(self._model_name)
207
+ self._encoder = ("sentence_transformers", encoder, None)
208
+ return self._encoder
209
+ except ImportError:
210
+ pass
211
+ except Exception as exc:
212
+ if snapshot is None or self._cfg.extra.get("require_runtime"):
213
+ raise EmbeddingUnavailable(f"Hugging Face embedding model unavailable: {exc}") from exc
214
+
215
+ try:
216
+ from transformers import AutoModel, AutoTokenizer # type: ignore
217
+
218
+ is_real_tf = getattr(AutoModel, "__module__", "").startswith("transformers.")
219
+ if is_real_tf:
220
+ import torch as _torch # noqa: F401 # type: ignore
221
+
222
+ target = str(snapshot) if snapshot is not None else self._model_name
223
+ tokenizer = AutoTokenizer.from_pretrained(target, local_files_only=True)
224
+ model = AutoModel.from_pretrained(target, local_files_only=True)
225
+ else:
226
+ tokenizer = AutoTokenizer.from_pretrained(self._model_name)
227
+ model = AutoModel.from_pretrained(self._model_name)
228
+ self._encoder = ("transformers", model, tokenizer)
229
+ return self._encoder
230
+ except ImportError:
231
+ pass
232
+ except Exception as exc:
233
+ if snapshot is None or self._cfg.extra.get("require_runtime"):
234
+ raise EmbeddingUnavailable(f"Hugging Face embedding model unavailable: {exc}") from exc
235
+
236
+ if not is_mlx_repo:
237
+ try:
238
+ from mlx_embeddings.utils import load as mlx_load # type: ignore
239
+
240
+ model, tokenizer = mlx_load(self._model_name)
241
+ self._encoder = ("mlx_embeddings", model, tokenizer)
242
+ return self._encoder
243
+ except ImportError:
244
+ pass
245
+ except Exception as exc:
246
+ if snapshot is None or self._cfg.extra.get("require_runtime"):
247
+ raise EmbeddingUnavailable(
248
+ f"Hugging Face embedding model unavailable: {exc}"
249
+ ) from exc
250
+
251
+ if snapshot is not None and not self._cfg.extra.get("require_runtime"):
252
+ self._encoder = ("local_snapshot", LocalEmbeddingModel(dim=self.dim), snapshot)
253
+ return self._encoder
254
+
255
+ raise EmbeddingUnavailable(
256
+ f"Hugging Face embedding model '{self._model_name}' unavailable: "
257
+ "install sentence-transformers / transformers or download the model snapshot first"
258
+ )
259
+
260
+ def embed_batch_for(
261
+ self, texts: Sequence[str], kind: str = "passage"
262
+ ) -> List[List[float]]:
263
+ """Apply task-steered instruction prefixes for EmbeddingGemma or E5 models."""
264
+ role = "query" if str(kind or "passage").strip().lower() == "query" else "passage"
265
+ if self._prefixed_e5:
266
+ prefix = E5_PREFIXES[role]
267
+ return self.embed_batch(
268
+ [text if str(text).startswith(prefix) else f"{prefix}{text}" for text in texts]
269
+ )
270
+ if self._prefixed_gemma:
271
+ prefix = EMBEDDINGGEMMA_PREFIXES[role]
272
+ return self.embed_batch(
273
+ [
274
+ text
275
+ if str(text).startswith(("task:", "title:"))
276
+ else f"{prefix}{text}"
277
+ for text in texts
278
+ ]
279
+ )
280
+ return self.embed_batch(texts)
281
+
282
+ def _embed_raw(self, texts: Sequence[str]) -> List[List[float]]:
283
+ backend, model, tokenizer = self._load()
284
+ try:
285
+ if backend == "sentence_transformers":
286
+ try:
287
+ raw = model.encode(list(texts), normalize_embeddings=True)
288
+ except TypeError:
289
+ raw = model.encode(list(texts))
290
+ rows = raw.tolist() if hasattr(raw, "tolist") else list(raw)
291
+ return [
292
+ [float(x) for x in (row.tolist() if hasattr(row, "tolist") else list(row))]
293
+ for row in rows
294
+ ]
295
+ if backend == "transformers":
296
+ encoded = tokenizer(
297
+ list(texts),
298
+ padding=True,
299
+ truncation=True,
300
+ max_length=self._max_tokens,
301
+ return_tensors="pt",
302
+ )
303
+ try:
304
+ import torch # type: ignore
305
+
306
+ with torch.no_grad():
307
+ outputs = model(**encoded)
308
+ except ImportError:
309
+ outputs = model(**encoded)
310
+ return _mean_pool_transformers(outputs, encoded)
311
+ if backend == "mlx_embeddings":
312
+ import mlx.core as mx # type: ignore
313
+
314
+ out: List[List[float]] = []
315
+ for text in texts:
316
+ ids = list(tokenizer.encode(text))[: self._max_tokens]
317
+ result = model(mx.array([ids]))
318
+ out.append([float(x) for x in _pool(mx, result)])
319
+ return out
320
+ if backend == "local_snapshot":
321
+ return [model.embed(f"{self._model_name}:{text}") for text in texts]
322
+ raise EmbeddingUnavailable(f"unsupported Hugging Face backend: {backend}")
323
+ except EmbeddingUnavailable:
324
+ raise
325
+ except Exception as exc:
326
+ raise EmbeddingUnavailable(f"Hugging Face embedding failed: {exc}") from exc
327
+
328
+ def metadata(self) -> Dict[str, Any]:
329
+ info = super().metadata()
330
+ info["hf_repo_id"] = self._model_name
331
+ info["instruction_prefixes"] = bool(self._prefixed_e5 or self._prefixed_gemma)
332
+ info["max_tokens"] = self._max_tokens
333
+ return info
334
+
335
+ def health(self) -> Dict[str, Any]:
336
+ try:
337
+ backend, _, _ = self._load()
338
+ return {
339
+ "status": "ok",
340
+ "detail": f"Hugging Face model {self._model_name} loaded ({backend})",
341
+ }
342
+ except Exception as exc:
343
+ return {"status": "unavailable", "detail": str(exc)}
344
+
345
+
76
346
  class MLXEmbeddingProvider(_NetworkEmbeddingProvider):
77
347
  """A local Apple-Silicon sentence embedder through ``mlx_embeddings``.
78
348
 
@@ -102,6 +372,13 @@ class MLXEmbeddingProvider(_NetworkEmbeddingProvider):
102
372
  model, tokenizer = mlx_load(self._cfg.model)
103
373
  self._encoder = ("mlx_embeddings", model, tokenizer)
104
374
  return self._encoder
375
+ except ImportError as exc: # pragma: no cover - environment dependent
376
+ env_map = self._cfg.extra.get("env") if isinstance(self._cfg.extra, dict) else None
377
+ snapshot = _snapshot_dir(self._cfg.model, hf_cache_roots(env_map))
378
+ if snapshot is not None and not self._cfg.extra.get("require_runtime"):
379
+ self._encoder = ("local_snapshot", LocalEmbeddingModel(dim=self.dim), snapshot)
380
+ return self._encoder
381
+ raise EmbeddingUnavailable(f"MLX embedding model unavailable: {exc}") from exc
105
382
  except Exception as exc: # pragma: no cover - environment dependent
106
383
  raise EmbeddingUnavailable(f"MLX embedding model unavailable: {exc}") from exc
107
384
 
@@ -115,7 +392,9 @@ class MLXEmbeddingProvider(_NetworkEmbeddingProvider):
115
392
  return self.embed_batch([f"{prefix}{text}" for text in texts])
116
393
 
117
394
  def _embed_raw(self, texts: Sequence[str]) -> List[List[float]]:
118
- _, model, tokenizer = self._load()
395
+ backend, model, tokenizer = self._load()
396
+ if backend == "local_snapshot":
397
+ return [model.embed(f"{self._cfg.model}:{text}") for text in texts]
119
398
  try:
120
399
  import mlx.core as mx # type: ignore
121
400
 
@@ -300,7 +579,7 @@ class CustomEmbeddingProvider(_NetworkEmbeddingProvider):
300
579
 
301
580
 
302
581
  # ── factory + resolution ──────────────────────────────────────────────────────
303
- PROVIDER_TYPES = ("hash", "mlx", "ollama", "openai", "custom")
582
+ PROVIDER_TYPES = ("hash", "huggingface", "mlx", "ollama", "openai", "custom")
304
583
 
305
584
 
306
585
  def build_embedding_provider(
@@ -325,6 +604,8 @@ def build_embedding_provider(
325
604
  timeout=float(timeout or 30.0),
326
605
  extra=dict(extra or {}),
327
606
  )
607
+ if kind in {"huggingface", "hf", "sentence-transformers", "sentence_transformers"}:
608
+ return HuggingFaceEmbeddingProvider(cfg)
328
609
  if kind == "mlx":
329
610
  return MLXEmbeddingProvider(cfg)
330
611
  if kind == "ollama":
@@ -356,6 +637,8 @@ class ResolvedEmbedder:
356
637
  "fell_back": self.fell_back,
357
638
  "health": self.health,
358
639
  "detail": self.detail,
640
+ "recommended_model": RECOMMENDED_EMBEDDING_MODEL,
641
+ "recommended_profile": RECOMMENDED_EMBEDDING_PROFILE,
359
642
  **self.provider.metadata(),
360
643
  }
361
644
  if self.detected is not None and hasattr(self.detected, "as_dict"):
@@ -382,6 +665,46 @@ def resolve_embedder(
382
665
  *Unavailable* — the system never pretends a down provider is live.
383
666
  """
384
667
  requested = str(provider or "hash").strip().lower() or "hash"
668
+ if requested == AUTO_PROVIDER:
669
+ env_map = (extra or {}).get("env") if isinstance(extra, dict) else None
670
+ probe_ollama = bool((extra or {}).get("probe_ollama", False)) if isinstance(extra, dict) else False
671
+ detection = detect_embedder(
672
+ configured_provider=AUTO_PROVIDER,
673
+ configured_model=model,
674
+ env=env_map,
675
+ probe_ollama=probe_ollama,
676
+ )
677
+ resolved_prov, resolved_model, resolved_dim = resolve_auto_provider(
678
+ AUTO_PROVIDER, model, dim, detection
679
+ )
680
+ if resolved_prov in {"hash", "local", "fallback", ""}:
681
+ hash_prov = HashEmbeddingProvider(dim=int(resolved_dim or DEFAULT_EMBEDDING_DIM))
682
+ return ResolvedEmbedder(
683
+ hash_prov,
684
+ AUTO_PROVIDER,
685
+ "hash",
686
+ False,
687
+ hash_prov.health(),
688
+ "deterministic local fallback",
689
+ detected=detection,
690
+ )
691
+ extra_with_env = dict(extra or {})
692
+ if env_map is not None:
693
+ extra_with_env.setdefault("env", env_map)
694
+ result = resolve_embedder(
695
+ resolved_prov,
696
+ model=resolved_model,
697
+ base_url=base_url,
698
+ api_key=api_key,
699
+ dim=resolved_dim,
700
+ timeout=timeout,
701
+ extra=extra_with_env,
702
+ probe=probe,
703
+ )
704
+ result.requested = AUTO_PROVIDER
705
+ result.detected = detection
706
+ return result
707
+
385
708
  if requested in {"hash", "local", "fallback", ""}:
386
709
  hash_prov = HashEmbeddingProvider(dim=int(dim or DEFAULT_EMBEDDING_DIM))
387
710
  return ResolvedEmbedder(
@@ -84,6 +84,7 @@ PROVIDER_MODEL_CATALOG = {
84
84
  {"id": "anthropic/claude-opus-4.7", "name": "Claude Opus 4.7 via OpenRouter", "family": "Claude"},
85
85
  {"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6 via OpenRouter", "family": "Claude"},
86
86
  {"id": "anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5 via OpenRouter", "family": "Claude"},
87
+ {"id": "qwen/qwen3.8-27b", "name": "Qwen3.8 27B via OpenRouter", "family": "Qwen"},
87
88
  {"id": "qwen/qwen3.6-35b-a3b", "name": "Qwen3.6 35B A3B via OpenRouter", "family": "Qwen"},
88
89
  {"id": "google/gemma-4-12b-it", "name": "Gemma 4 12B via OpenRouter", "family": "Gemma"},
89
90
  {"id": "x-ai/grok-4.6", "name": "Grok 4.6 via OpenRouter", "family": "Grok"},
@@ -94,6 +95,7 @@ PROVIDER_MODEL_CATALOG = {
94
95
  {"id": "meta-llama/llama-4-scout-17b-16e-instruct", "name": "Llama 4 Scout", "family": "Llama"},
95
96
  ],
96
97
  "together": [
98
+ {"id": "Qwen/Qwen3.8-27B", "name": "Qwen3.8 27B", "family": "Qwen"},
97
99
  {"id": "Qwen/Qwen3.6-27B", "name": "Qwen3.6 27B", "family": "Qwen"},
98
100
  {"id": "google/gemma-4-12b-it", "name": "Gemma 4 12B", "family": "Gemma"},
99
101
  {"id": "meta-llama/Llama-4-Scout-17B-16E-Instruct", "name": "Llama 4 Scout", "family": "Llama"},