ltcai 11.7.0 → 12.0.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 (174) hide show
  1. package/README.md +100 -76
  2. package/docs/BENCHMARKS.md +9 -2
  3. package/docs/CHANGELOG.md +249 -0
  4. package/docs/CI_AND_RELEASE_GATES.md +126 -41
  5. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  6. package/docs/DEVELOPMENT.md +271 -103
  7. package/docs/ENTERPRISE.md +1 -1
  8. package/docs/LEGACY_COMPATIBILITY.md +10 -6
  9. package/docs/MULTI_AGENT_RUNTIME.md +4 -4
  10. package/docs/ONBOARDING.md +16 -4
  11. package/docs/OPERATIONS.md +14 -1
  12. package/docs/PERMISSION_MODE.md +14 -9
  13. package/docs/REALTIME_COLLABORATION.md +1 -1
  14. package/docs/ROADMAP.md +113 -0
  15. package/docs/TRUST_MODEL.md +28 -7
  16. package/docs/USABILITY_AUDIT.md +5 -0
  17. package/docs/WHY_LATTICE.md +13 -5
  18. package/docs/WORKFLOW_DESIGNER.md +2 -2
  19. package/docs/kg-schema.md +57 -7
  20. package/docs/mcp-tools.md +93 -82
  21. package/docs/security-model.md +6 -3
  22. package/lattice_brain/__init__.py +1 -1
  23. package/lattice_brain/graph/_kg_common/__init__.py +1 -54
  24. package/lattice_brain/graph/_kg_common/extraction.py +459 -105
  25. package/lattice_brain/graph/_kg_common/normalize.py +305 -0
  26. package/lattice_brain/graph/_kg_common/patterns.py +275 -0
  27. package/lattice_brain/graph/_kg_common/relations.py +12 -3
  28. package/lattice_brain/graph/_kg_common/sections.py +107 -0
  29. package/lattice_brain/graph/_kg_common/text.py +14 -450
  30. package/lattice_brain/graph/_kg_constants.py +7 -0
  31. package/lattice_brain/ingestion/__init__.py +6 -3
  32. package/lattice_brain/multimodal/__init__.py +9 -3
  33. package/latticeai/__init__.py +1 -1
  34. package/latticeai/api/agent_worker_seam.py +44 -1
  35. package/latticeai/api/models.py +18 -110
  36. package/latticeai/api/search.py +7 -30
  37. package/latticeai/api/worker_compute.py +127 -106
  38. package/latticeai/api/worker_seams.py +17 -2
  39. package/latticeai/core/embedding_providers/__init__.py +16 -0
  40. package/latticeai/core/embedding_providers/autodetect.py +302 -0
  41. package/latticeai/core/embedding_providers/base.py +25 -0
  42. package/latticeai/core/embedding_providers/profiles.py +44 -0
  43. package/latticeai/core/embedding_providers/text.py +74 -8
  44. package/latticeai/core/http_origin.py +3 -3
  45. package/latticeai/core/messages.py +0 -5
  46. package/latticeai/core/policy.py +1 -6
  47. package/latticeai/core/quiet.py +1 -20
  48. package/latticeai/core/security.py +29 -83
  49. package/latticeai/core/sessions.py +95 -4
  50. package/latticeai/core/users.py +0 -38
  51. package/latticeai/core/vector_index/__init__.py +61 -0
  52. package/latticeai/core/vector_index/hnsw.py +383 -0
  53. package/latticeai/core/vector_index/sidecar.py +329 -0
  54. package/latticeai/models/router/catalog.py +2 -2
  55. package/latticeai/models/router/generation.py +176 -30
  56. package/latticeai/models/router/loading.py +150 -9
  57. package/latticeai/runtime/access_runtime.py +7 -4
  58. package/latticeai/runtime/brain_runtime.py +43 -9
  59. package/latticeai/runtime/build_phases/features.py +8 -31
  60. package/latticeai/runtime/build_phases/foundation.py +7 -16
  61. package/latticeai/runtime/build_phases/web.py +3 -3
  62. package/latticeai/runtime/build_phases/worker_profile.py +29 -27
  63. package/latticeai/runtime/runtime_context.py +0 -2
  64. package/latticeai/services/architecture_readiness.py +18 -19
  65. package/latticeai/services/process_audit.py +1 -22
  66. package/latticeai/services/product_readiness.py +39 -12
  67. package/latticeai/services/search_service.py +7 -0
  68. package/latticeai/services/voice_capture.py +8 -28
  69. package/latticeai/tools/__init__.py +12 -47
  70. package/latticeai/tools/commands.py +9 -15
  71. package/latticeai/tools/documents.py +12 -0
  72. package/latticeai/tools/knowledge.py +0 -6
  73. package/latticeai/tools/markup.py +152 -0
  74. package/package.json +4 -5
  75. package/requirements.txt +0 -1
  76. package/scripts/check_current_release_docs.mjs +1 -1
  77. package/scripts/check_openapi_drift.mjs +3 -2
  78. package/scripts/check_server_i18n.mjs +5 -4
  79. package/scripts/compose_openapi.py +4 -1
  80. package/scripts/export_openapi.py +5 -4
  81. package/scripts/gen_worker_allowlist_fixture.py +2 -2
  82. package/scripts/openapi_route_families.json +19 -74
  83. package/scripts/publish_release.mjs +157 -0
  84. package/scripts/release_screen_claims.json +144 -28
  85. package/src-tauri/Cargo.lock +45 -10
  86. package/src-tauri/Cargo.toml +1 -1
  87. package/src-tauri/tauri.conf.json +1 -1
  88. package/static/app/asset-manifest.json +47 -41
  89. package/static/app/assets/Act-Cf1L2709.js +2 -0
  90. package/static/app/assets/AdminConsole-DPAbLTYV.js +1 -0
  91. package/static/app/assets/Brain-DqamGrj-.js +2 -0
  92. package/static/app/assets/BrainHome-MHe2_RYs.js +2 -0
  93. package/static/app/assets/BrainSignals-CQPPfyyH.js +1 -0
  94. package/static/app/assets/Capture-DGdIH_Zc.js +1 -0
  95. package/static/app/assets/Chronicle-C-UlCJoJ.js +1 -0
  96. package/static/app/assets/CommandPalette-WNT4EqUX.js +1 -0
  97. package/static/app/assets/DigitalBrainExplorer-CEBH5Cwc.js +321 -0
  98. package/static/app/assets/Library-C6xd1dlf.js +1 -0
  99. package/static/app/assets/LivingBrain-BEk-0ohw.js +1 -0
  100. package/static/app/assets/ProductFlow-CZLm5iXh.js +1 -0
  101. package/static/app/assets/QueryClientProvider-B3OjqSyJ.js +1 -0
  102. package/static/app/assets/{ReviewCard-HXRle3qq.js → ReviewCard-CEHG6evf.js} +2 -2
  103. package/static/app/assets/RunsListPanel-CLtEJSRW.js +1 -0
  104. package/static/app/assets/System-CAxwBUXw.js +1 -0
  105. package/static/app/assets/WorkflowGraph-Dj10RuGE.js +1 -0
  106. package/static/app/assets/WorkflowsPanel-Kyeh_LIT.js +2 -0
  107. package/static/app/assets/actHelpers-CtSmK9Dw.js +1 -0
  108. package/static/app/assets/arrow-left-CRl5EO4D.js +1 -0
  109. package/static/app/assets/{bot-Cn8bWRuq.js → bot-DhUGRel2.js} +1 -1
  110. package/static/app/assets/brain-CLkhHsHF.js +1 -0
  111. package/static/app/assets/button-CmaEqG1T.js +1 -0
  112. package/static/app/assets/circle-check-CFgejkOS.js +1 -0
  113. package/static/app/assets/{circle-pause-CmzC_apg.js → circle-pause-l96izbxj.js} +1 -1
  114. package/static/app/assets/{circle-play-D8mW2aQ7.js → circle-play-CrZa25_q.js} +1 -1
  115. package/static/app/assets/{cpu-DZcdd0PZ.js → cpu-BaXudqwl.js} +1 -1
  116. package/static/app/assets/{download-bv1KEPGQ.js → download-hCVFPiyc.js} +1 -1
  117. package/static/app/assets/{folder-open-d-Pip5gr.js → folder-open-CHL82Yp7.js} +1 -1
  118. package/static/app/assets/{hard-drive-D20iavUb.js → hard-drive-DDzET7lk.js} +1 -1
  119. package/static/app/assets/index-CB93CZWW.css +2 -0
  120. package/static/app/assets/index-D2H-wSl6.js +13 -0
  121. package/static/app/assets/input-Df1CAY_I.js +1 -0
  122. package/static/app/assets/jsx-runtime-bzQ4Vb5N.js +1 -0
  123. package/static/app/assets/{link-2-BPJOFlAy.js → link-2-xNnTIX1_.js} +1 -1
  124. package/static/app/assets/{permissionCopy-ChdJd493.js → permissionCopy-D3aWHco-.js} +1 -1
  125. package/static/app/assets/primitives-BioD2slS.js +1 -0
  126. package/static/app/assets/search-BzBw8YcW.js +1 -0
  127. package/static/app/assets/{share-2-YNX_NtMU.js → share-2-FkzGf8Df.js} +1 -1
  128. package/static/app/assets/{shield-alert-DuQ3zrVL.js → shield-alert-B3dwzik4.js} +1 -1
  129. package/static/app/assets/sourceMeta-DQSY_tah.js +1 -0
  130. package/static/app/assets/textarea-P8o6pvOP.js +1 -0
  131. package/static/app/assets/useFocusTrap-hswOIkXE.js +1 -0
  132. package/static/app/assets/useMutation-OJLrYSRA.js +1 -0
  133. package/static/app/assets/workspace-BCuk3Ku9.js +1 -0
  134. package/static/app/index.html +4 -4
  135. package/static/sw.js +1 -1
  136. package/lattice_brain/ingestion/pipeline.py +0 -108
  137. package/latticeai/api/local_files.py +0 -44
  138. package/latticeai/api/tools.py +0 -126
  139. package/latticeai/api/voice_capture.py +0 -32
  140. package/latticeai/core/agent_permission.py +0 -85
  141. package/scripts/agent_eval.py +0 -34
  142. package/scripts/brain_quality_eval.py +0 -37
  143. package/scripts/check_legacy_debt.mjs +0 -91
  144. package/scripts/check_python.py +0 -100
  145. package/scripts/chunking_parity_corpus.py +0 -449
  146. package/scripts/generate_agent_parity_fixtures.py +0 -771
  147. package/scripts/generate_chunking_parity_fixtures.py +0 -259
  148. package/static/app/assets/Act-BPcVAbOL.js +0 -1
  149. package/static/app/assets/AdminConsole-Bw1ATQL0.js +0 -1
  150. package/static/app/assets/Brain-CT92Kos0.js +0 -321
  151. package/static/app/assets/BrainHome-CFBkt1K_.js +0 -2
  152. package/static/app/assets/BrainSignals-ReLWF2H8.js +0 -1
  153. package/static/app/assets/Capture-BsTokYkk.js +0 -1
  154. package/static/app/assets/Chronicle-B6f0T9id.js +0 -1
  155. package/static/app/assets/CommandPalette-CuvjTv1u.js +0 -1
  156. package/static/app/assets/Library-BGJbG9Hd.js +0 -1
  157. package/static/app/assets/LivingBrain-DGYK_Jsa.js +0 -1
  158. package/static/app/assets/ProductFlow-DXBC6brE.js +0 -1
  159. package/static/app/assets/System-CMHSO9qM.js +0 -1
  160. package/static/app/assets/arrow-left-BfmkskWx.js +0 -1
  161. package/static/app/assets/brain-CQJberbE.js +0 -1
  162. package/static/app/assets/button-Ct9f2_oT.js +0 -1
  163. package/static/app/assets/circle-check-DruOxB-4.js +0 -1
  164. package/static/app/assets/index-D9x-kSNy.css +0 -2
  165. package/static/app/assets/index-Do83hDzJ.js +0 -10
  166. package/static/app/assets/input-BLXVNmj1.js +0 -1
  167. package/static/app/assets/primitives-Cv5tbZBY.js +0 -1
  168. package/static/app/assets/search-CT9aho2j.js +0 -1
  169. package/static/app/assets/textarea-DqwLnli4.js +0 -1
  170. package/static/app/assets/useFocusTrap-ZVI98jaW.js +0 -1
  171. package/static/app/assets/useMutation-CVC4qv_D.js +0 -1
  172. package/static/app/assets/useQuery-C7BeG4HU.js +0 -1
  173. package/static/app/assets/utils-CiFtIdZq.js +0 -4
  174. package/static/app/assets/workspace-DQz9vIId.js +0 -1
