ltcai 11.2.0 → 11.4.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 +46 -53
- package/docs/CHANGELOG.md +61 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/MULTI_AGENT_RUNTIME.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +6 -2
- package/docs/PERMISSION_MODE.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +2 -2
- package/docs/v11.3.0_PLAN.md +202 -0
- package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common/__init__.py +287 -0
- package/lattice_brain/graph/_kg_common/extraction.py +516 -0
- package/lattice_brain/graph/_kg_common/relations.py +161 -0
- package/lattice_brain/graph/_kg_common/text.py +479 -0
- package/lattice_brain/graph/discovery_index/__init__.py +35 -0
- package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
- package/lattice_brain/graph/discovery_index/extract.py +137 -0
- package/lattice_brain/graph/discovery_index/scan.py +411 -0
- package/lattice_brain/graph/discovery_index/upsert.py +495 -0
- package/lattice_brain/graph/projection/__init__.py +42 -0
- package/lattice_brain/graph/projection/curation.py +500 -0
- package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +15 -477
- package/lattice_brain/graph/retrieval/__init__.py +54 -0
- package/lattice_brain/graph/retrieval/context.py +197 -0
- package/lattice_brain/graph/retrieval/graph_view.py +319 -0
- package/lattice_brain/graph/retrieval/hybrid.py +488 -0
- package/lattice_brain/graph/retrieval/maintenance.py +121 -0
- package/lattice_brain/graph/retrieval/signals.py +95 -0
- package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
- package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
- package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
- package/lattice_brain/graph/retrieval_vector/search.py +560 -0
- package/lattice_brain/graph/retrieval_vector/status.py +374 -0
- package/lattice_brain/ingestion/__init__.py +130 -0
- package/lattice_brain/ingestion/_contract.py +90 -0
- package/lattice_brain/ingestion/constants.py +127 -0
- package/lattice_brain/ingestion/folder_scan.py +57 -0
- package/lattice_brain/ingestion/folders.py +258 -0
- package/lattice_brain/ingestion/hashing.py +26 -0
- package/lattice_brain/ingestion/jobs_api.py +107 -0
- package/lattice_brain/ingestion/models.py +80 -0
- package/lattice_brain/ingestion/pipeline.py +486 -0
- package/lattice_brain/ingestion/quality.py +209 -0
- package/lattice_brain/ingestion/routing.py +295 -0
- package/lattice_brain/multimodal/__init__.py +164 -0
- package/lattice_brain/multimodal/audio.py +77 -0
- package/lattice_brain/multimodal/common.py +118 -0
- package/lattice_brain/multimodal/images.py +498 -0
- package/lattice_brain/multimodal/ports.py +169 -0
- package/lattice_brain/multimodal/video.py +410 -0
- package/lattice_brain/portability/__init__.py +90 -0
- package/lattice_brain/portability/_contract.py +42 -0
- package/lattice_brain/portability/backups.py +338 -0
- package/lattice_brain/portability/bundles.py +136 -0
- package/lattice_brain/portability/constants.py +93 -0
- package/lattice_brain/portability/fsops.py +138 -0
- package/lattice_brain/portability/service.py +41 -0
- package/lattice_brain/{portability.py → portability/sharing.py} +44 -677
- package/lattice_brain/runtime/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chronicle.py +63 -0
- package/latticeai/core/agent/__init__.py +93 -0
- package/latticeai/core/agent/_contract.py +79 -0
- package/latticeai/core/agent/context.py +57 -0
- package/latticeai/core/agent/deps.py +125 -0
- package/latticeai/core/agent/execution.py +622 -0
- package/latticeai/core/agent/planning.py +145 -0
- package/latticeai/core/agent/recovery.py +157 -0
- package/latticeai/core/agent/runtime.py +210 -0
- package/latticeai/core/agent/verification.py +231 -0
- package/latticeai/core/embedding_providers/__init__.py +151 -0
- package/latticeai/core/embedding_providers/base.py +199 -0
- package/latticeai/core/embedding_providers/captions.py +162 -0
- package/latticeai/core/embedding_providers/profiles.py +126 -0
- package/latticeai/core/embedding_providers/text.py +350 -0
- package/latticeai/core/embedding_providers/vision.py +352 -0
- package/latticeai/core/file_generation/__init__.py +115 -0
- package/latticeai/core/file_generation/bundles.py +76 -0
- package/latticeai/core/file_generation/extraction.py +154 -0
- package/latticeai/core/file_generation/inference.py +235 -0
- package/latticeai/core/file_generation/orchestration.py +152 -0
- package/latticeai/core/file_generation/prompting.py +117 -0
- package/latticeai/core/file_generation/repair.py +114 -0
- package/latticeai/core/file_generation/sanitize.py +61 -0
- package/latticeai/core/file_generation/validation.py +201 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +9 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/integrations/telegram_bot/__init__.py +123 -0
- package/latticeai/integrations/telegram_bot/__main__.py +17 -0
- package/latticeai/integrations/telegram_bot/config.py +86 -0
- package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
- package/latticeai/integrations/telegram_bot/flows.py +478 -0
- package/latticeai/integrations/telegram_bot/helpers.py +322 -0
- package/latticeai/integrations/telegram_bot/screens.py +394 -0
- package/latticeai/models/router/__init__.py +88 -0
- package/latticeai/models/router/_contract.py +66 -0
- package/latticeai/models/router/branding.py +56 -0
- package/latticeai/models/router/catalog.py +69 -0
- package/latticeai/models/router/documents.py +199 -0
- package/latticeai/models/router/errors.py +37 -0
- package/latticeai/models/router/generation.py +258 -0
- package/latticeai/models/router/loading.py +291 -0
- package/latticeai/models/router/local_models.py +85 -0
- package/latticeai/models/router/registry.py +147 -0
- package/latticeai/runtime/build_phases/__init__.py +82 -0
- package/latticeai/runtime/build_phases/features.py +407 -0
- package/latticeai/runtime/build_phases/foundation.py +555 -0
- package/latticeai/runtime/build_phases/web.py +492 -0
- package/latticeai/runtime/runtime_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +48 -19
- package/latticeai/services/brain_intelligence/__init__.py +58 -0
- package/latticeai/services/brain_intelligence/_contract.py +71 -0
- package/latticeai/services/brain_intelligence/consistency.py +193 -0
- package/latticeai/services/brain_intelligence/constants.py +47 -0
- package/latticeai/services/brain_intelligence/digest.py +258 -0
- package/latticeai/services/brain_intelligence/health.py +331 -0
- package/latticeai/services/brain_intelligence/proposals.py +264 -0
- package/latticeai/services/brain_intelligence/sampling.py +84 -0
- package/latticeai/services/brain_intelligence/service.py +48 -0
- package/latticeai/services/chronicle.py +557 -0
- package/latticeai/services/memory_service/__init__.py +52 -0
- package/latticeai/services/memory_service/_contract.py +100 -0
- package/latticeai/services/memory_service/brief.py +431 -0
- package/latticeai/services/memory_service/constants.py +57 -0
- package/latticeai/services/memory_service/maintenance.py +138 -0
- package/latticeai/services/memory_service/manager.py +186 -0
- package/latticeai/services/memory_service/proof.py +136 -0
- package/latticeai/services/memory_service/recall.py +225 -0
- package/latticeai/services/memory_service/service.py +48 -0
- package/latticeai/services/memory_service/stores.py +110 -0
- package/latticeai/services/model_runtime/__init__.py +322 -0
- package/latticeai/services/model_runtime/cloud.py +87 -0
- package/latticeai/services/model_runtime/download.py +282 -0
- package/latticeai/services/model_runtime/engines.py +341 -0
- package/latticeai/services/model_runtime/loading.py +178 -0
- package/latticeai/services/model_runtime/service.py +129 -0
- package/latticeai/services/model_runtime/state.py +131 -0
- package/latticeai/services/model_runtime/status.py +255 -0
- package/latticeai/services/product_readiness.py +15 -7
- package/latticeai/setup/wizard/__init__.py +126 -0
- package/latticeai/setup/wizard/catalog.py +172 -0
- package/latticeai/setup/wizard/detect.py +323 -0
- package/latticeai/setup/wizard/install.py +348 -0
- package/latticeai/setup/wizard/paths.py +168 -0
- package/latticeai/setup/wizard/plans.py +74 -0
- package/latticeai/setup/wizard/recommend.py +320 -0
- package/package.json +6 -2
- package/scripts/bump_version.py +14 -0
- package/scripts/capture_release_evidence.mjs +33 -21
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_i18n_namespace_coverage.mjs +41 -4
- package/scripts/check_max_file_lines.mjs +102 -0
- package/scripts/check_release_evidence_bound.mjs +30 -15
- package/scripts/check_screenshot_pixel_delta.py +34 -4
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/generate_rust_parity_fixtures.py +562 -0
- package/scripts/lib/mock_server_fingerprint.mjs +94 -0
- package/scripts/release_screen_claims.json +31 -2
- package/src-tauri/Cargo.lock +361 -3
- package/src-tauri/Cargo.toml +6 -1
- package/src-tauri/src/backend.rs +349 -0
- package/src-tauri/src/folder.rs +33 -0
- package/src-tauri/src/main.rs +97 -399
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +41 -37
- package/static/app/assets/Act-yYpYnn0v.js +1 -0
- package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
- package/static/app/assets/{Brain-tuhI4sOC.js → Brain-C1HBN0Wf.js} +2 -2
- package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
- package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
- package/static/app/assets/Capture-CFIRsFNE.js +1 -0
- package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
- package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
- package/static/app/assets/Library-DwO3yZST.js +1 -0
- package/static/app/assets/{LivingBrain-DBwhto14.js → LivingBrain-Jn1GK0-S.js} +1 -1
- package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
- package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
- package/static/app/assets/System-DW8F-2xL.js +1 -0
- package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
- package/static/app/assets/{bot-Cia42c2h.js → bot-IM_E_Y12.js} +1 -1
- package/static/app/assets/brain-Ci1CkWjM.js +1 -0
- package/static/app/assets/{button-2j2Ijzgq.js → button-COwyqfHM.js} +1 -1
- package/static/app/assets/circle-check-DfInj-qD.js +1 -0
- package/static/app/assets/{circle-pause-BEFeWpVW.js → circle-pause-DEM4A1Y5.js} +1 -1
- package/static/app/assets/{circle-play-ujXMcHxl.js → circle-play-C9djDuLd.js} +1 -1
- package/static/app/assets/{cpu-k4awryFq.js → cpu-DFdo1gw-.js} +1 -1
- package/static/app/assets/{download-DFbLJ_ig.js → download-SnJL6oqk.js} +1 -1
- package/static/app/assets/{folder-open-7y_b6xkM.js → folder-open-CqZeDkjE.js} +1 -1
- package/static/app/assets/{hard-drive-Bidh02Kr.js → hard-drive-j1jJXYYf.js} +1 -1
- package/static/app/assets/{index-DwDl9-8Y.css → index-BLPb5lmE.css} +1 -1
- package/static/app/assets/index-_u5iUHDr.js +10 -0
- package/static/app/assets/input-B0lPdRQZ.js +1 -0
- package/static/app/assets/link-2-CoFbooHS.js +1 -0
- package/static/app/assets/{permissionCopy-Bpb83Hx9.js → permissionCopy-BsyLxtao.js} +1 -1
- package/static/app/assets/primitives-DEbN-d6p.js +1 -0
- package/static/app/assets/search-BybIWPNd.js +1 -0
- package/static/app/assets/{share-2-BH1M-WNi.js → share-2-CVtZ_ewX.js} +1 -1
- package/static/app/assets/{shield-alert-BlKdBXcG.js → shield-alert-CBi2GNWM.js} +1 -1
- package/static/app/assets/{textarea-CCWbUfFB.js → textarea-DNMpB5ih.js} +1 -1
- package/static/app/assets/{useFocusTrap-YdHQ7pJ1.js → useFocusTrap-C83t3GXF.js} +1 -1
- package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
- package/static/app/assets/{useQuery-CXQiwbVT.js → useQuery-Dcp1OChy.js} +1 -1
- package/static/app/assets/utils-BlZr7Pd4.js +4 -0
- package/static/app/assets/workspace-jJY4RuAV.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/lattice_brain/graph/_kg_common.py +0 -1331
- package/lattice_brain/graph/discovery_index.py +0 -1141
- package/lattice_brain/graph/retrieval.py +0 -1120
- package/lattice_brain/graph/retrieval_vector.py +0 -1293
- package/lattice_brain/ingestion.py +0 -1525
- package/lattice_brain/multimodal.py +0 -1258
- package/latticeai/core/agent.py +0 -1465
- package/latticeai/core/embedding_providers.py +0 -1196
- package/latticeai/core/file_generation.py +0 -1047
- package/latticeai/integrations/telegram_bot.py +0 -1390
- package/latticeai/models/router.py +0 -1007
- package/latticeai/runtime/build_phases.py +0 -1450
- package/latticeai/services/brain_intelligence.py +0 -1083
- package/latticeai/services/memory_service.py +0 -1177
- package/latticeai/services/model_runtime.py +0 -1281
- package/latticeai/setup/wizard.py +0 -1310
- package/static/app/assets/Act-AWf0SAKp.js +0 -1
- package/static/app/assets/AdminConsole-D0u8Tiyj.js +0 -1
- package/static/app/assets/BrainHome-Ts7G_Ila.js +0 -2
- package/static/app/assets/BrainSignals-jMYgQ2Ar.js +0 -1
- package/static/app/assets/Capture-CqOSzyPr.js +0 -1
- package/static/app/assets/CommandPalette-DC0Bzh-I.js +0 -1
- package/static/app/assets/Library-CX-bbhmK.js +0 -1
- package/static/app/assets/ProductFlow-BHA2cfKI.js +0 -1
- package/static/app/assets/ReviewCard-BUhCKRNM.js +0 -3
- package/static/app/assets/System-Bu2t5hn1.js +0 -1
- package/static/app/assets/arrow-left-Dzwa5zRb.js +0 -1
- package/static/app/assets/brain-DJMoqrwx.js +0 -1
- package/static/app/assets/index-BpYkzcVm.js +0 -10
- package/static/app/assets/input-DSlJJxRs.js +0 -1
- package/static/app/assets/primitives-BCx6TvfG.js +0 -1
- package/static/app/assets/search-Cgy8cCFJ.js +0 -1
- package/static/app/assets/utils-zqPZJxdx.js +0 -4
- package/static/app/assets/workspace-DXTihhfU.js +0 -1
|
@@ -1,1196 +0,0 @@
|
|
|
1
|
-
"""Provider-backed embeddings for Lattice AI retrieval.
|
|
2
|
-
|
|
3
|
-
The knowledge graph stores dense vectors keyed by ``(embedding_model,
|
|
4
|
-
embedding_dim)`` and only ever compares vectors that share those keys
|
|
5
|
-
(``knowledge_graph.vector_search``). That contract means the *embedder* can be
|
|
6
|
-
swapped behind a single interface as long as every implementation agrees on:
|
|
7
|
-
|
|
8
|
-
* ``model_id`` / ``dim`` — the index identity (a change forces a re-index, which
|
|
9
|
-
``index_status`` already reports as ``stale``/``needs_reindex``);
|
|
10
|
-
* ``encode`` / ``decode`` — the on-disk float32 codec (shared by all providers);
|
|
11
|
-
* ``embed`` returns an **L2-normalized** vector, so ``similarity`` is a plain dot
|
|
12
|
-
product and equals cosine similarity regardless of provider.
|
|
13
|
-
|
|
14
|
-
This module defines that :class:`EmbeddingProvider` interface and five concrete
|
|
15
|
-
implementations:
|
|
16
|
-
|
|
17
|
-
1. :class:`HashEmbeddingProvider` — deterministic, offline, always-available
|
|
18
|
-
fallback (wraps the legacy :class:`~latticeai.core.local_embeddings.LocalEmbeddingModel`).
|
|
19
|
-
2. :class:`MLXEmbeddingProvider` — local Apple-Silicon embedding models.
|
|
20
|
-
3. :class:`OllamaEmbeddingProvider` — a local/remote Ollama server.
|
|
21
|
-
4. :class:`OpenAICompatibleEmbeddingProvider` — any ``/v1/embeddings`` endpoint
|
|
22
|
-
(OpenAI, LM Studio, vLLM, llama.cpp, Together, …).
|
|
23
|
-
5. :class:`CustomEmbeddingProvider` — a user-supplied dotted callable.
|
|
24
|
-
|
|
25
|
-
:func:`resolve_embedder` builds the configured provider and, when that provider
|
|
26
|
-
is unavailable, degrades to the hash fallback while *reporting* the requested
|
|
27
|
-
vs. active provider — nothing is silently faked.
|
|
28
|
-
|
|
29
|
-
Vision seam (v11.1.0, Track 3)
|
|
30
|
-
------------------------------
|
|
31
|
-
Images join the same contract through :class:`VisionEmbeddingProvider`, with two
|
|
32
|
-
deliberate differences from the text side:
|
|
33
|
-
|
|
34
|
-
* **No fallback.** The hash embedder turns *text* into a real, if crude, cosine
|
|
35
|
-
signal. There is no equivalent for pixels: hashing a file path produces a
|
|
36
|
-
vector that says nothing about the picture, so an unavailable vision model is
|
|
37
|
-
reported as unavailable (:class:`EmbeddingUnavailable` /
|
|
38
|
-
``ResolvedVisionEmbedder.available == False``) and the caller skips the
|
|
39
|
-
embedding instead of storing a decoy.
|
|
40
|
-
* **A separate space by default.** A CLIP-family image vector is not comparable
|
|
41
|
-
with a BGE text vector, so ``space == "image"`` means "index these apart and
|
|
42
|
-
join them by late fusion". Only a genuinely shared-space model may declare
|
|
43
|
-
``space == "shared"`` (opt-in), and only then can a *text* query be scored
|
|
44
|
-
against image vectors.
|
|
45
|
-
|
|
46
|
-
:class:`VisionCaptioner` is the matching seam for descriptions. Its default
|
|
47
|
-
implementation returns ``None``: a caption is what a vision-language model
|
|
48
|
-
said about an image, so with no VLM loaded there is no caption — never a
|
|
49
|
-
sentence assembled from the filename and passed off as one.
|
|
50
|
-
"""
|
|
51
|
-
|
|
52
|
-
from __future__ import annotations
|
|
53
|
-
|
|
54
|
-
import importlib
|
|
55
|
-
import math
|
|
56
|
-
import os
|
|
57
|
-
import struct
|
|
58
|
-
from dataclasses import dataclass, field
|
|
59
|
-
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
60
|
-
|
|
61
|
-
from latticeai.core.local_embeddings import DEFAULT_EMBEDDING_DIM, LocalEmbeddingModel
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
class EmbeddingUnavailable(RuntimeError):
|
|
65
|
-
"""Raised when a configured provider cannot produce an embedding.
|
|
66
|
-
|
|
67
|
-
Callers in the hot path (``vector_search``) translate this into a clear
|
|
68
|
-
503/"provider unavailable" rather than a misleading empty result.
|
|
69
|
-
"""
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
# Best-known output dimensionality for common embedding models, so the index
|
|
73
|
-
# identity is stable before the first (possibly remote) call. A configured
|
|
74
|
-
# ``dim`` always wins; an unknown model falls back to a one-time live probe.
|
|
75
|
-
_KNOWN_DIMS = {
|
|
76
|
-
"bge-m3": 1024,
|
|
77
|
-
"nomic-embed-text": 768,
|
|
78
|
-
"mxbai-embed-large": 1024,
|
|
79
|
-
"all-minilm": 384,
|
|
80
|
-
"all-minilm-l6-v2": 384,
|
|
81
|
-
"bge-small-en": 384,
|
|
82
|
-
"bge-base-en": 768,
|
|
83
|
-
"bge-large-en": 1024,
|
|
84
|
-
"gte-small": 384,
|
|
85
|
-
"gte-base": 768,
|
|
86
|
-
"gte-large": 1024,
|
|
87
|
-
"e5-large": 1024,
|
|
88
|
-
"multilingual-e5-large": 1024,
|
|
89
|
-
"text-embedding-3-small": 1536,
|
|
90
|
-
"text-embedding-3-large": 3072,
|
|
91
|
-
"text-embedding-ada-002": 1536,
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
PRODUCTION_PROVIDER_PROFILES: Dict[str, Dict[str, Any]] = {
|
|
96
|
-
"local:bge-m3": {
|
|
97
|
-
"id": "local:bge-m3",
|
|
98
|
-
"provider": "mlx",
|
|
99
|
-
"model": "bge-m3",
|
|
100
|
-
"dimensions": 1024,
|
|
101
|
-
"grade": "production",
|
|
102
|
-
"family": "local",
|
|
103
|
-
"label": "BGE-M3 local",
|
|
104
|
-
"detail": "Multilingual semantic embeddings for local retrieval.",
|
|
105
|
-
},
|
|
106
|
-
"local:nomic-embed-text": {
|
|
107
|
-
"id": "local:nomic-embed-text",
|
|
108
|
-
"provider": "ollama",
|
|
109
|
-
"model": "nomic-embed-text",
|
|
110
|
-
"dimensions": 768,
|
|
111
|
-
"grade": "production",
|
|
112
|
-
"family": "local",
|
|
113
|
-
"label": "Nomic Embed Text local",
|
|
114
|
-
"detail": "General-purpose local semantic embeddings.",
|
|
115
|
-
},
|
|
116
|
-
"local:e5-large": {
|
|
117
|
-
"id": "local:e5-large",
|
|
118
|
-
"provider": "mlx",
|
|
119
|
-
"model": "e5-large",
|
|
120
|
-
"dimensions": 1024,
|
|
121
|
-
"grade": "production",
|
|
122
|
-
"family": "local",
|
|
123
|
-
"label": "E5 Large local",
|
|
124
|
-
"detail": "High-recall local retrieval profile.",
|
|
125
|
-
},
|
|
126
|
-
"local:gte-large": {
|
|
127
|
-
"id": "local:gte-large",
|
|
128
|
-
"provider": "mlx",
|
|
129
|
-
"model": "gte-large",
|
|
130
|
-
"dimensions": 1024,
|
|
131
|
-
"grade": "production",
|
|
132
|
-
"family": "local",
|
|
133
|
-
"label": "GTE Large local",
|
|
134
|
-
"detail": "Large local semantic embedding profile.",
|
|
135
|
-
},
|
|
136
|
-
"ollama:nomic-embed-text": {
|
|
137
|
-
"id": "ollama:nomic-embed-text",
|
|
138
|
-
"provider": "ollama",
|
|
139
|
-
"model": "nomic-embed-text",
|
|
140
|
-
"dimensions": 768,
|
|
141
|
-
"grade": "production",
|
|
142
|
-
"family": "ollama",
|
|
143
|
-
"label": "Ollama Nomic Embed Text",
|
|
144
|
-
"detail": "Production semantic embeddings through Ollama.",
|
|
145
|
-
},
|
|
146
|
-
"ollama:mxbai-embed-large": {
|
|
147
|
-
"id": "ollama:mxbai-embed-large",
|
|
148
|
-
"provider": "ollama",
|
|
149
|
-
"model": "mxbai-embed-large",
|
|
150
|
-
"dimensions": 1024,
|
|
151
|
-
"grade": "production",
|
|
152
|
-
"family": "ollama",
|
|
153
|
-
"label": "Ollama MXBAI Embed Large",
|
|
154
|
-
"detail": "High-quality local semantic embeddings through Ollama.",
|
|
155
|
-
},
|
|
156
|
-
"ollama:bge-m3": {
|
|
157
|
-
"id": "ollama:bge-m3",
|
|
158
|
-
"provider": "ollama",
|
|
159
|
-
"model": "bge-m3",
|
|
160
|
-
"dimensions": 1024,
|
|
161
|
-
"grade": "production",
|
|
162
|
-
"family": "ollama",
|
|
163
|
-
"label": "Ollama BGE-M3-compatible",
|
|
164
|
-
"detail": "BGE-M3-compatible providers exposed through Ollama.",
|
|
165
|
-
},
|
|
166
|
-
"mlx:bge-m3": {
|
|
167
|
-
"id": "mlx:bge-m3",
|
|
168
|
-
"provider": "mlx",
|
|
169
|
-
"model": "bge-m3",
|
|
170
|
-
"dimensions": 1024,
|
|
171
|
-
"grade": "production",
|
|
172
|
-
"family": "mlx",
|
|
173
|
-
"label": "MLX BGE-M3",
|
|
174
|
-
"detail": "Apple Silicon optimized local embeddings.",
|
|
175
|
-
},
|
|
176
|
-
"openai:text-embedding-3-small": {
|
|
177
|
-
"id": "openai:text-embedding-3-small",
|
|
178
|
-
"provider": "openai",
|
|
179
|
-
"model": "text-embedding-3-small",
|
|
180
|
-
"dimensions": 1536,
|
|
181
|
-
"grade": "production",
|
|
182
|
-
"family": "openai-compatible",
|
|
183
|
-
"label": "OpenAI-compatible small",
|
|
184
|
-
"detail": "OpenAI-compatible /v1/embeddings endpoint.",
|
|
185
|
-
},
|
|
186
|
-
"openai:text-embedding-3-large": {
|
|
187
|
-
"id": "openai:text-embedding-3-large",
|
|
188
|
-
"provider": "openai",
|
|
189
|
-
"model": "text-embedding-3-large",
|
|
190
|
-
"dimensions": 3072,
|
|
191
|
-
"grade": "production",
|
|
192
|
-
"family": "openai-compatible",
|
|
193
|
-
"label": "OpenAI-compatible large",
|
|
194
|
-
"detail": "Highest-dimensional OpenAI-compatible embedding profile.",
|
|
195
|
-
},
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def embedding_provider_profiles() -> List[Dict[str, Any]]:
|
|
200
|
-
return [dict(PRODUCTION_PROVIDER_PROFILES[key]) for key in sorted(PRODUCTION_PROVIDER_PROFILES)]
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def resolve_embedding_profile(profile: str) -> Dict[str, Any]:
|
|
204
|
-
if not profile:
|
|
205
|
-
return {}
|
|
206
|
-
key = str(profile).strip().lower()
|
|
207
|
-
if key in PRODUCTION_PROVIDER_PROFILES:
|
|
208
|
-
return dict(PRODUCTION_PROVIDER_PROFILES[key])
|
|
209
|
-
raise ValueError(f"unknown embedding profile: {profile!r}")
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def _guess_dim(model: str, default: int) -> int:
|
|
213
|
-
key = str(model or "").split("/")[-1].strip().lower()
|
|
214
|
-
key = key.split(":")[0]
|
|
215
|
-
return _KNOWN_DIMS.get(key, default)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def _l2_normalize(vector: Sequence[float]) -> List[float]:
|
|
219
|
-
norm = math.sqrt(sum(float(v) * float(v) for v in vector))
|
|
220
|
-
if norm <= 0:
|
|
221
|
-
return [float(v) for v in vector]
|
|
222
|
-
return [float(v) / norm for v in vector]
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
class EmbeddingProvider:
|
|
226
|
-
"""Interface every embedder implements.
|
|
227
|
-
|
|
228
|
-
Subclasses must set ``model_id`` and ``dim`` and implement
|
|
229
|
-
:meth:`embed_batch`; the rest (single embed, codec, similarity) is shared.
|
|
230
|
-
"""
|
|
231
|
-
|
|
232
|
-
#: stable identity stored alongside every vector — change ⇒ re-index
|
|
233
|
-
model_id: str = ""
|
|
234
|
-
#: vector dimensionality
|
|
235
|
-
dim: int = DEFAULT_EMBEDDING_DIM
|
|
236
|
-
#: short provider kind ("hash" | "mlx" | "ollama" | "openai" | "custom")
|
|
237
|
-
provider: str = "hash"
|
|
238
|
-
#: "fallback" (hash) | "production" (real semantic model)
|
|
239
|
-
grade: str = "production"
|
|
240
|
-
|
|
241
|
-
# ── required ──────────────────────────────────────────────────────────
|
|
242
|
-
def embed_batch(self, texts: Sequence[str]) -> List[List[float]]:
|
|
243
|
-
raise NotImplementedError
|
|
244
|
-
|
|
245
|
-
# ── derived (shared) ──────────────────────────────────────────────────
|
|
246
|
-
def _model_id_with_dim(self, dim: int) -> str:
|
|
247
|
-
"""This provider's ``model_id`` restated at ``dim``.
|
|
248
|
-
|
|
249
|
-
Every provider spells its identity ``<kind>:<model>:<dim>``, so the
|
|
250
|
-
numeric tail is what moves when a live call reveals the model's true
|
|
251
|
-
width. An id without such a tail does not encode a dimension and is
|
|
252
|
-
returned unchanged — the index keys on ``model_id`` *and* ``dim``, so
|
|
253
|
-
nothing becomes ambiguous.
|
|
254
|
-
"""
|
|
255
|
-
head, sep, tail = self.model_id.rpartition(":")
|
|
256
|
-
if sep and tail.isdigit():
|
|
257
|
-
return f"{head}:{dim}"
|
|
258
|
-
return self.model_id
|
|
259
|
-
|
|
260
|
-
def embed(self, text: str) -> List[float]:
|
|
261
|
-
result = self.embed_batch([text])
|
|
262
|
-
return result[0] if result else [0.0] * self.dim
|
|
263
|
-
|
|
264
|
-
def encode(self, vector: Iterable[float]) -> bytes:
|
|
265
|
-
values = [float(v) for v in vector]
|
|
266
|
-
return struct.pack(f"<{len(values)}f", *values)
|
|
267
|
-
|
|
268
|
-
def decode(self, payload: bytes, dim: Optional[int] = None) -> List[float]:
|
|
269
|
-
if not payload:
|
|
270
|
-
return []
|
|
271
|
-
count = int(dim or self.dim)
|
|
272
|
-
if len(payload) != count * 4:
|
|
273
|
-
count = len(payload) // 4
|
|
274
|
-
return list(struct.unpack(f"<{count}f", payload[: count * 4]))
|
|
275
|
-
|
|
276
|
-
def similarity(self, left: Iterable[float], right: Iterable[float]) -> float:
|
|
277
|
-
# strict=True: a dimension mismatch means the two vectors came from
|
|
278
|
-
# different embedding models. Truncating to the shorter one produces a
|
|
279
|
-
# plausible-looking similarity that is meaningless — exactly the silent
|
|
280
|
-
# wrongness this codebase keeps finding. Callers that can hit a model
|
|
281
|
-
# swap already handle failure and fall back to lexical search.
|
|
282
|
-
left_v, right_v = list(left), list(right)
|
|
283
|
-
if len(left_v) != len(right_v):
|
|
284
|
-
raise ValueError(
|
|
285
|
-
f"embedding dimension mismatch: {len(left_v)} vs {len(right_v)}; "
|
|
286
|
-
"the vector index was built with a different model"
|
|
287
|
-
)
|
|
288
|
-
return float(sum(a * b for a, b in zip(left_v, right_v, strict=True)))
|
|
289
|
-
|
|
290
|
-
# ── observability ─────────────────────────────────────────────────────
|
|
291
|
-
def health(self) -> Dict[str, Any]:
|
|
292
|
-
"""Return ``{status, detail}``; status ∈ ok | unavailable."""
|
|
293
|
-
return {"status": "ok", "detail": "ready"}
|
|
294
|
-
|
|
295
|
-
def metadata(self) -> Dict[str, Any]:
|
|
296
|
-
return {
|
|
297
|
-
"provider": self.provider,
|
|
298
|
-
"model": self.model_id,
|
|
299
|
-
"model_id": self.model_id,
|
|
300
|
-
"dim": self.dim,
|
|
301
|
-
"grade": self.grade,
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
# ── 1. Hash (offline fallback) ────────────────────────────────────────────────
|
|
306
|
-
class HashEmbeddingProvider(EmbeddingProvider):
|
|
307
|
-
"""Deterministic feature-hashing embedder — no network, always available."""
|
|
308
|
-
|
|
309
|
-
provider = "hash"
|
|
310
|
-
grade = "fallback"
|
|
311
|
-
|
|
312
|
-
def __init__(self, dim: int = DEFAULT_EMBEDDING_DIM):
|
|
313
|
-
self._model = LocalEmbeddingModel(dim=dim)
|
|
314
|
-
self.dim = self._model.dim
|
|
315
|
-
self.model_id = self._model.model_id
|
|
316
|
-
|
|
317
|
-
def embed(self, text: str) -> List[float]:
|
|
318
|
-
return self._model.embed(text) # already L2-normalized
|
|
319
|
-
|
|
320
|
-
def embed_batch(self, texts: Sequence[str]) -> List[List[float]]:
|
|
321
|
-
return [self._model.embed(t) for t in texts]
|
|
322
|
-
|
|
323
|
-
def health(self) -> Dict[str, Any]:
|
|
324
|
-
return {"status": "ok", "detail": "deterministic local fallback"}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
def _as_float_list(value: Any) -> List[Any]:
|
|
328
|
-
"""A pooled embedding row as a flat list.
|
|
329
|
-
|
|
330
|
-
``mx.array.tolist()`` is typed as scalar-or-nested; at this call site the
|
|
331
|
-
array is always 1-D, so a scalar would be a bug worth surfacing.
|
|
332
|
-
"""
|
|
333
|
-
if isinstance(value, (int, float)):
|
|
334
|
-
raise EmbeddingUnavailable("MLX embedding produced a scalar, not a vector")
|
|
335
|
-
return list(value)
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
# ── shared base for remote/model-backed providers ─────────────────────────────
|
|
339
|
-
@dataclass
|
|
340
|
-
class _RemoteConfig:
|
|
341
|
-
model: str
|
|
342
|
-
base_url: str = ""
|
|
343
|
-
api_key: str = ""
|
|
344
|
-
dim: int = DEFAULT_EMBEDDING_DIM
|
|
345
|
-
timeout: float = 30.0
|
|
346
|
-
extra: Dict[str, Any] = field(default_factory=dict)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
class _NetworkEmbeddingProvider(EmbeddingProvider):
|
|
350
|
-
"""Common machinery for providers that call a model/server to embed."""
|
|
351
|
-
|
|
352
|
-
def __init__(self, cfg: _RemoteConfig):
|
|
353
|
-
self._cfg = cfg
|
|
354
|
-
self.dim = int(cfg.dim or DEFAULT_EMBEDDING_DIM)
|
|
355
|
-
|
|
356
|
-
# subclasses implement the raw call
|
|
357
|
-
def _embed_raw(self, texts: Sequence[str]) -> List[List[float]]:
|
|
358
|
-
raise NotImplementedError
|
|
359
|
-
|
|
360
|
-
def embed_batch(self, texts: Sequence[str]) -> List[List[float]]:
|
|
361
|
-
clean = [str(t or "")[:50_000] for t in texts]
|
|
362
|
-
if not clean:
|
|
363
|
-
return []
|
|
364
|
-
vectors = self._embed_raw(clean)
|
|
365
|
-
out: List[List[float]] = []
|
|
366
|
-
for vec in vectors:
|
|
367
|
-
vec = [float(x) for x in (vec or [])]
|
|
368
|
-
if vec:
|
|
369
|
-
# lock the index identity to the true model dimensionality —
|
|
370
|
-
# the id carries that dimension, so it moves with it or the
|
|
371
|
-
# vectors end up filed under a width they do not have
|
|
372
|
-
self.dim = len(vec)
|
|
373
|
-
self.model_id = self._model_id_with_dim(self.dim)
|
|
374
|
-
out.append(_l2_normalize(vec) if vec else [0.0] * self.dim)
|
|
375
|
-
return out
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
# ── 2. MLX (local Apple-Silicon model) ────────────────────────────────────────
|
|
379
|
-
class MLXEmbeddingProvider(_NetworkEmbeddingProvider):
|
|
380
|
-
provider = "mlx"
|
|
381
|
-
|
|
382
|
-
def __init__(self, cfg: _RemoteConfig):
|
|
383
|
-
super().__init__(cfg)
|
|
384
|
-
if not cfg.dim:
|
|
385
|
-
self.dim = _guess_dim(cfg.model, DEFAULT_EMBEDDING_DIM)
|
|
386
|
-
self.model_id = f"mlx:{cfg.model}:{self.dim}"
|
|
387
|
-
self._encoder: Optional[Tuple[str, Any, Any]] = None
|
|
388
|
-
|
|
389
|
-
def _load(self):
|
|
390
|
-
if self._encoder is not None:
|
|
391
|
-
return self._encoder
|
|
392
|
-
try: # optional dependency; only imported when this provider is used
|
|
393
|
-
from mlx_embeddings.utils import load as mlx_load # type: ignore
|
|
394
|
-
|
|
395
|
-
model, tokenizer = mlx_load(self._cfg.model)
|
|
396
|
-
self._encoder = ("mlx_embeddings", model, tokenizer)
|
|
397
|
-
return self._encoder
|
|
398
|
-
except Exception as exc: # pragma: no cover - environment dependent
|
|
399
|
-
raise EmbeddingUnavailable(f"MLX embedding model unavailable: {exc}") from exc
|
|
400
|
-
|
|
401
|
-
def _embed_raw(self, texts: Sequence[str]) -> List[List[float]]:
|
|
402
|
-
kind, model, tokenizer = self._load()
|
|
403
|
-
try:
|
|
404
|
-
import mlx.core as mx # type: ignore
|
|
405
|
-
|
|
406
|
-
out: List[List[float]] = []
|
|
407
|
-
for text in texts:
|
|
408
|
-
ids = tokenizer.encode(text)
|
|
409
|
-
tokens = mx.array([ids])
|
|
410
|
-
result = model(tokens)
|
|
411
|
-
pooled = result[0] if isinstance(result, (tuple, list)) else result
|
|
412
|
-
vec = mx.mean(pooled, axis=1)[0] if pooled.ndim == 3 else pooled[0]
|
|
413
|
-
out.append([float(x) for x in _as_float_list(vec.tolist())])
|
|
414
|
-
return out
|
|
415
|
-
except EmbeddingUnavailable:
|
|
416
|
-
raise
|
|
417
|
-
except Exception as exc: # pragma: no cover - environment dependent
|
|
418
|
-
raise EmbeddingUnavailable(f"MLX embedding failed: {exc}") from exc
|
|
419
|
-
|
|
420
|
-
def health(self) -> Dict[str, Any]:
|
|
421
|
-
try:
|
|
422
|
-
self._load()
|
|
423
|
-
return {"status": "ok", "detail": f"MLX model {self._cfg.model} loaded"}
|
|
424
|
-
except Exception as exc:
|
|
425
|
-
return {"status": "unavailable", "detail": str(exc)}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
# ── 3. Ollama ─────────────────────────────────────────────────────────────────
|
|
429
|
-
class OllamaEmbeddingProvider(_NetworkEmbeddingProvider):
|
|
430
|
-
provider = "ollama"
|
|
431
|
-
|
|
432
|
-
def __init__(self, cfg: _RemoteConfig):
|
|
433
|
-
super().__init__(cfg)
|
|
434
|
-
self._base = (cfg.base_url or "http://127.0.0.1:11434").rstrip("/")
|
|
435
|
-
if not cfg.dim:
|
|
436
|
-
self.dim = _guess_dim(cfg.model, DEFAULT_EMBEDDING_DIM)
|
|
437
|
-
self.model_id = f"ollama:{cfg.model}:{self.dim}"
|
|
438
|
-
|
|
439
|
-
def _embed_raw(self, texts: Sequence[str]) -> List[List[float]]:
|
|
440
|
-
out: List[List[float]] = []
|
|
441
|
-
try:
|
|
442
|
-
import httpx
|
|
443
|
-
|
|
444
|
-
with httpx.Client(timeout=self._cfg.timeout) as client:
|
|
445
|
-
# /api/embed supports batching; fall back to /api/embeddings.
|
|
446
|
-
resp = client.post(
|
|
447
|
-
f"{self._base}/api/embed",
|
|
448
|
-
json={"model": self._cfg.model, "input": list(texts)},
|
|
449
|
-
)
|
|
450
|
-
if resp.status_code == 404:
|
|
451
|
-
for text in texts:
|
|
452
|
-
r = client.post(
|
|
453
|
-
f"{self._base}/api/embeddings",
|
|
454
|
-
json={"model": self._cfg.model, "prompt": text},
|
|
455
|
-
)
|
|
456
|
-
r.raise_for_status()
|
|
457
|
-
out.append(r.json().get("embedding") or [])
|
|
458
|
-
return out
|
|
459
|
-
resp.raise_for_status()
|
|
460
|
-
data = resp.json()
|
|
461
|
-
return data.get("embeddings") or [data.get("embedding") or []]
|
|
462
|
-
except Exception as exc:
|
|
463
|
-
raise EmbeddingUnavailable(f"Ollama embedding failed: {exc}") from exc
|
|
464
|
-
|
|
465
|
-
def health(self) -> Dict[str, Any]:
|
|
466
|
-
try:
|
|
467
|
-
import httpx
|
|
468
|
-
|
|
469
|
-
with httpx.Client(timeout=min(self._cfg.timeout, 5.0)) as client:
|
|
470
|
-
r = client.get(f"{self._base}/api/tags")
|
|
471
|
-
r.raise_for_status()
|
|
472
|
-
return {"status": "ok", "detail": f"Ollama reachable at {self._base}"}
|
|
473
|
-
except Exception as exc:
|
|
474
|
-
return {"status": "unavailable", "detail": f"Ollama unreachable: {exc}"}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
# ── 4. OpenAI-compatible (/v1/embeddings) ─────────────────────────────────────
|
|
478
|
-
class OpenAICompatibleEmbeddingProvider(_NetworkEmbeddingProvider):
|
|
479
|
-
provider = "openai"
|
|
480
|
-
|
|
481
|
-
def __init__(self, cfg: _RemoteConfig):
|
|
482
|
-
super().__init__(cfg)
|
|
483
|
-
self._base = (cfg.base_url or "https://api.openai.com/v1").rstrip("/")
|
|
484
|
-
if not cfg.dim:
|
|
485
|
-
self.dim = _guess_dim(cfg.model, DEFAULT_EMBEDDING_DIM)
|
|
486
|
-
self.model_id = f"openai:{cfg.model}:{self.dim}"
|
|
487
|
-
|
|
488
|
-
def _headers(self) -> Dict[str, str]:
|
|
489
|
-
headers = {"Content-Type": "application/json"}
|
|
490
|
-
if self._cfg.api_key:
|
|
491
|
-
headers["Authorization"] = f"Bearer {self._cfg.api_key}"
|
|
492
|
-
return headers
|
|
493
|
-
|
|
494
|
-
def _embed_raw(self, texts: Sequence[str]) -> List[List[float]]:
|
|
495
|
-
try:
|
|
496
|
-
import httpx
|
|
497
|
-
|
|
498
|
-
with httpx.Client(timeout=self._cfg.timeout) as client:
|
|
499
|
-
r = client.post(
|
|
500
|
-
f"{self._base}/embeddings",
|
|
501
|
-
headers=self._headers(),
|
|
502
|
-
json={"model": self._cfg.model, "input": list(texts)},
|
|
503
|
-
)
|
|
504
|
-
r.raise_for_status()
|
|
505
|
-
rows = sorted(r.json().get("data", []), key=lambda d: d.get("index", 0))
|
|
506
|
-
return [row.get("embedding") or [] for row in rows]
|
|
507
|
-
except Exception as exc:
|
|
508
|
-
raise EmbeddingUnavailable(f"OpenAI-compatible embedding failed: {exc}") from exc
|
|
509
|
-
|
|
510
|
-
def health(self) -> Dict[str, Any]:
|
|
511
|
-
try:
|
|
512
|
-
self._embed_raw(["ping"])
|
|
513
|
-
return {"status": "ok", "detail": f"{self._base} reachable"}
|
|
514
|
-
except Exception as exc:
|
|
515
|
-
return {"status": "unavailable", "detail": str(exc)}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
# ── 5. Custom (user-supplied callable) ────────────────────────────────────────
|
|
519
|
-
class CustomEmbeddingProvider(_NetworkEmbeddingProvider):
|
|
520
|
-
"""Loads a dotted ``module:callable`` (or ``module.callable``).
|
|
521
|
-
|
|
522
|
-
The callable receives ``List[str]`` and returns ``List[List[float]]``.
|
|
523
|
-
Configured via ``LATTICEAI_EMBEDDING_CUSTOM_TARGET``.
|
|
524
|
-
"""
|
|
525
|
-
|
|
526
|
-
provider = "custom"
|
|
527
|
-
|
|
528
|
-
def __init__(self, cfg: _RemoteConfig):
|
|
529
|
-
super().__init__(cfg)
|
|
530
|
-
self._target_ref = str(cfg.extra.get("target") or os.getenv("LATTICEAI_EMBEDDING_CUSTOM_TARGET", ""))
|
|
531
|
-
self.model_id = f"custom:{cfg.model or self._target_ref or 'callable'}:{self.dim}"
|
|
532
|
-
self._fn: Optional[Callable[..., Any]] = None
|
|
533
|
-
|
|
534
|
-
def _load(self):
|
|
535
|
-
if self._fn is not None:
|
|
536
|
-
return self._fn
|
|
537
|
-
ref = self._target_ref
|
|
538
|
-
if not ref:
|
|
539
|
-
raise EmbeddingUnavailable("custom embedding target not configured (LATTICEAI_EMBEDDING_CUSTOM_TARGET)")
|
|
540
|
-
module_name, _, attr = ref.replace(":", ".").rpartition(".")
|
|
541
|
-
if not module_name:
|
|
542
|
-
raise EmbeddingUnavailable(f"invalid custom embedding target: {ref}")
|
|
543
|
-
try:
|
|
544
|
-
module = importlib.import_module(module_name)
|
|
545
|
-
self._fn = getattr(module, attr)
|
|
546
|
-
return self._fn
|
|
547
|
-
except Exception as exc:
|
|
548
|
-
raise EmbeddingUnavailable(f"custom embedding target unavailable: {exc}") from exc
|
|
549
|
-
|
|
550
|
-
def _embed_raw(self, texts: Sequence[str]) -> List[List[float]]:
|
|
551
|
-
fn = self._load()
|
|
552
|
-
try:
|
|
553
|
-
return list(fn(list(texts)))
|
|
554
|
-
except Exception as exc:
|
|
555
|
-
raise EmbeddingUnavailable(f"custom embedding failed: {exc}") from exc
|
|
556
|
-
|
|
557
|
-
def health(self) -> Dict[str, Any]:
|
|
558
|
-
try:
|
|
559
|
-
self._load()
|
|
560
|
-
return {"status": "ok", "detail": f"custom target {self._target_ref} loaded"}
|
|
561
|
-
except Exception as exc:
|
|
562
|
-
return {"status": "unavailable", "detail": str(exc)}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
# ── factory + resolution ──────────────────────────────────────────────────────
|
|
566
|
-
PROVIDER_TYPES = ("hash", "mlx", "ollama", "openai", "custom")
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
def build_embedding_provider(
|
|
570
|
-
provider: str,
|
|
571
|
-
*,
|
|
572
|
-
model: str = "",
|
|
573
|
-
base_url: str = "",
|
|
574
|
-
api_key: str = "",
|
|
575
|
-
dim: int = 0,
|
|
576
|
-
timeout: float = 30.0,
|
|
577
|
-
extra: Optional[Dict[str, Any]] = None,
|
|
578
|
-
) -> EmbeddingProvider:
|
|
579
|
-
"""Construct a provider by name. Never makes a network call."""
|
|
580
|
-
kind = str(provider or "hash").strip().lower()
|
|
581
|
-
if kind in {"", "hash", "local", "fallback"}:
|
|
582
|
-
return HashEmbeddingProvider(dim=int(dim or DEFAULT_EMBEDDING_DIM))
|
|
583
|
-
cfg = _RemoteConfig(
|
|
584
|
-
model=model,
|
|
585
|
-
base_url=base_url,
|
|
586
|
-
api_key=api_key,
|
|
587
|
-
dim=int(dim or 0),
|
|
588
|
-
timeout=float(timeout or 30.0),
|
|
589
|
-
extra=dict(extra or {}),
|
|
590
|
-
)
|
|
591
|
-
if kind == "mlx":
|
|
592
|
-
return MLXEmbeddingProvider(cfg)
|
|
593
|
-
if kind == "ollama":
|
|
594
|
-
return OllamaEmbeddingProvider(cfg)
|
|
595
|
-
if kind in {"openai", "openai-compatible", "openai_compatible"}:
|
|
596
|
-
return OpenAICompatibleEmbeddingProvider(cfg)
|
|
597
|
-
if kind == "custom":
|
|
598
|
-
return CustomEmbeddingProvider(cfg)
|
|
599
|
-
raise ValueError(f"unknown embedding provider: {provider!r} (expected one of {PROVIDER_TYPES})")
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
@dataclass
|
|
603
|
-
class ResolvedEmbedder:
|
|
604
|
-
provider: EmbeddingProvider
|
|
605
|
-
requested: str
|
|
606
|
-
active: str
|
|
607
|
-
fell_back: bool
|
|
608
|
-
health: Dict[str, Any]
|
|
609
|
-
detail: str = ""
|
|
610
|
-
|
|
611
|
-
def as_dict(self) -> Dict[str, Any]:
|
|
612
|
-
return {
|
|
613
|
-
"requested_provider": self.requested,
|
|
614
|
-
"active_provider": self.active,
|
|
615
|
-
"fell_back": self.fell_back,
|
|
616
|
-
"health": self.health,
|
|
617
|
-
"detail": self.detail,
|
|
618
|
-
**self.provider.metadata(),
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
def resolve_embedder(
|
|
623
|
-
provider: str = "",
|
|
624
|
-
*,
|
|
625
|
-
model: str = "",
|
|
626
|
-
base_url: str = "",
|
|
627
|
-
api_key: str = "",
|
|
628
|
-
dim: int = 0,
|
|
629
|
-
timeout: float = 30.0,
|
|
630
|
-
extra: Optional[Dict[str, Any]] = None,
|
|
631
|
-
probe: bool = True,
|
|
632
|
-
) -> ResolvedEmbedder:
|
|
633
|
-
"""Build the requested provider, degrading to hash if it is unavailable.
|
|
634
|
-
|
|
635
|
-
Local-first guarantee: the app always gets a working embedder. When the
|
|
636
|
-
requested provider is unreachable we return the hash fallback but record
|
|
637
|
-
``fell_back=True`` and the failing health detail so the UI shows it as
|
|
638
|
-
*Unavailable* — the system never pretends a down provider is live.
|
|
639
|
-
"""
|
|
640
|
-
requested = str(provider or "hash").strip().lower() or "hash"
|
|
641
|
-
if requested in {"hash", "local", "fallback", ""}:
|
|
642
|
-
hash_prov = HashEmbeddingProvider(dim=int(dim or DEFAULT_EMBEDDING_DIM))
|
|
643
|
-
return ResolvedEmbedder(
|
|
644
|
-
hash_prov, "hash", "hash", False, hash_prov.health(), "deterministic local fallback"
|
|
645
|
-
)
|
|
646
|
-
|
|
647
|
-
try:
|
|
648
|
-
prov = build_embedding_provider(
|
|
649
|
-
requested, model=model, base_url=base_url, api_key=api_key, dim=dim, timeout=timeout, extra=extra
|
|
650
|
-
)
|
|
651
|
-
except Exception as exc:
|
|
652
|
-
fallback = HashEmbeddingProvider(dim=int(dim or DEFAULT_EMBEDDING_DIM))
|
|
653
|
-
return ResolvedEmbedder(
|
|
654
|
-
fallback, requested, "hash", True,
|
|
655
|
-
{"status": "unavailable", "detail": str(exc)},
|
|
656
|
-
f"could not construct {requested}; using hash fallback",
|
|
657
|
-
)
|
|
658
|
-
|
|
659
|
-
if probe:
|
|
660
|
-
try:
|
|
661
|
-
health = prov.health()
|
|
662
|
-
except Exception as exc: # provider health must never crash startup
|
|
663
|
-
health = {"status": "unavailable", "detail": str(exc)}
|
|
664
|
-
else:
|
|
665
|
-
health = {"status": "unknown", "detail": "not probed"}
|
|
666
|
-
if probe and health.get("status") != "ok":
|
|
667
|
-
fallback = HashEmbeddingProvider(dim=int(dim or DEFAULT_EMBEDDING_DIM))
|
|
668
|
-
return ResolvedEmbedder(
|
|
669
|
-
fallback, requested, "hash", True, health,
|
|
670
|
-
f"{requested} unavailable ({health.get('detail', '')}); using hash fallback",
|
|
671
|
-
)
|
|
672
|
-
return ResolvedEmbedder(prov, requested, prov.provider, False, health, "")
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
# ── Vision (image) embedding seam ─────────────────────────────────────────────
|
|
676
|
-
#: CLIP ViT-B/32 width — the most common local image-embedding output.
|
|
677
|
-
DEFAULT_VISION_DIM = 512
|
|
678
|
-
#: Image vectors live in their own index and join text results by late fusion.
|
|
679
|
-
VISION_SPACE_IMAGE = "image"
|
|
680
|
-
#: A genuinely multimodal model (CLIP-style) can share the text space — opt-in.
|
|
681
|
-
VISION_SPACE_SHARED = "shared"
|
|
682
|
-
VISION_SPACES = (VISION_SPACE_IMAGE, VISION_SPACE_SHARED)
|
|
683
|
-
VISION_PROVIDER_TYPES = ("mlx", "custom")
|
|
684
|
-
VISION_TARGET_ENV = "LATTICEAI_VISION_EMBEDDING_TARGET"
|
|
685
|
-
VISION_CAPTION_TARGET_ENV = "LATTICEAI_VISION_CAPTION_TARGET"
|
|
686
|
-
|
|
687
|
-
_KNOWN_VISION_DIMS = {
|
|
688
|
-
"clip-vit-base-patch32": 512,
|
|
689
|
-
"clip-vit-base-patch16": 512,
|
|
690
|
-
"clip-vit-large-patch14": 768,
|
|
691
|
-
"siglip-base-patch16-224": 768,
|
|
692
|
-
"siglip-large-patch16-384": 1024,
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
def _guess_vision_dim(model: str, default: int) -> int:
|
|
697
|
-
key = str(model or "").split("/")[-1].strip().lower()
|
|
698
|
-
key = key.split(":")[0]
|
|
699
|
-
return _KNOWN_VISION_DIMS.get(key, default)
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
def _normalize_space(value: Any) -> str:
|
|
703
|
-
space = str(value or "").strip().lower()
|
|
704
|
-
return space if space in VISION_SPACES else VISION_SPACE_IMAGE
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
class VisionEmbeddingProvider(EmbeddingProvider):
|
|
708
|
-
"""Turns an image *file path* into a vector.
|
|
709
|
-
|
|
710
|
-
Subclasses implement :meth:`embed_images`; everything else — the single
|
|
711
|
-
``embed_image``, L2 normalization, locking the index identity to the width
|
|
712
|
-
the model actually returned, and the refusal to embed text outside a shared
|
|
713
|
-
space — is shared here.
|
|
714
|
-
"""
|
|
715
|
-
|
|
716
|
-
provider = "vision"
|
|
717
|
-
grade = "production"
|
|
718
|
-
#: ``"image"`` (own index, late fusion) or ``"shared"`` (same space as text)
|
|
719
|
-
space: str = VISION_SPACE_IMAGE
|
|
720
|
-
|
|
721
|
-
def __init__(self, cfg: _RemoteConfig):
|
|
722
|
-
self._cfg = cfg
|
|
723
|
-
self.dim = int(cfg.dim or DEFAULT_VISION_DIM)
|
|
724
|
-
self.space = _normalize_space(cfg.extra.get("space"))
|
|
725
|
-
|
|
726
|
-
# ── required ──────────────────────────────────────────────────────────
|
|
727
|
-
def embed_images(self, paths: Sequence[str]) -> List[List[float]]:
|
|
728
|
-
raise NotImplementedError
|
|
729
|
-
|
|
730
|
-
# ── derived (shared) ──────────────────────────────────────────────────
|
|
731
|
-
@property
|
|
732
|
-
def shares_text_space(self) -> bool:
|
|
733
|
-
"""True when a text query may be scored against these vectors."""
|
|
734
|
-
return self.space == VISION_SPACE_SHARED
|
|
735
|
-
|
|
736
|
-
def embed_image(self, path: str) -> List[float]:
|
|
737
|
-
vectors = self.embed_images([path])
|
|
738
|
-
if not vectors:
|
|
739
|
-
raise EmbeddingUnavailable(f"{self.model_id} returned no vector for {path}")
|
|
740
|
-
return vectors[0]
|
|
741
|
-
|
|
742
|
-
def embed_batch(self, texts: Sequence[str]) -> List[List[float]]:
|
|
743
|
-
"""Text side of a multimodal model — only in a shared space.
|
|
744
|
-
|
|
745
|
-
Scoring a BGE query vector against CLIP image vectors produces a number
|
|
746
|
-
with no meaning, so the image-space default refuses rather than
|
|
747
|
-
returning something a caller would rank on.
|
|
748
|
-
"""
|
|
749
|
-
if not self.shares_text_space:
|
|
750
|
-
raise EmbeddingUnavailable(
|
|
751
|
-
f"{self.model_id} embeds images into a separate space; text "
|
|
752
|
-
"queries reach image nodes through late fusion, not this index"
|
|
753
|
-
)
|
|
754
|
-
return self._normalize_rows(self._embed_texts_raw(texts))
|
|
755
|
-
|
|
756
|
-
def _embed_texts_raw(self, texts: Sequence[str]) -> List[List[float]]:
|
|
757
|
-
raise NotImplementedError
|
|
758
|
-
|
|
759
|
-
def _normalize_rows(self, rows: Iterable[Any]) -> List[List[float]]:
|
|
760
|
-
"""L2-normalize, and lock the index identity to the real width."""
|
|
761
|
-
out: List[List[float]] = []
|
|
762
|
-
for row in rows:
|
|
763
|
-
vec = [float(x) for x in (row or [])]
|
|
764
|
-
if not vec:
|
|
765
|
-
raise EmbeddingUnavailable(f"{self.model_id} produced an empty vector")
|
|
766
|
-
self.dim = len(vec)
|
|
767
|
-
self.model_id = self._model_id_with_dim(self.dim)
|
|
768
|
-
out.append(_l2_normalize(vec))
|
|
769
|
-
return out
|
|
770
|
-
|
|
771
|
-
def metadata(self) -> Dict[str, Any]:
|
|
772
|
-
data = super().metadata()
|
|
773
|
-
data.update(
|
|
774
|
-
{
|
|
775
|
-
"modality": "image",
|
|
776
|
-
"space": self.space,
|
|
777
|
-
"shares_text_space": self.shares_text_space,
|
|
778
|
-
}
|
|
779
|
-
)
|
|
780
|
-
return data
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
class MLXVisionEmbeddingProvider(VisionEmbeddingProvider):
|
|
784
|
-
"""Local CLIP-family image embedder loaded through ``mlx_clip``.
|
|
785
|
-
|
|
786
|
-
Guarded import, opt-in, never a core dependency: the module must expose
|
|
787
|
-
``load(model)`` returning an encoder with ``encode_image(paths)`` and —
|
|
788
|
-
for a shared space — ``encode_text(texts)``.
|
|
789
|
-
"""
|
|
790
|
-
|
|
791
|
-
provider = "mlx-vision"
|
|
792
|
-
|
|
793
|
-
def __init__(self, cfg: _RemoteConfig):
|
|
794
|
-
super().__init__(cfg)
|
|
795
|
-
if not cfg.dim:
|
|
796
|
-
self.dim = _guess_vision_dim(cfg.model, DEFAULT_VISION_DIM)
|
|
797
|
-
self.model_id = f"mlx-vision:{cfg.model}:{self.dim}"
|
|
798
|
-
self._encoder: Optional[Any] = None
|
|
799
|
-
|
|
800
|
-
def _load(self) -> Any:
|
|
801
|
-
if self._encoder is not None:
|
|
802
|
-
return self._encoder
|
|
803
|
-
try: # optional dependency; only imported when this provider is used
|
|
804
|
-
import mlx_clip # type: ignore
|
|
805
|
-
|
|
806
|
-
self._encoder = mlx_clip.load(self._cfg.model)
|
|
807
|
-
except Exception as exc:
|
|
808
|
-
raise EmbeddingUnavailable(f"MLX vision model unavailable: {exc}") from exc
|
|
809
|
-
return self._encoder
|
|
810
|
-
|
|
811
|
-
def embed_images(self, paths: Sequence[str]) -> List[List[float]]:
|
|
812
|
-
encoder = self._load()
|
|
813
|
-
try:
|
|
814
|
-
rows = encoder.encode_image(list(paths))
|
|
815
|
-
except Exception as exc:
|
|
816
|
-
raise EmbeddingUnavailable(f"MLX vision embedding failed: {exc}") from exc
|
|
817
|
-
return self._normalize_rows(rows)
|
|
818
|
-
|
|
819
|
-
def _embed_texts_raw(self, texts: Sequence[str]) -> List[List[float]]:
|
|
820
|
-
encoder = self._load()
|
|
821
|
-
encode_text = getattr(encoder, "encode_text", None)
|
|
822
|
-
if not callable(encode_text):
|
|
823
|
-
raise EmbeddingUnavailable(
|
|
824
|
-
f"{self.model_id} has no text encoder, so it cannot back a shared space"
|
|
825
|
-
)
|
|
826
|
-
try:
|
|
827
|
-
return list(encode_text(list(texts)))
|
|
828
|
-
except Exception as exc:
|
|
829
|
-
raise EmbeddingUnavailable(f"MLX vision text embedding failed: {exc}") from exc
|
|
830
|
-
|
|
831
|
-
def health(self) -> Dict[str, Any]:
|
|
832
|
-
try:
|
|
833
|
-
self._load()
|
|
834
|
-
return {"status": "ok", "detail": f"MLX vision model {self._cfg.model} loaded"}
|
|
835
|
-
except Exception as exc:
|
|
836
|
-
return {"status": "unavailable", "detail": str(exc)}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
class CustomVisionEmbeddingProvider(VisionEmbeddingProvider):
|
|
840
|
-
"""A user-supplied ``module:callable`` that embeds image paths.
|
|
841
|
-
|
|
842
|
-
The callable receives ``List[str]`` (paths) and returns
|
|
843
|
-
``List[List[float]]``. Configured via ``LATTICEAI_VISION_EMBEDDING_TARGET``.
|
|
844
|
-
"""
|
|
845
|
-
|
|
846
|
-
provider = "custom-vision"
|
|
847
|
-
|
|
848
|
-
def __init__(self, cfg: _RemoteConfig):
|
|
849
|
-
super().__init__(cfg)
|
|
850
|
-
self._target_ref = str(cfg.extra.get("target") or os.getenv(VISION_TARGET_ENV, ""))
|
|
851
|
-
self.model_id = f"custom-vision:{cfg.model or self._target_ref or 'callable'}:{self.dim}"
|
|
852
|
-
self._fn: Optional[Callable[..., Any]] = None
|
|
853
|
-
|
|
854
|
-
def _load(self) -> Callable[..., Any]:
|
|
855
|
-
if self._fn is not None:
|
|
856
|
-
return self._fn
|
|
857
|
-
self._fn = _load_dotted(self._target_ref, VISION_TARGET_ENV, "vision embedding")
|
|
858
|
-
return self._fn
|
|
859
|
-
|
|
860
|
-
def embed_images(self, paths: Sequence[str]) -> List[List[float]]:
|
|
861
|
-
fn = self._load()
|
|
862
|
-
try:
|
|
863
|
-
rows = list(fn(list(paths)))
|
|
864
|
-
except Exception as exc:
|
|
865
|
-
raise EmbeddingUnavailable(f"custom vision embedding failed: {exc}") from exc
|
|
866
|
-
return self._normalize_rows(rows)
|
|
867
|
-
|
|
868
|
-
def _embed_texts_raw(self, texts: Sequence[str]) -> List[List[float]]:
|
|
869
|
-
# A dotted image embedder is one callable over paths; a shared space
|
|
870
|
-
# would need a second, text-side entry point this contract has no slot
|
|
871
|
-
# for. Saying so beats scoring a query against the wrong function.
|
|
872
|
-
raise EmbeddingUnavailable(
|
|
873
|
-
f"{self.model_id} is an image-only callable and has no text encoder"
|
|
874
|
-
)
|
|
875
|
-
|
|
876
|
-
def health(self) -> Dict[str, Any]:
|
|
877
|
-
try:
|
|
878
|
-
self._load()
|
|
879
|
-
return {"status": "ok", "detail": f"custom vision target {self._target_ref} loaded"}
|
|
880
|
-
except Exception as exc:
|
|
881
|
-
return {"status": "unavailable", "detail": str(exc)}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
def _load_dotted(ref: str, env_name: str, label: str) -> Callable[..., Any]:
|
|
885
|
-
"""Import ``module:callable`` (or ``module.callable``) or explain why not."""
|
|
886
|
-
if not ref:
|
|
887
|
-
raise EmbeddingUnavailable(f"{label} target not configured ({env_name})")
|
|
888
|
-
module_name, _, attr = ref.replace(":", ".").rpartition(".")
|
|
889
|
-
if not module_name:
|
|
890
|
-
raise EmbeddingUnavailable(f"invalid {label} target: {ref}")
|
|
891
|
-
try:
|
|
892
|
-
module = importlib.import_module(module_name)
|
|
893
|
-
return getattr(module, attr) # type: ignore[no-any-return]
|
|
894
|
-
except Exception as exc:
|
|
895
|
-
raise EmbeddingUnavailable(f"{label} target unavailable: {exc}") from exc
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
def build_vision_provider(
|
|
899
|
-
provider: str,
|
|
900
|
-
*,
|
|
901
|
-
model: str = "",
|
|
902
|
-
dim: int = 0,
|
|
903
|
-
space: str = VISION_SPACE_IMAGE,
|
|
904
|
-
timeout: float = 30.0,
|
|
905
|
-
extra: Optional[Dict[str, Any]] = None,
|
|
906
|
-
) -> VisionEmbeddingProvider:
|
|
907
|
-
"""Construct a vision provider by name. Never makes a network call."""
|
|
908
|
-
kind = str(provider or "").strip().lower()
|
|
909
|
-
cfg = _RemoteConfig(
|
|
910
|
-
model=model,
|
|
911
|
-
dim=int(dim or 0),
|
|
912
|
-
timeout=float(timeout or 30.0),
|
|
913
|
-
extra={"space": space, **(extra or {})},
|
|
914
|
-
)
|
|
915
|
-
if kind == "mlx":
|
|
916
|
-
return MLXVisionEmbeddingProvider(cfg)
|
|
917
|
-
if kind == "custom":
|
|
918
|
-
return CustomVisionEmbeddingProvider(cfg)
|
|
919
|
-
raise ValueError(
|
|
920
|
-
f"unknown vision embedding provider: {provider!r} (expected one of {VISION_PROVIDER_TYPES})"
|
|
921
|
-
)
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
@dataclass
|
|
925
|
-
class ResolvedVisionEmbedder:
|
|
926
|
-
"""A vision provider, or an honest account of why there isn't one."""
|
|
927
|
-
|
|
928
|
-
provider: Optional[VisionEmbeddingProvider]
|
|
929
|
-
requested: str
|
|
930
|
-
health: Dict[str, Any]
|
|
931
|
-
detail: str = ""
|
|
932
|
-
|
|
933
|
-
@property
|
|
934
|
-
def available(self) -> bool:
|
|
935
|
-
return self.provider is not None
|
|
936
|
-
|
|
937
|
-
@property
|
|
938
|
-
def space(self) -> str:
|
|
939
|
-
return self.provider.space if self.provider is not None else VISION_SPACE_IMAGE
|
|
940
|
-
|
|
941
|
-
def as_port(self) -> Optional[Callable[[str], List[float]]]:
|
|
942
|
-
"""The one-argument seam Brain Core injects (``None`` when absent).
|
|
943
|
-
|
|
944
|
-
``lattice_brain`` must not import ``latticeai``, so the ingestion
|
|
945
|
-
pipeline never sees this class — only the callable it hands over.
|
|
946
|
-
"""
|
|
947
|
-
if self.provider is None:
|
|
948
|
-
return None
|
|
949
|
-
return self.provider.embed_image
|
|
950
|
-
|
|
951
|
-
def as_dict(self) -> Dict[str, Any]:
|
|
952
|
-
payload: Dict[str, Any] = {
|
|
953
|
-
"requested_provider": self.requested,
|
|
954
|
-
"available": self.available,
|
|
955
|
-
"health": self.health,
|
|
956
|
-
"detail": self.detail,
|
|
957
|
-
}
|
|
958
|
-
if self.provider is not None:
|
|
959
|
-
payload.update(self.provider.metadata())
|
|
960
|
-
return payload
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
def resolve_vision_embedder(
|
|
964
|
-
provider: str = "",
|
|
965
|
-
*,
|
|
966
|
-
model: str = "",
|
|
967
|
-
dim: int = 0,
|
|
968
|
-
space: str = VISION_SPACE_IMAGE,
|
|
969
|
-
timeout: float = 30.0,
|
|
970
|
-
extra: Optional[Dict[str, Any]] = None,
|
|
971
|
-
probe: bool = True,
|
|
972
|
-
) -> ResolvedVisionEmbedder:
|
|
973
|
-
"""Build the requested vision provider, or report it unavailable.
|
|
974
|
-
|
|
975
|
-
Unlike :func:`resolve_embedder` there is no fallback: a hashed file path is
|
|
976
|
-
not a picture. An empty ``provider`` means the user never asked for image
|
|
977
|
-
embeddings, which is a configuration state rather than a failure.
|
|
978
|
-
"""
|
|
979
|
-
requested = str(provider or "").strip().lower()
|
|
980
|
-
if not requested:
|
|
981
|
-
return ResolvedVisionEmbedder(
|
|
982
|
-
None,
|
|
983
|
-
"",
|
|
984
|
-
{"status": "unavailable", "detail": "no vision provider configured"},
|
|
985
|
-
"image embeddings are off; set a vision provider to enable them",
|
|
986
|
-
)
|
|
987
|
-
try:
|
|
988
|
-
prov = build_vision_provider(
|
|
989
|
-
requested, model=model, dim=dim, space=space, timeout=timeout, extra=extra
|
|
990
|
-
)
|
|
991
|
-
except Exception as exc:
|
|
992
|
-
return ResolvedVisionEmbedder(
|
|
993
|
-
None,
|
|
994
|
-
requested,
|
|
995
|
-
{"status": "unavailable", "detail": str(exc)},
|
|
996
|
-
f"could not construct vision provider {requested}",
|
|
997
|
-
)
|
|
998
|
-
if not probe:
|
|
999
|
-
return ResolvedVisionEmbedder(
|
|
1000
|
-
prov, requested, {"status": "unknown", "detail": "not probed"}, ""
|
|
1001
|
-
)
|
|
1002
|
-
try:
|
|
1003
|
-
health = prov.health()
|
|
1004
|
-
except Exception as exc: # a provider probe must never crash startup
|
|
1005
|
-
health = {"status": "unavailable", "detail": str(exc)}
|
|
1006
|
-
if health.get("status") != "ok":
|
|
1007
|
-
return ResolvedVisionEmbedder(
|
|
1008
|
-
None,
|
|
1009
|
-
requested,
|
|
1010
|
-
health,
|
|
1011
|
-
f"{requested} vision model unavailable ({health.get('detail', '')})",
|
|
1012
|
-
)
|
|
1013
|
-
return ResolvedVisionEmbedder(prov, requested, health, "")
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
# ── Vision captions (a VLM said this, or nobody did) ──────────────────────────
|
|
1017
|
-
class VisionCaptioner:
|
|
1018
|
-
"""Describes an image — the null implementation, which describes nothing.
|
|
1019
|
-
|
|
1020
|
-
Every "clever" fallback here is a lie: ``Image IMG_2381.png (JPEG
|
|
1021
|
-
3024x4032)`` is metadata wearing a caption's clothes, and once it is in the
|
|
1022
|
-
graph nothing downstream can tell it from a model's actual description. So
|
|
1023
|
-
the base class returns ``None`` and :meth:`available` says ``False``.
|
|
1024
|
-
"""
|
|
1025
|
-
|
|
1026
|
-
provider = "none"
|
|
1027
|
-
model_id = ""
|
|
1028
|
-
|
|
1029
|
-
def available(self) -> bool:
|
|
1030
|
-
return False
|
|
1031
|
-
|
|
1032
|
-
def caption(self, path: str, *, prompt: str = "") -> Optional[str]:
|
|
1033
|
-
return None
|
|
1034
|
-
|
|
1035
|
-
def health(self) -> Dict[str, Any]:
|
|
1036
|
-
return {"status": "unavailable", "detail": "no vision-language model is loaded"}
|
|
1037
|
-
|
|
1038
|
-
def metadata(self) -> Dict[str, Any]:
|
|
1039
|
-
return {
|
|
1040
|
-
"provider": self.provider,
|
|
1041
|
-
"model": self.model_id,
|
|
1042
|
-
"available": self.available(),
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
#: Short, literal instruction — a caption is a description, not an essay.
|
|
1047
|
-
DEFAULT_CAPTION_PROMPT = "Describe this image in one factual sentence."
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
class MLXVisionCaptioner(VisionCaptioner):
|
|
1051
|
-
"""Caption through a locally loaded ``mlx_vlm`` model (guarded import)."""
|
|
1052
|
-
|
|
1053
|
-
provider = "mlx-vlm"
|
|
1054
|
-
|
|
1055
|
-
def __init__(self, model: str, *, prompt: str = DEFAULT_CAPTION_PROMPT, max_tokens: int = 64):
|
|
1056
|
-
self.model_id = str(model or "")
|
|
1057
|
-
self._prompt = prompt or DEFAULT_CAPTION_PROMPT
|
|
1058
|
-
self._max_tokens = max(1, int(max_tokens))
|
|
1059
|
-
self._loaded: Optional[Tuple[Any, Any]] = None
|
|
1060
|
-
|
|
1061
|
-
def _load(self) -> Tuple[Any, Any]:
|
|
1062
|
-
if self._loaded is not None:
|
|
1063
|
-
return self._loaded
|
|
1064
|
-
try: # optional dependency (`pip install "ltcai[local]"`)
|
|
1065
|
-
from mlx_vlm import load as vlm_load # type: ignore
|
|
1066
|
-
|
|
1067
|
-
model, processor = vlm_load(self.model_id)
|
|
1068
|
-
self._loaded = (model, processor)
|
|
1069
|
-
except Exception as exc:
|
|
1070
|
-
raise EmbeddingUnavailable(f"vision-language model unavailable: {exc}") from exc
|
|
1071
|
-
return self._loaded
|
|
1072
|
-
|
|
1073
|
-
def available(self) -> bool:
|
|
1074
|
-
try:
|
|
1075
|
-
self._load()
|
|
1076
|
-
return True
|
|
1077
|
-
except EmbeddingUnavailable:
|
|
1078
|
-
return False
|
|
1079
|
-
|
|
1080
|
-
def caption(self, path: str, *, prompt: str = "") -> Optional[str]:
|
|
1081
|
-
try:
|
|
1082
|
-
model, processor = self._load()
|
|
1083
|
-
from mlx_vlm import generate as vlm_generate # type: ignore
|
|
1084
|
-
|
|
1085
|
-
text = vlm_generate(
|
|
1086
|
-
model,
|
|
1087
|
-
processor,
|
|
1088
|
-
str(path),
|
|
1089
|
-
prompt or self._prompt,
|
|
1090
|
-
max_tokens=self._max_tokens,
|
|
1091
|
-
)
|
|
1092
|
-
except Exception:
|
|
1093
|
-
# A caption the model did not produce is not a caption. Absence is
|
|
1094
|
-
# the honest answer, and every caller already handles it.
|
|
1095
|
-
return None
|
|
1096
|
-
cleaned = str(text or "").strip()
|
|
1097
|
-
return cleaned or None
|
|
1098
|
-
|
|
1099
|
-
def health(self) -> Dict[str, Any]:
|
|
1100
|
-
try:
|
|
1101
|
-
self._load()
|
|
1102
|
-
return {"status": "ok", "detail": f"VLM {self.model_id} loaded"}
|
|
1103
|
-
except EmbeddingUnavailable as exc:
|
|
1104
|
-
return {"status": "unavailable", "detail": str(exc)}
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
class CustomVisionCaptioner(VisionCaptioner):
|
|
1108
|
-
"""A user-supplied ``module:callable`` that captions an image path."""
|
|
1109
|
-
|
|
1110
|
-
provider = "custom-vlm"
|
|
1111
|
-
|
|
1112
|
-
def __init__(self, target: str = ""):
|
|
1113
|
-
self._target_ref = str(target or os.getenv(VISION_CAPTION_TARGET_ENV, ""))
|
|
1114
|
-
self.model_id = self._target_ref
|
|
1115
|
-
self._fn: Optional[Callable[..., Any]] = None
|
|
1116
|
-
|
|
1117
|
-
def _load(self) -> Callable[..., Any]:
|
|
1118
|
-
if self._fn is None:
|
|
1119
|
-
self._fn = _load_dotted(self._target_ref, VISION_CAPTION_TARGET_ENV, "vision caption")
|
|
1120
|
-
return self._fn
|
|
1121
|
-
|
|
1122
|
-
def available(self) -> bool:
|
|
1123
|
-
try:
|
|
1124
|
-
self._load()
|
|
1125
|
-
return True
|
|
1126
|
-
except EmbeddingUnavailable:
|
|
1127
|
-
return False
|
|
1128
|
-
|
|
1129
|
-
def caption(self, path: str, *, prompt: str = "") -> Optional[str]:
|
|
1130
|
-
try:
|
|
1131
|
-
text = self._load()(str(path), prompt or DEFAULT_CAPTION_PROMPT)
|
|
1132
|
-
except Exception:
|
|
1133
|
-
return None
|
|
1134
|
-
cleaned = str(text or "").strip()
|
|
1135
|
-
return cleaned or None
|
|
1136
|
-
|
|
1137
|
-
def health(self) -> Dict[str, Any]:
|
|
1138
|
-
try:
|
|
1139
|
-
self._load()
|
|
1140
|
-
return {"status": "ok", "detail": f"custom captioner {self._target_ref} loaded"}
|
|
1141
|
-
except EmbeddingUnavailable as exc:
|
|
1142
|
-
return {"status": "unavailable", "detail": str(exc)}
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
def resolve_vision_captioner(
|
|
1146
|
-
provider: str = "", *, model: str = "", target: str = ""
|
|
1147
|
-
) -> VisionCaptioner:
|
|
1148
|
-
"""Build a captioner, or the null one that honestly captions nothing."""
|
|
1149
|
-
kind = str(provider or "").strip().lower()
|
|
1150
|
-
if kind in {"mlx", "mlx-vlm", "mlx_vlm"} and model:
|
|
1151
|
-
return MLXVisionCaptioner(model)
|
|
1152
|
-
if kind in {"custom", "custom-vlm"}:
|
|
1153
|
-
return CustomVisionCaptioner(target)
|
|
1154
|
-
return VisionCaptioner()
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
def vision_caption_port(captioner: VisionCaptioner) -> Optional[Callable[[str], Optional[str]]]:
|
|
1158
|
-
"""The caption seam Brain Core injects — ``None`` when no VLM is loaded."""
|
|
1159
|
-
return captioner.caption if captioner.available() else None
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
__all__ = [
|
|
1163
|
-
"DEFAULT_CAPTION_PROMPT",
|
|
1164
|
-
"DEFAULT_VISION_DIM",
|
|
1165
|
-
"VISION_CAPTION_TARGET_ENV",
|
|
1166
|
-
"VISION_PROVIDER_TYPES",
|
|
1167
|
-
"VISION_SPACES",
|
|
1168
|
-
"VISION_SPACE_IMAGE",
|
|
1169
|
-
"VISION_SPACE_SHARED",
|
|
1170
|
-
"VISION_TARGET_ENV",
|
|
1171
|
-
"CustomVisionCaptioner",
|
|
1172
|
-
"CustomVisionEmbeddingProvider",
|
|
1173
|
-
"EmbeddingProvider",
|
|
1174
|
-
"EmbeddingUnavailable",
|
|
1175
|
-
"MLXVisionCaptioner",
|
|
1176
|
-
"MLXVisionEmbeddingProvider",
|
|
1177
|
-
"ResolvedVisionEmbedder",
|
|
1178
|
-
"VisionCaptioner",
|
|
1179
|
-
"VisionEmbeddingProvider",
|
|
1180
|
-
"build_vision_provider",
|
|
1181
|
-
"resolve_vision_captioner",
|
|
1182
|
-
"resolve_vision_embedder",
|
|
1183
|
-
"vision_caption_port",
|
|
1184
|
-
"HashEmbeddingProvider",
|
|
1185
|
-
"MLXEmbeddingProvider",
|
|
1186
|
-
"OllamaEmbeddingProvider",
|
|
1187
|
-
"OpenAICompatibleEmbeddingProvider",
|
|
1188
|
-
"CustomEmbeddingProvider",
|
|
1189
|
-
"ResolvedEmbedder",
|
|
1190
|
-
"build_embedding_provider",
|
|
1191
|
-
"resolve_embedder",
|
|
1192
|
-
"resolve_embedding_profile",
|
|
1193
|
-
"embedding_provider_profiles",
|
|
1194
|
-
"PRODUCTION_PROVIDER_PROFILES",
|
|
1195
|
-
"PROVIDER_TYPES",
|
|
1196
|
-
]
|