ltcai 12.4.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 (82) hide show
  1. package/README.md +23 -37
  2. package/docs/CHANGELOG.md +13 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +9 -9
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/lattice_brain/__init__.py +1 -1
  11. package/latticeai/__init__.py +1 -1
  12. package/latticeai/api/models.py +21 -1
  13. package/latticeai/api/search.py +8 -1
  14. package/latticeai/api/worker_compute.py +9 -1
  15. package/latticeai/core/embedding_providers/__init__.py +14 -0
  16. package/latticeai/core/embedding_providers/autodetect.py +91 -24
  17. package/latticeai/core/embedding_providers/base.py +10 -2
  18. package/latticeai/core/embedding_providers/profiles.py +44 -0
  19. package/latticeai/core/embedding_providers/text.py +325 -2
  20. package/latticeai/services/architecture_readiness.py +1 -1
  21. package/latticeai/services/embedding_recommendation.py +229 -0
  22. package/latticeai/services/model_capability_registry.py +37 -0
  23. package/latticeai/services/model_catalog.py +130 -0
  24. package/latticeai/services/product_readiness.py +1 -1
  25. package/package.json +1 -1
  26. package/scripts/bump_version.py +106 -6
  27. package/scripts/check_current_release_docs.mjs +1 -1
  28. package/scripts/check_screenshot_pixel_delta.py +6 -2
  29. package/src-tauri/Cargo.lock +10 -10
  30. package/src-tauri/Cargo.toml +1 -1
  31. package/src-tauri/tauri.conf.json +1 -1
  32. package/static/app/asset-manifest.json +44 -44
  33. package/static/app/assets/{Act-inLiZfDE.js → Act-DQ2Ldwo8.js} +2 -2
  34. package/static/app/assets/{AdminConsole-iLFwSJSg.js → AdminConsole-y6EjiXeH.js} +1 -1
  35. package/static/app/assets/{Brain-Cb7noiNc.js → Brain-CRWWw-h9.js} +2 -2
  36. package/static/app/assets/{BrainHome-CfiJ4tau.js → BrainHome-cmvTu7Ea.js} +1 -1
  37. package/static/app/assets/{BrainSignals-ShxKNNqr.js → BrainSignals-gaNn5E4-.js} +1 -1
  38. package/static/app/assets/{Capture-Cajj1Trf.js → Capture-BJK7in1j.js} +1 -1
  39. package/static/app/assets/{Chronicle-CPd8GP_O.js → Chronicle-BuYZ9lMt.js} +1 -1
  40. package/static/app/assets/{CommandPalette-Cb_GWoYC.js → CommandPalette-BjFwuKaA.js} +1 -1
  41. package/static/app/assets/{DigitalBrainExplorer-Df6KVrBz.js → DigitalBrainExplorer-DTBRPYJ3.js} +1 -1
  42. package/static/app/assets/Library-BsmZ-YVh.js +1 -0
  43. package/static/app/assets/{LivingBrain-u9X_XGC2.js → LivingBrain-aT9hgkPV.js} +1 -1
  44. package/static/app/assets/ProductFlow-Ct0QLLY5.js +1 -0
  45. package/static/app/assets/{ReviewCard-C64YFHOR.js → ReviewCard-CdooYtWr.js} +1 -1
  46. package/static/app/assets/{RunsListPanel-DKZEJcxF.js → RunsListPanel-Btf2DIrG.js} +1 -1
  47. package/static/app/assets/{System-Dretrknf.js → System-B77M4t-M.js} +1 -1
  48. package/static/app/assets/{WorkflowGraph-CE6XgWjJ.js → WorkflowGraph-u3ZKLvZe.js} +1 -1
  49. package/static/app/assets/{WorkflowsPanel-MKU05J7_.js → WorkflowsPanel--nTFq3Cs.js} +2 -2
  50. package/static/app/assets/{actHelpers-BjMnKMCI.js → actHelpers-xNnCfUeC.js} +1 -1
  51. package/static/app/assets/arrow-left-Cp4NHbfX.js +1 -0
  52. package/static/app/assets/{bot-Qgvc2PGU.js → bot-CXwvpODC.js} +1 -1
  53. package/static/app/assets/{brain-BwGnubn8.js → brain-BzjoGgor.js} +1 -1
  54. package/static/app/assets/{button-DEloiIsj.js → button-_hHOhp7g.js} +1 -1
  55. package/static/app/assets/circle-check-qG5dMahn.js +1 -0
  56. package/static/app/assets/{circle-pause-CkkmXxA9.js → circle-pause-DBOqYYMh.js} +1 -1
  57. package/static/app/assets/{circle-play-rnb6A1zq.js → circle-play-DA0Vf2NH.js} +1 -1
  58. package/static/app/assets/{cpu-CzcGFQQq.js → cpu-DwsE0_o3.js} +1 -1
  59. package/static/app/assets/{download-BTQigJvp.js → download-iCoe2f2Q.js} +1 -1
  60. package/static/app/assets/{folder-open-CIXPPhhm.js → folder-open-C1vNOMhy.js} +1 -1
  61. package/static/app/assets/{hard-drive-CZXxdIcT.js → hard-drive-a73KaYth.js} +1 -1
  62. package/static/app/assets/index-Dbb2YhYw.css +2 -0
  63. package/static/app/assets/{index-CHQbWj9a.js → index-DpuEeYmP.js} +2 -2
  64. package/static/app/assets/{input-CTZHuOXe.js → input-BclQNHyj.js} +1 -1
  65. package/static/app/assets/{link-2-BsG1NQUA.js → link-2-D23wihBe.js} +1 -1
  66. package/static/app/assets/{permissionCopy-klyWL3ai.js → permissionCopy-DiGZRKAY.js} +1 -1
  67. package/static/app/assets/{primitives-ZmN4jzq-.js → primitives-1S-XCgmz.js} +1 -1
  68. package/static/app/assets/search-Cl2k5uhD.js +1 -0
  69. package/static/app/assets/{share-2-Cuv9rj7A.js → share-2-47_av_aF.js} +1 -1
  70. package/static/app/assets/{shield-alert-KKySFd03.js → shield-alert-BvKq2P-G.js} +1 -1
  71. package/static/app/assets/{textarea-CBwqgGSA.js → textarea-DcLO52bu.js} +1 -1
  72. package/static/app/assets/{useFocusTrap-D93vds_Z.js → useFocusTrap-CpqVPzJ_.js} +1 -1
  73. package/static/app/assets/{useMutation-B9hHPD0p.js → useMutation-DhI3jqCN.js} +1 -1
  74. package/static/app/assets/workspace-DwNHbqaX.js +1 -0
  75. package/static/app/index.html +2 -2
  76. package/static/app/assets/Library-Dk5Mefl_.js +0 -1
  77. package/static/app/assets/ProductFlow-CpX0BMr9.js +0 -1
  78. package/static/app/assets/arrow-left-Cbck4Ig2.js +0 -1
  79. package/static/app/assets/circle-check-CDygnRMn.js +0 -1
  80. package/static/app/assets/index-C_dThdD6.css +0 -2
  81. package/static/app/assets/search-xjKVb4Nh.js +0 -1
  82. package/static/app/assets/workspace-JGzrc0D8.js +0 -1
@@ -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(
@@ -19,7 +19,7 @@ from dataclasses import dataclass
19
19
  from pathlib import Path
20
20
  from typing import Any, Dict, List
21
21
 
22
- ARCHITECTURE_VERSION_TARGET = "12.4.0"
22
+ ARCHITECTURE_VERSION_TARGET = "12.5.0"
23
23
 
24
24
  PREFERRED_REFACTORING_ORDER = [
25
25
  "agent-runtime",