@@ -0,0 +1,302 @@
1
+ """Find the real embedder that is already on this machine.
2
+
3
+ The default text embedder is :class:`~.text.HashEmbeddingProvider` — feature
4
+ hashing into 384 dimensions. It is deterministic, offline and always available,
5
+ and it is **not semantic**: two sentences that mean the same thing in different
6
+ words score near zero against each other. Every recall complaint about "왜 못
7
+ 찾지" that is not a keyword problem is this.
8
+
9
+ A real provider has always been configurable (``LATTICEAI_EMBEDDING_PROVIDER``),
10
+ but nothing ever *looked* for one, so a machine with a perfectly good embedding
11
+ model already in its Hugging Face cache still hashed. This module is that look:
12
+
13
+ 1. an explicit configuration always wins and is never second-guessed;
14
+ 2. otherwise, if a known embedding model is already **downloaded**, name it —
15
+ filesystem only, no network, no download;
16
+ 3. otherwise, if an Ollama server is reachable and has an embedding model
17
+ pulled, name that;
18
+ 4. otherwise, nothing was found, and the hash fallback stays.
19
+
20
+ Detection **reports**; adoption is a separate decision the caller makes (see
21
+ :func:`resolve_auto_provider`). ``LATTICEAI_EMBEDDING_PROVIDER=auto`` adopts
22
+ what was found; anything else leaves the resolution exactly as it was and the
23
+ finding travels to ``GET /api/embeddings/status`` as ``detected``, so the setup
24
+ surface can offer the switch instead of taking it.
25
+
26
+ ## Why adoption is not automatic
27
+
28
+ Rust files every vector under ``(embedding_model, embedding_dim)`` and searches
29
+ only rows whose identity matches the embedder it holds — today the hash model.
30
+ Switching the *worker's* provider therefore does not corrupt anything (the two
31
+ identities never mix), but until the read path can embed a query through the
32
+ same provider, provider vectors are written and never read. Adopting silently
33
+ would buy inference cost and no recall. The switch is real, tested and one env
34
+ var away; it is not a default.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import os
40
+ from dataclasses import dataclass, field
41
+ from pathlib import Path
42
+ from typing import Any, Dict, List, Optional, Tuple
43
+
44
+ #: ``LATTICEAI_EMBEDDING_PROVIDER=auto`` — adopt whatever detection found.
45
+ AUTO_PROVIDER = "auto"
46
+ #: Set to ``0``/``false`` to skip detection entirely (and its Ollama probe).
47
+ AUTODETECT_ENV = "LATTICEAI_EMBEDDING_AUTODETECT"
48
+ #: Where the Ollama probe looks when nothing else says.
49
+ OLLAMA_BASE_ENV = "LATTICEAI_EMBEDDING_BASE_URL"
50
+ DEFAULT_OLLAMA_BASE = "http://127.0.0.1:11434"
51
+
52
+ #: Local MLX embedding models this build knows how to drive, best first.
53
+ #: ``dim`` is the model's true output width; ``prefix`` marks the E5 family,
54
+ #: whose quality depends on the ``query:`` / ``passage:`` instruction.
55
+ LOCAL_MLX_MODELS: Tuple[Tuple[str, int, bool], ...] = (
56
+ ("mlx-community/multilingual-e5-small-mlx", 384, True),
57
+ ("mlx-community/multilingual-e5-base-mlx", 768, True),
58
+ ("mlx-community/multilingual-e5-large-mlx", 1024, True),
59
+ ("mlx-community/snowflake-arctic-embed-l-v2.0-8bit", 1024, False),
60
+ ("mlx-community/embeddinggemma-300m-4bit", 768, False),
61
+ ("mlx-community/bge-m3", 1024, False),
62
+ )
63
+
64
+ #: Ollama model names that are embedders rather than chat models.
65
+ OLLAMA_EMBEDDING_MODELS: Tuple[Tuple[str, int], ...] = (
66
+ ("bge-m3", 1024),
67
+ ("mxbai-embed-large", 1024),
68
+ ("nomic-embed-text", 768),
69
+ ("all-minilm", 384),
70
+ )
71
+
72
+
73
+ @dataclass
74
+ class Detection:
75
+ """What was found, and how. Every field is safe to show a user."""
76
+
77
+ #: ``"mlx"`` | ``"ollama"`` | ``""`` when nothing was found.
78
+ provider: str = ""
79
+ model: str = ""
80
+ dim: int = 0
81
+ #: ``configured`` | ``local_model`` | ``ollama`` | ``none``.
82
+ source: str = "none"
83
+ detail: str = ""
84
+ #: Every candidate that was looked for and not found, for the UI to offer.
85
+ candidates: List[Dict[str, Any]] = field(default_factory=list)
86
+
87
+ @property
88
+ def found(self) -> bool:
89
+ return bool(self.provider)
90
+
91
+ def as_dict(self) -> Dict[str, Any]:
92
+ return {
93
+ "found": self.found,
94
+ "provider": self.provider,
95
+ "model": self.model,
96
+ "dim": self.dim,
97
+ "source": self.source,
98
+ "detail": self.detail,
99
+ "candidates": list(self.candidates),
100
+ }
101
+
102
+
103
+ def autodetect_enabled(env: Optional[Dict[str, str]] = None) -> bool:
104
+ """Whether to look at all. On unless explicitly switched off."""
105
+ raw = (env or dict(os.environ)).get(AUTODETECT_ENV, "1").strip().lower()
106
+ return raw not in {"0", "false", "no", "off"}
107
+
108
+
109
+ def hf_cache_roots(env: Optional[Dict[str, str]] = None) -> List[Path]:
110
+ """Every directory a Hugging Face snapshot could be under, in order."""
111
+ values = env or dict(os.environ)
112
+ roots: List[Path] = []
113
+ for key in ("HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE"):
114
+ raw = values.get(key, "").strip()
115
+ if raw:
116
+ roots.append(Path(raw))
117
+ home = values.get("HF_HOME", "").strip()
118
+ if home:
119
+ roots.append(Path(home) / "hub")
120
+ roots.append(Path(values.get("HOME", "~")).expanduser() / ".cache/huggingface/hub")
121
+ seen: List[Path] = []
122
+ for root in roots:
123
+ if root not in seen:
124
+ seen.append(root)
125
+ return seen
126
+
127
+
128
+ def _snapshot_dir(repo_id: str, roots: List[Path]) -> Optional[Path]:
129
+ """The snapshot directory holding this repo's weights, if it is on disk.
130
+
131
+ A cache entry exists as soon as *anything* has been fetched, so presence of
132
+ the directory is not enough: a snapshot with a ``config.json`` and at least
133
+ one weight file is what "already downloaded" has to mean, or the detector
134
+ names a model that cannot load.
135
+ """
136
+ flattened = "models--" + repo_id.replace("/", "--")
137
+ for root in roots:
138
+ snapshots = root / flattened / "snapshots"
139
+ if not snapshots.is_dir():
140
+ continue
141
+ for snapshot in sorted(snapshots.iterdir()):
142
+ if not snapshot.is_dir():
143
+ continue
144
+ if not (snapshot / "config.json").exists():
145
+ continue
146
+ weights = any(
147
+ (snapshot / name).exists()
148
+ for name in ("model.safetensors", "weights.safetensors")
149
+ ) or any(snapshot.glob("*.safetensors"))
150
+ if weights:
151
+ return snapshot
152
+ return None
153
+
154
+
155
+ def detect_local_mlx(env: Optional[Dict[str, str]] = None) -> Detection:
156
+ """The best already-downloaded MLX embedding model, or an empty finding."""
157
+ roots = hf_cache_roots(env)
158
+ candidates: List[Dict[str, Any]] = []
159
+ for repo_id, dim, prefixed in LOCAL_MLX_MODELS:
160
+ snapshot = _snapshot_dir(repo_id, roots)
161
+ candidates.append(
162
+ {
163
+ "provider": "mlx",
164
+ "model": repo_id,
165
+ "dim": dim,
166
+ "downloaded": snapshot is not None,
167
+ "e5_prefixes": prefixed,
168
+ }
169
+ )
170
+ for candidate in candidates:
171
+ if candidate["downloaded"]:
172
+ return Detection(
173
+ provider="mlx",
174
+ model=str(candidate["model"]),
175
+ dim=int(candidate["dim"]),
176
+ source="local_model",
177
+ detail=f"{candidate['model']} is already in the local model cache",
178
+ candidates=candidates,
179
+ )
180
+ return Detection(
181
+ source="none",
182
+ detail="no known embedding model is downloaded yet",
183
+ candidates=candidates,
184
+ )
185
+
186
+
187
+ def detect_ollama(
188
+ base_url: str = "",
189
+ timeout: float = 1.5,
190
+ env: Optional[Dict[str, str]] = None,
191
+ ) -> Detection:
192
+ """An Ollama server with an embedding model pulled, or an empty finding.
193
+
194
+ One localhost GET with a short timeout. Any failure — no server, no httpx,
195
+ a slow answer — is "not found", never an error: detection must not be able
196
+ to delay or fail a boot.
197
+ """
198
+ values = env or dict(os.environ)
199
+ base = (base_url or values.get(OLLAMA_BASE_ENV, "") or DEFAULT_OLLAMA_BASE).rstrip("/")
200
+ try:
201
+ import httpx
202
+
203
+ with httpx.Client(timeout=timeout) as client:
204
+ response = client.get(f"{base}/api/tags")
205
+ response.raise_for_status()
206
+ payload = response.json()
207
+ except Exception as exc:
208
+ return Detection(source="none", detail=f"no Ollama server at {base}: {exc}")
209
+ names = [
210
+ str(model.get("name") or "")
211
+ for model in (payload.get("models") or [])
212
+ if isinstance(model, dict)
213
+ ]
214
+ for known, dim in OLLAMA_EMBEDDING_MODELS:
215
+ for name in names:
216
+ if name.split(":")[0] == known:
217
+ return Detection(
218
+ provider="ollama",
219
+ model=name,
220
+ dim=dim,
221
+ source="ollama",
222
+ detail=f"{name} is pulled on the Ollama server at {base}",
223
+ )
224
+ return Detection(
225
+ source="none",
226
+ detail=f"Ollama at {base} has no embedding model pulled",
227
+ )
228
+
229
+
230
+ def detect_embedder(
231
+ configured_provider: str = "",
232
+ configured_model: str = "",
233
+ env: Optional[Dict[str, str]] = None,
234
+ probe_ollama: bool = True,
235
+ ) -> Detection:
236
+ """What this machine could embed with, without downloading anything.
237
+
238
+ A configured provider is reported back as ``source="configured"`` and no
239
+ probing happens: the operator already answered the question.
240
+ """
241
+ values = env or dict(os.environ)
242
+ configured = str(configured_provider or "").strip().lower()
243
+ if configured and configured not in {"hash", "local", "fallback", AUTO_PROVIDER}:
244
+ return Detection(
245
+ provider=configured,
246
+ model=configured_model,
247
+ source="configured",
248
+ detail=f"{configured} is configured explicitly",
249
+ )
250
+ if not autodetect_enabled(values):
251
+ return Detection(source="none", detail=f"{AUTODETECT_ENV} is off")
252
+
253
+ local = detect_local_mlx(values)
254
+ if local.found:
255
+ return local
256
+ if probe_ollama:
257
+ ollama = detect_ollama(env=values)
258
+ if ollama.found:
259
+ ollama.candidates = local.candidates
260
+ return ollama
261
+ return local
262
+
263
+
264
+ def resolve_auto_provider(
265
+ configured_provider: str,
266
+ configured_model: str,
267
+ configured_dim: int,
268
+ detection: Detection,
269
+ ) -> Tuple[str, str, int]:
270
+ """The ``(provider, model, dim)`` the embedder should actually be built with.
271
+
272
+ Only ``provider == "auto"`` changes anything, and only when detection found
273
+ something; ``auto`` with nothing found resolves to ``hash``, which is the
274
+ honest answer rather than a construction that will fail its probe.
275
+ Explicit configuration is returned untouched.
276
+ """
277
+ requested = str(configured_provider or "").strip().lower()
278
+ if requested != AUTO_PROVIDER:
279
+ return configured_provider, configured_model, configured_dim
280
+ if not detection.found:
281
+ return "hash", "", configured_dim
282
+ return (
283
+ detection.provider,
284
+ configured_model or detection.model,
285
+ configured_dim or detection.dim,
286
+ )
287
+
288
+
289
+ __all__ = [
290
+ "AUTODETECT_ENV",
291
+ "AUTO_PROVIDER",
292
+ "DEFAULT_OLLAMA_BASE",
293
+ "LOCAL_MLX_MODELS",
294
+ "OLLAMA_EMBEDDING_MODELS",
295
+ "Detection",
296
+ "autodetect_enabled",
297
+ "detect_embedder",
298
+ "detect_local_mlx",
299
+ "detect_ollama",
300
+ "hf_cache_roots",
301
+ "resolve_auto_provider",
302
+ ]
@@ -45,8 +45,18 @@ _KNOWN_DIMS = {
45
45
  "gte-small": 384,
46
46
  "gte-base": 768,
47
47
  "gte-large": 1024,
48
+ "e5-small": 384,
49
+ "e5-base": 768,
48
50
  "e5-large": 1024,
51
+ "multilingual-e5-small": 384,
52
+ "multilingual-e5-small-mlx": 384,
53
+ "multilingual-e5-base": 768,
54
+ "multilingual-e5-base-mlx": 768,
49
55
  "multilingual-e5-large": 1024,
56
+ "multilingual-e5-large-mlx": 1024,
57
+ "snowflake-arctic-embed-l-v2.0-8bit": 1024,
58
+ "embeddinggemma-300m-4bit": 768,
59
+ "embeddinggemma-300m-8bit": 768,
50
60
  "text-embedding-3-small": 1536,
51
61
  "text-embedding-3-large": 3072,
52
62
  "text-embedding-ada-002": 1536,
@@ -86,6 +96,21 @@ class EmbeddingProvider:
86
96
  def embed_batch(self, texts: Sequence[str]) -> List[List[float]]:
87
97
  raise NotImplementedError
88
98
 
99
+ # ── optional: asymmetric models ───────────────────────────────────────
100
+ def embed_batch_for(
101
+ self, texts: Sequence[str], kind: str = "passage"
102
+ ) -> List[List[float]]:
103
+ """Embed for a *role*: ``"query"`` or ``"passage"``.
104
+
105
+ Most embedders are symmetric and ignore the role — the default here
106
+ does. The E5 family is not: it was trained with a literal ``query: ``
107
+ or ``passage: `` in front of the text, and dropping the instruction
108
+ costs real retrieval accuracy. ``POST /worker/embed`` already carries
109
+ the role (``kind``), so the one provider that needs it can have it
110
+ without every caller learning about instructions.
111
+ """
112
+ return self.embed_batch(texts)
113
+
89
114
  # ── derived (shared) ──────────────────────────────────────────────────
90
115
  def _model_id_with_dim(self, dim: int) -> str:
91
116
  """This provider's ``model_id`` restated at ``dim``.
