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.
- package/README.md +100 -76
- package/docs/BENCHMARKS.md +9 -2
- package/docs/CHANGELOG.md +249 -0
- package/docs/CI_AND_RELEASE_GATES.md +126 -41
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +271 -103
- package/docs/ENTERPRISE.md +1 -1
- package/docs/LEGACY_COMPATIBILITY.md +10 -6
- package/docs/MULTI_AGENT_RUNTIME.md +4 -4
- package/docs/ONBOARDING.md +16 -4
- package/docs/OPERATIONS.md +14 -1
- package/docs/PERMISSION_MODE.md +14 -9
- package/docs/REALTIME_COLLABORATION.md +1 -1
- package/docs/ROADMAP.md +113 -0
- package/docs/TRUST_MODEL.md +28 -7
- package/docs/USABILITY_AUDIT.md +5 -0
- package/docs/WHY_LATTICE.md +13 -5
- package/docs/WORKFLOW_DESIGNER.md +2 -2
- package/docs/kg-schema.md +57 -7
- package/docs/mcp-tools.md +93 -82
- package/docs/security-model.md +6 -3
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common/__init__.py +1 -54
- package/lattice_brain/graph/_kg_common/extraction.py +459 -105
- package/lattice_brain/graph/_kg_common/normalize.py +305 -0
- package/lattice_brain/graph/_kg_common/patterns.py +275 -0
- package/lattice_brain/graph/_kg_common/relations.py +12 -3
- package/lattice_brain/graph/_kg_common/sections.py +107 -0
- package/lattice_brain/graph/_kg_common/text.py +14 -450
- package/lattice_brain/graph/_kg_constants.py +7 -0
- package/lattice_brain/ingestion/__init__.py +6 -3
- package/lattice_brain/multimodal/__init__.py +9 -3
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agent_worker_seam.py +44 -1
- package/latticeai/api/models.py +18 -110
- package/latticeai/api/search.py +7 -30
- package/latticeai/api/worker_compute.py +127 -106
- package/latticeai/api/worker_seams.py +17 -2
- package/latticeai/core/embedding_providers/__init__.py +16 -0
- package/latticeai/core/embedding_providers/autodetect.py +302 -0
- package/latticeai/core/embedding_providers/base.py +25 -0
- package/latticeai/core/embedding_providers/profiles.py +44 -0
- package/latticeai/core/embedding_providers/text.py +74 -8
- package/latticeai/core/http_origin.py +3 -3
- package/latticeai/core/messages.py +0 -5
- package/latticeai/core/policy.py +1 -6
- package/latticeai/core/quiet.py +1 -20
- package/latticeai/core/security.py +29 -83
- package/latticeai/core/sessions.py +95 -4
- package/latticeai/core/users.py +0 -38
- package/latticeai/core/vector_index/__init__.py +61 -0
- package/latticeai/core/vector_index/hnsw.py +383 -0
- package/latticeai/core/vector_index/sidecar.py +329 -0
- package/latticeai/models/router/catalog.py +2 -2
- package/latticeai/models/router/generation.py +176 -30
- package/latticeai/models/router/loading.py +150 -9
- package/latticeai/runtime/access_runtime.py +7 -4
- package/latticeai/runtime/brain_runtime.py +43 -9
- package/latticeai/runtime/build_phases/features.py +8 -31
- package/latticeai/runtime/build_phases/foundation.py +7 -16
- package/latticeai/runtime/build_phases/web.py +3 -3
- package/latticeai/runtime/build_phases/worker_profile.py +29 -27
- package/latticeai/runtime/runtime_context.py +0 -2
- package/latticeai/services/architecture_readiness.py +18 -19
- package/latticeai/services/process_audit.py +1 -22
- package/latticeai/services/product_readiness.py +39 -12
- package/latticeai/services/search_service.py +7 -0
- package/latticeai/services/voice_capture.py +8 -28
- package/latticeai/tools/__init__.py +12 -47
- package/latticeai/tools/commands.py +9 -15
- package/latticeai/tools/documents.py +12 -0
- package/latticeai/tools/knowledge.py +0 -6
- package/latticeai/tools/markup.py +152 -0
- package/package.json +4 -5
- package/requirements.txt +0 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_openapi_drift.mjs +3 -2
- package/scripts/check_server_i18n.mjs +5 -4
- package/scripts/compose_openapi.py +4 -1
- package/scripts/export_openapi.py +5 -4
- package/scripts/gen_worker_allowlist_fixture.py +2 -2
- package/scripts/openapi_route_families.json +19 -74
- package/scripts/publish_release.mjs +157 -0
- package/scripts/release_screen_claims.json +144 -28
- package/src-tauri/Cargo.lock +45 -10
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +47 -41
- package/static/app/assets/Act-Cf1L2709.js +2 -0
- package/static/app/assets/AdminConsole-DPAbLTYV.js +1 -0
- package/static/app/assets/Brain-DqamGrj-.js +2 -0
- package/static/app/assets/BrainHome-MHe2_RYs.js +2 -0
- package/static/app/assets/BrainSignals-CQPPfyyH.js +1 -0
- package/static/app/assets/Capture-DGdIH_Zc.js +1 -0
- package/static/app/assets/Chronicle-C-UlCJoJ.js +1 -0
- package/static/app/assets/CommandPalette-WNT4EqUX.js +1 -0
- package/static/app/assets/DigitalBrainExplorer-CEBH5Cwc.js +321 -0
- package/static/app/assets/Library-C6xd1dlf.js +1 -0
- package/static/app/assets/LivingBrain-BEk-0ohw.js +1 -0
- package/static/app/assets/ProductFlow-CZLm5iXh.js +1 -0
- package/static/app/assets/QueryClientProvider-B3OjqSyJ.js +1 -0
- package/static/app/assets/{ReviewCard-HXRle3qq.js → ReviewCard-CEHG6evf.js} +2 -2
- package/static/app/assets/RunsListPanel-CLtEJSRW.js +1 -0
- package/static/app/assets/System-CAxwBUXw.js +1 -0
- package/static/app/assets/WorkflowGraph-Dj10RuGE.js +1 -0
- package/static/app/assets/WorkflowsPanel-Kyeh_LIT.js +2 -0
- package/static/app/assets/actHelpers-CtSmK9Dw.js +1 -0
- package/static/app/assets/arrow-left-CRl5EO4D.js +1 -0
- package/static/app/assets/{bot-Cn8bWRuq.js → bot-DhUGRel2.js} +1 -1
- package/static/app/assets/brain-CLkhHsHF.js +1 -0
- package/static/app/assets/button-CmaEqG1T.js +1 -0
- package/static/app/assets/circle-check-CFgejkOS.js +1 -0
- package/static/app/assets/{circle-pause-CmzC_apg.js → circle-pause-l96izbxj.js} +1 -1
- package/static/app/assets/{circle-play-D8mW2aQ7.js → circle-play-CrZa25_q.js} +1 -1
- package/static/app/assets/{cpu-DZcdd0PZ.js → cpu-BaXudqwl.js} +1 -1
- package/static/app/assets/{download-bv1KEPGQ.js → download-hCVFPiyc.js} +1 -1
- package/static/app/assets/{folder-open-d-Pip5gr.js → folder-open-CHL82Yp7.js} +1 -1
- package/static/app/assets/{hard-drive-D20iavUb.js → hard-drive-DDzET7lk.js} +1 -1
- package/static/app/assets/index-CB93CZWW.css +2 -0
- package/static/app/assets/index-D2H-wSl6.js +13 -0
- package/static/app/assets/input-Df1CAY_I.js +1 -0
- package/static/app/assets/jsx-runtime-bzQ4Vb5N.js +1 -0
- package/static/app/assets/{link-2-BPJOFlAy.js → link-2-xNnTIX1_.js} +1 -1
- package/static/app/assets/{permissionCopy-ChdJd493.js → permissionCopy-D3aWHco-.js} +1 -1
- package/static/app/assets/primitives-BioD2slS.js +1 -0
- package/static/app/assets/search-BzBw8YcW.js +1 -0
- package/static/app/assets/{share-2-YNX_NtMU.js → share-2-FkzGf8Df.js} +1 -1
- package/static/app/assets/{shield-alert-DuQ3zrVL.js → shield-alert-B3dwzik4.js} +1 -1
- package/static/app/assets/sourceMeta-DQSY_tah.js +1 -0
- package/static/app/assets/textarea-P8o6pvOP.js +1 -0
- package/static/app/assets/useFocusTrap-hswOIkXE.js +1 -0
- package/static/app/assets/useMutation-OJLrYSRA.js +1 -0
- package/static/app/assets/workspace-BCuk3Ku9.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/lattice_brain/ingestion/pipeline.py +0 -108
- package/latticeai/api/local_files.py +0 -44
- package/latticeai/api/tools.py +0 -126
- package/latticeai/api/voice_capture.py +0 -32
- package/latticeai/core/agent_permission.py +0 -85
- package/scripts/agent_eval.py +0 -34
- package/scripts/brain_quality_eval.py +0 -37
- package/scripts/check_legacy_debt.mjs +0 -91
- package/scripts/check_python.py +0 -100
- package/scripts/chunking_parity_corpus.py +0 -449
- package/scripts/generate_agent_parity_fixtures.py +0 -771
- package/scripts/generate_chunking_parity_fixtures.py +0 -259
- package/static/app/assets/Act-BPcVAbOL.js +0 -1
- package/static/app/assets/AdminConsole-Bw1ATQL0.js +0 -1
- package/static/app/assets/Brain-CT92Kos0.js +0 -321
- package/static/app/assets/BrainHome-CFBkt1K_.js +0 -2
- package/static/app/assets/BrainSignals-ReLWF2H8.js +0 -1
- package/static/app/assets/Capture-BsTokYkk.js +0 -1
- package/static/app/assets/Chronicle-B6f0T9id.js +0 -1
- package/static/app/assets/CommandPalette-CuvjTv1u.js +0 -1
- package/static/app/assets/Library-BGJbG9Hd.js +0 -1
- package/static/app/assets/LivingBrain-DGYK_Jsa.js +0 -1
- package/static/app/assets/ProductFlow-DXBC6brE.js +0 -1
- package/static/app/assets/System-CMHSO9qM.js +0 -1
- package/static/app/assets/arrow-left-BfmkskWx.js +0 -1
- package/static/app/assets/brain-CQJberbE.js +0 -1
- package/static/app/assets/button-Ct9f2_oT.js +0 -1
- package/static/app/assets/circle-check-DruOxB-4.js +0 -1
- package/static/app/assets/index-D9x-kSNy.css +0 -2
- package/static/app/assets/index-Do83hDzJ.js +0 -10
- package/static/app/assets/input-BLXVNmj1.js +0 -1
- package/static/app/assets/primitives-Cv5tbZBY.js +0 -1
- package/static/app/assets/search-CT9aho2j.js +0 -1
- package/static/app/assets/textarea-DqwLnli4.js +0 -1
- package/static/app/assets/useFocusTrap-ZVI98jaW.js +0 -1
- package/static/app/assets/useMutation-CVC4qv_D.js +0 -1
- package/static/app/assets/useQuery-C7BeG4HU.js +0 -1
- package/static/app/assets/utils-CiFtIdZq.js +0 -4
- 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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
22
|
-
|
|
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
|
|
package/latticeai/core/policy.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import Dict,
|
|
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]
|
package/latticeai/core/quiet.py
CHANGED
|
@@ -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
|
-
|
|
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"]
|