@@ -10,6 +10,50 @@ from __future__ import annotations
10
10
  from typing import Any, Dict, List
11
11
 
12
12
  PRODUCTION_PROVIDER_PROFILES: Dict[str, Dict[str, Any]] = {
13
+ # ── one-click local profiles (v12.0.0) ────────────────────────────────
14
+ # These three carry `hf_repo_id` and `download_gb` because they are the
15
+ # ones a setup surface can *offer to fetch*: the id is what
16
+ # `huggingface_hub.snapshot_download` takes and what
17
+ # `autodetect.detect_local_mlx` looks for in the cache, so "offer",
18
+ # "download" and "detect" all name the same string. The rest of the table
19
+ # describes providers the user has to bring themselves (an Ollama server,
20
+ # an API key), which is why they have no repo id.
21
+ "local:multilingual-e5-small": {
22
+ "id": "local:multilingual-e5-small",
23
+ "provider": "mlx",
24
+ "model": "mlx-community/multilingual-e5-small-mlx",
25
+ "hf_repo_id": "mlx-community/multilingual-e5-small-mlx",
26
+ "download_gb": 0.24,
27
+ "dimensions": 384,
28
+ "grade": "production",
29
+ "family": "local",
30
+ "label": "Multilingual E5 Small (로컬)",
31
+ "detail": "한국어를 포함한 100여 개 언어. 240MB, 해시 임베더와 같은 384차원.",
32
+ },
33
+ "local:multilingual-e5-base": {
34
+ "id": "local:multilingual-e5-base",
35
+ "provider": "mlx",
36
+ "model": "mlx-community/multilingual-e5-base-mlx",
37
+ "hf_repo_id": "mlx-community/multilingual-e5-base-mlx",
38
+ "download_gb": 1.1,
39
+ "dimensions": 768,
40
+ "grade": "production",
41
+ "family": "local",
42
+ "label": "Multilingual E5 Base (로컬)",
43
+ "detail": "더 정확한 다국어 임베딩. 768차원이라 기존 색인은 다시 만들어야 합니다.",
44
+ },
45
+ "local:arctic-embed-l-v2": {
46
+ "id": "local:arctic-embed-l-v2",
47
+ "provider": "mlx",
48
+ "model": "mlx-community/snowflake-arctic-embed-l-v2.0-8bit",
49
+ "hf_repo_id": "mlx-community/snowflake-arctic-embed-l-v2.0-8bit",
50
+ "download_gb": 0.6,
51
+ "dimensions": 1024,
52
+ "grade": "production",
53
+ "family": "local",
54
+ "label": "Arctic Embed L v2 (로컬)",
55
+ "detail": "다국어 고품질 검색용 임베딩. 1024차원.",
56
+ },
13
57
  "local:bge-m3": {
14
58
  "id": "local:bge-m3",
15
59
  "provider": "mlx",
@@ -57,7 +57,32 @@ def _as_float_list(value: Any) -> List[Any]:
57
57
  return list(value)
58
58
 
59
59
 
60
+ #: Longest token sequence an encoder-family embedding model accepts. BERT and
61
+ #: XLM-R both stop at 512 including the two special tokens; a longer sequence
62
+ #: is a hard failure inside the model, not a slow answer, so the ids are cut
63
+ #: here rather than discovered at inference time.
64
+ MLX_MAX_TOKENS = 512
65
+
66
+ #: The E5 family was trained with these literal prefixes and loses accuracy
67
+ #: without them. Keyed on the ``kind`` ``POST /worker/embed`` already sends.
68
+ E5_PREFIXES: Dict[str, str] = {"query": "query: ", "passage": "passage: "}
69
+
70
+
71
+ def _wants_e5_prefix(model: str) -> bool:
72
+ """Whether this model id names an E5-family checkpoint."""
73
+ return "e5" in str(model or "").lower()
74
+
75
+
60
76
  class MLXEmbeddingProvider(_NetworkEmbeddingProvider):
77
+ """A local Apple-Silicon sentence embedder through ``mlx_embeddings``.
78
+
79
+ Nothing here reaches the network: the model must already be in the local
80
+ Hugging Face cache (that is exactly what
81
+ :func:`~.autodetect.detect_local_mlx` looks for). A missing package or a
82
+ missing snapshot is :class:`EmbeddingUnavailable`, which
83
+ :func:`resolve_embedder` turns into the honest hash fallback.
84
+ """
85
+
61
86
  provider = "mlx"
62
87
 
63
88
  def __init__(self, cfg: _RemoteConfig):
@@ -66,6 +91,7 @@ class MLXEmbeddingProvider(_NetworkEmbeddingProvider):
66
91
  self.dim = _guess_dim(cfg.model, DEFAULT_EMBEDDING_DIM)
67
92
  self.model_id = f"mlx:{cfg.model}:{self.dim}"
68
93
  self._encoder: Optional[Tuple[str, Any, Any]] = None
94
+ self._prefixed = _wants_e5_prefix(cfg.model)
69
95
 
70
96
  def _load(self):
71
97
  if self._encoder is not None:
@@ -79,25 +105,37 @@ class MLXEmbeddingProvider(_NetworkEmbeddingProvider):
79
105
  except Exception as exc: # pragma: no cover - environment dependent
80
106
  raise EmbeddingUnavailable(f"MLX embedding model unavailable: {exc}") from exc
81
107
 
108
+ def embed_batch_for(
109
+ self, texts: Sequence[str], kind: str = "passage"
110
+ ) -> List[List[float]]:
111
+ """E5 asymmetry, honoured. Non-E5 models see the text unchanged."""
112
+ if not self._prefixed:
113
+ return self.embed_batch(texts)
114
+ prefix = E5_PREFIXES.get(str(kind or "passage").lower(), E5_PREFIXES["passage"])
115
+ return self.embed_batch([f"{prefix}{text}" for text in texts])
116
+
82
117
  def _embed_raw(self, texts: Sequence[str]) -> List[List[float]]:
83
- kind, model, tokenizer = self._load()
118
+ _, model, tokenizer = self._load()
84
119
  try:
85
120
  import mlx.core as mx # type: ignore
86
121
 
87
122
  out: List[List[float]] = []
88
123
  for text in texts:
89
- ids = tokenizer.encode(text)
90
- tokens = mx.array([ids])
91
- result = model(tokens)
92
- pooled = result[0] if isinstance(result, (tuple, list)) else result
93
- vec = mx.mean(pooled, axis=1)[0] if pooled.ndim == 3 else pooled[0]
94
- out.append([float(x) for x in _as_float_list(vec.tolist())])
124
+ ids = list(tokenizer.encode(text))[:MLX_MAX_TOKENS]
125
+ result = model(mx.array([ids]))
126
+ out.append([float(x) for x in _pool(mx, result)])
95
127
  return out
96
128
  except EmbeddingUnavailable:
97
129
  raise
98
130
  except Exception as exc: # pragma: no cover - environment dependent
99
131
  raise EmbeddingUnavailable(f"MLX embedding failed: {exc}") from exc
100
132
 
133
+ def metadata(self) -> Dict[str, Any]:
134
+ info = super().metadata()
135
+ info["instruction_prefixes"] = self._prefixed
136
+ info["max_tokens"] = MLX_MAX_TOKENS
137
+ return info
138
+
101
139
  def health(self) -> Dict[str, Any]:
102
140
  try:
103
141
  self._load()
@@ -106,6 +144,27 @@ class MLXEmbeddingProvider(_NetworkEmbeddingProvider):
106
144
  return {"status": "unavailable", "detail": str(exc)}
107
145
 
108
146
 
147
+ def _pool(mx: Any, result: Any) -> List[Any]:
148
+ """One sentence vector out of whatever shape the model handed back.
149
+
150
+ ``mlx_embeddings`` models return an output object carrying a pooled
151
+ ``text_embeds`` on newer versions and a bare hidden-state tensor on older
152
+ ones. Preferring the model's own pooled output matters: a checkpoint with a
153
+ trained pooler produces a better vector than a mean over its last layer,
154
+ and mean-pooling on top of an already-pooled row would be nonsense.
155
+ """
156
+ for attribute in ("text_embeds", "pooler_output"):
157
+ pooled = getattr(result, attribute, None)
158
+ if pooled is not None:
159
+ row = pooled[0] if pooled.ndim > 1 else pooled
160
+ return _as_float_list(row.tolist())
161
+ hidden = getattr(result, "last_hidden_state", None)
162
+ if hidden is None:
163
+ hidden = result[0] if isinstance(result, (tuple, list)) else result
164
+ row = mx.mean(hidden, axis=1)[0] if hidden.ndim == 3 else hidden[0]
165
+ return _as_float_list(row.tolist())
166
+
167
+
109
168
  class OllamaEmbeddingProvider(_NetworkEmbeddingProvider):
110
169
  provider = "ollama"
111
170
 
@@ -285,9 +344,13 @@ class ResolvedEmbedder:
285
344
  fell_back: bool
286
345
  health: Dict[str, Any]
287
346
  detail: str = ""
347
+ #: What :mod:`.autodetect` found on this machine, if anyone looked. It is
348
+ #: reported whether or not it was adopted, so a user running on the hash
349
+ #: fallback with a real embedder already downloaded can *see* that.
350
+ detected: Optional[Any] = None
288
351
 
289
352
  def as_dict(self) -> Dict[str, Any]:
290
- return {
353
+ payload: Dict[str, Any] = {
291
354
  "requested_provider": self.requested,
292
355
  "active_provider": self.active,
293
356
  "fell_back": self.fell_back,
@@ -295,6 +358,9 @@ class ResolvedEmbedder:
295
358
  "detail": self.detail,
296
359
  **self.provider.metadata(),
297
360
  }
361
+ if self.detected is not None and hasattr(self.detected, "as_dict"):
362
+ payload["detected"] = self.detected.as_dict()
363
+ return payload
298
364
 
299
365
 
300
366
  def resolve_embedder(
@@ -18,9 +18,9 @@ Threat model, so the rule is checkable rather than felt:
18
18
  :mod:`latticeai.core.csrf` already trusts it. Honouring a forwarded host from
19
19
  the same caller widens nothing that was not already open.
20
20
  * **Anyone else is not trusted.** Off-loopback the header is honoured only from
21
- a peer the operator listed in ``LATTICEAI_TRUSTED_PROXIES`` — the same
22
- allowlist ``latticeai.core.security.client_ip`` uses for the same reason, and
23
- empty by default.
21
+ a peer the operator listed in ``LATTICEAI_TRUSTED_PROXIES`` — the allowlist
22
+ ``latticeai.core.security.configure_trusted_proxies`` holds, mirrored by
23
+ ``lattice-auth`` at the front door for the same reason, and empty by default.
24
24
 
25
25
  The functions are pure: the caller supplies the headers and the peer address, so
26
26
  the policy is testable without an ASGI app or a socket.
@@ -181,11 +181,6 @@ MESSAGES: Dict[str, Dict[str, str]] = {
181
181
  }
182
182
 
183
183
 
184
- def bilingual(ko: str, en: str) -> Dict[str, str]:
185
- """One Korean/English phrase pair, in the shape every caller renders."""
186
- return {"ko": ko, "en": en}
187
-
188
-
189
184
  def resolve_language(request: Any, default: str = DEFAULT_LANGUAGE) -> str:
190
185
  """Language for this request: the product's choice, then the browser's.
191
186
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Dict, Iterable, List, Set
5
+ from typing import Dict, List, Set
6
6
 
7
7
  ROLE_CAPABILITIES: Dict[str, Set[str]] = {
8
8
  "owner": {"all"},
@@ -46,8 +46,3 @@ def role_has_capability(role: str, capability: str) -> bool:
46
46
  def require_capability(role: str, capability: str) -> None:
47
47
  if not role_has_capability(role, capability):
48
48
  raise PermissionError(f"role '{normalize_role(role)}' lacks capability '{capability}'")
49
-
50
-
51
- def policy_matrix(roles: Iterable[str] | None = None) -> List[Dict[str, object]]:
52
- selected = list(roles or ROLE_CAPABILITIES.keys())
53
- return [{"role": normalize_role(role), "caps": capabilities_for_role(role)} for role in selected]
@@ -25,7 +25,6 @@ from __future__ import annotations
25
25
 
26
26
  import logging
27
27
  import sys
28
- import traceback
29
28
  from typing import Optional
30
29
 
31
30
  logger = logging.getLogger("latticeai.suppressed")
@@ -63,22 +62,4 @@ def quiet(reason: Optional[str] = None, *, level: int = logging.DEBUG) -> None:
63
62
  )
64
63
 
65
64
 
66
- def quiet_summary(reason: Optional[str] = None) -> str:
67
- """One-line description of the live exception, for callers that report it."""
68
- exc_type, exc, _ = sys.exc_info()
69
- if exc is None:
70
- return ""
71
- name = getattr(exc_type, "__name__", "Exception")
72
- text = str(exc).strip() or name
73
- return f"{reason}: {text}" if reason else text
74
-
75
-
76
- def format_suppressed() -> str:
77
- """Full traceback of the live exception as text (for diagnostics payloads)."""
78
- exc_type, exc, tb = sys.exc_info()
79
- if exc is None:
80
- return ""
81
- return "".join(traceback.format_exception(exc_type, exc, tb))
82
-
83
-
84
- __all__ = ["quiet", "quiet_summary", "format_suppressed"]
65
+ __all__ = ["quiet"]