ltcai 10.9.0 → 11.0.1
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 -64
- package/docs/CHANGELOG.md +72 -237
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/PERFORMANCE.md +78 -7
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/docs/v11.1.0_PRODUCT_INTELLIGENCE_PLAN.md +313 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_contract.py +11 -0
- package/lattice_brain/graph/curator.py +1 -1
- package/lattice_brain/graph/fusion.py +184 -3
- package/lattice_brain/graph/proactive.py +138 -1
- package/lattice_brain/graph/projection.py +10 -2
- package/lattice_brain/graph/retrieval.py +137 -7
- package/lattice_brain/graph/retrieval_docgen.py +20 -20
- package/lattice_brain/graph/retrieval_policy.py +6 -0
- package/lattice_brain/graph/retrieval_reads.py +188 -1
- package/lattice_brain/graph/retrieval_vector.py +474 -110
- package/lattice_brain/graph/schema.py +125 -2
- package/lattice_brain/graph/vector_index/__init__.py +85 -0
- package/lattice_brain/graph/vector_index/base.py +170 -0
- package/lattice_brain/graph/vector_index/brute_force.py +114 -0
- package/lattice_brain/graph/vector_index/hnsw.py +293 -0
- package/lattice_brain/graph/vector_index/jobs.py +287 -0
- package/lattice_brain/graph/vector_index/quantized.py +151 -0
- package/lattice_brain/graph/vector_index/selector.py +131 -0
- package/lattice_brain/ingestion.py +50 -3
- package/lattice_brain/portability.py +654 -2
- package/lattice_brain/runtime/agent_runtime.py +1 -1
- package/lattice_brain/runtime/contracts.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/self_model.py +620 -0
- package/lattice_brain/synthesis.py +801 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +83 -1
- package/latticeai/api/chat_stream.py +4 -1
- package/latticeai/api/local_files.py +62 -0
- package/latticeai/api/models.py +1 -1
- package/latticeai/api/portability.py +130 -1
- package/latticeai/api/security_dashboard.py +48 -13
- package/latticeai/api/voice_capture.py +4 -1
- package/latticeai/api/workspace.py +11 -5
- package/latticeai/core/embedding_providers.py +20 -1
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +14 -0
- package/latticeai/core/model_compat.py +2 -2
- package/latticeai/core/tool_registry.py +0 -7
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_os_utils.py +4 -50
- package/latticeai/core/workspace_review_items.py +12 -1
- package/latticeai/integrations/telegram_bot.py +28 -10
- package/latticeai/models/router.py +1 -1
- package/latticeai/runtime/access_runtime.py +1 -1
- package/latticeai/runtime/network_boundary_wiring.py +9 -5
- package/latticeai/runtime/permission_mode_wiring.py +9 -6
- package/latticeai/runtime/router_registration.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +253 -0
- package/latticeai/services/memory_service.py +1 -1
- package/latticeai/services/model_catalog.py +4 -3
- package/latticeai/services/model_engines.py +28 -14
- package/latticeai/services/obsidian_bridge.py +618 -0
- package/latticeai/services/product_readiness.py +5 -3
- package/latticeai/tools/filesystem.py +4 -1
- package/package.json +1 -1
- package/scripts/bench_vector_index.py +295 -0
- package/scripts/check_current_release_docs.mjs +4 -2
- package/scripts/release_screen_claims.json +31 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -37
- package/static/app/assets/{Act-CS9IeqUX.js → Act-D4zSxFR-.js} +1 -1
- package/static/app/assets/{AdminConsole-3UkIEWGA.js → AdminConsole-w5jBfPt2.js} +1 -1
- package/static/app/assets/{Brain-B22EmNqS.js → Brain-C2EqQg74.js} +2 -2
- package/static/app/assets/BrainHome-CvXS6XiQ.js +2 -0
- package/static/app/assets/BrainSignals-DOE_KhOU.js +1 -0
- package/static/app/assets/Capture-DPqpGK8d.js +1 -0
- package/static/app/assets/{CommandPalette-86m4FCcN.js → CommandPalette-CNf7h5fp.js} +1 -1
- package/static/app/assets/Library-BN0HYOfc.js +1 -0
- package/static/app/assets/LivingBrain-Dfq_wEDI.js +1 -0
- package/static/app/assets/ProductFlow-B-3O0rNV.js +1 -0
- package/static/app/assets/{ReviewCard-BepjSDpN.js → ReviewCard-gZ-tdqFM.js} +1 -1
- package/static/app/assets/System-BElUcSSw.js +1 -0
- package/static/app/assets/arrow-left-CFNIMjhv.js +1 -0
- package/static/app/assets/{bot-DQj0-LkM.js → bot--qYHMtkP.js} +1 -1
- package/static/app/assets/brain-DDCLjRqO.js +1 -0
- package/static/app/assets/{button-CmTknyAP.js → button-51Z3rsuv.js} +1 -1
- package/static/app/assets/{circle-pause-yTCWRziJ.js → circle-pause-CMIiMaQl.js} +1 -1
- package/static/app/assets/{circle-play-Ccrva84R.js → circle-play-DZoO_cfG.js} +1 -1
- package/static/app/assets/{cpu-CbJqWTlS.js → cpu-Bs6uc9W9.js} +1 -1
- package/static/app/assets/{download-CkSzbzU-.js → download-G-2olkWz.js} +1 -1
- package/static/app/assets/{folder-open-CKyjQ4PU.js → folder-open-CTOspnmb.js} +1 -1
- package/static/app/assets/{hard-drive-DAzk9um0.js → hard-drive-CewHWJhn.js} +1 -1
- package/static/app/assets/index-CkzokZAj.css +2 -0
- package/static/app/assets/{index-CxOcwsHV.js → index-D7Rr-J2Y.js} +3 -3
- package/static/app/assets/{input-DcMETmZ7.js → input-D4w_BZWl.js} +1 -1
- package/static/app/assets/{permissionCopy-BVf13_25.js → permissionCopy-CosBEXAZ.js} +1 -1
- package/static/app/assets/primitives-d0g9pvzS.js +1 -0
- package/static/app/assets/search-BLCYt75v.js +1 -0
- package/static/app/assets/{share-2-COWCHNZm.js → share-2-NmD7e_oV.js} +1 -1
- package/static/app/assets/{shield-alert-BDrvilyK.js → shield-alert-CcQeMuju.js} +1 -1
- package/static/app/assets/{textarea-rUmsc8cP.js → textarea-BPAJDc-0.js} +1 -1
- package/static/app/assets/{useFocusTrap-Bi5UY_8v.js → useFocusTrap-C7YLdTBC.js} +1 -1
- package/static/app/assets/{useQuery-C-AicB-3.js → useQuery-DRyD9opW.js} +1 -1
- package/static/app/assets/{utils-BMwWg78e.js → utils-DG1_ExrP.js} +3 -3
- package/static/app/assets/{workspace-Y93tls8P.js → workspace-CWVf3gsI.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/BrainHome-CMDqgJF4.js +0 -2
- package/static/app/assets/BrainSignals-BeE8RJo3.js +0 -1
- package/static/app/assets/Capture-ZX9bQh68.js +0 -1
- package/static/app/assets/Library-Bhz5LUca.js +0 -1
- package/static/app/assets/LivingBrain-BSa0wpFG.js +0 -1
- package/static/app/assets/ProductFlow-IP4Q-5aQ.js +0 -1
- package/static/app/assets/System-Bwx1h_jT.js +0 -1
- package/static/app/assets/arrow-left-ig7AZU8B.js +0 -1
- package/static/app/assets/brain-D8OEwmVj.js +0 -1
- package/static/app/assets/index-BfD-jhA9.css +0 -2
- package/static/app/assets/primitives-CQV9Q2YM.js +0 -1
- package/static/app/assets/search-CXGASMMH.js +0 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""int8-quantized exhaustive index — same recall shape, a fraction of the RAM.
|
|
2
|
+
|
|
3
|
+
A 384-dimension embedding held as a Python ``list[float]`` costs ~3 KB (each
|
|
4
|
+
float is a boxed object plus a pointer); the same vector as an ``array("b")``
|
|
5
|
+
of int8 codes costs 384 bytes plus one header. On a 100 000-vector brain that
|
|
6
|
+
is the difference between ~300 MB and ~40 MB of resident scoring state, which
|
|
7
|
+
is what decides whether a large index can be held at all.
|
|
8
|
+
|
|
9
|
+
The trade is precision, not coverage: every vector is still compared (the
|
|
10
|
+
index is *exhaustive*), but each score is reconstructed from 8-bit codes, so
|
|
11
|
+
it lands within roughly ±1% of the exact cosine. Near-ties can therefore swap
|
|
12
|
+
places — which is why this backend reports ``approx = True`` and why the
|
|
13
|
+
default stays :class:`~.brute_force.BruteForceIndex`.
|
|
14
|
+
|
|
15
|
+
Symmetric per-vector scaling: each vector is divided by its own peak absolute
|
|
16
|
+
value before rounding, so a short vector keeps its full 8-bit resolution
|
|
17
|
+
instead of being crushed by the largest vector in the corpus.
|
|
18
|
+
|
|
19
|
+
Measured caveat (docs/PERFORMANCE.md, 10k vectors): the RAM saving above is a
|
|
20
|
+
property of the *data structure*, and the current search path does not cash it
|
|
21
|
+
in. ``retrieval_vector`` already feeds the index in bounded batches, so
|
|
22
|
+
resident vectors were never the dominant term — the fetched SQLite rows are —
|
|
23
|
+
and peak memory moves by ~1 MB while latency roughly doubles. This backend is
|
|
24
|
+
therefore honest, exhaustive, and currently not the one to choose; it exists
|
|
25
|
+
as the representation a *held* (cross-query) index would need.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from array import array
|
|
31
|
+
from operator import mul
|
|
32
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
|
33
|
+
|
|
34
|
+
from .base import IndexItem, IndexStats, ScoredId, score_floor, take_top
|
|
35
|
+
|
|
36
|
+
QUANTIZED_BACKEND = "quantized-int8"
|
|
37
|
+
#: int8 keeps ``-128``; the symmetric range stops at ``-127`` so ``+peak`` and
|
|
38
|
+
#: ``-peak`` quantize to codes of equal magnitude.
|
|
39
|
+
INT8_MAX = 127
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def quantize(vector: Sequence[float]) -> Tuple[array, float]:
|
|
43
|
+
"""``vector`` → (int8 codes, scale) with ``value ≈ code * scale``.
|
|
44
|
+
|
|
45
|
+
An all-zero vector has no peak to scale against; it quantizes to all-zero
|
|
46
|
+
codes with scale ``0.0``, which scores 0 against everything — the same
|
|
47
|
+
answer the exact scan gives for a zero vector.
|
|
48
|
+
"""
|
|
49
|
+
values = [float(value) for value in vector]
|
|
50
|
+
peak = max((abs(value) for value in values), default=0.0)
|
|
51
|
+
if peak <= 0.0:
|
|
52
|
+
return array("b", bytes(len(values))), 0.0
|
|
53
|
+
scale = peak / INT8_MAX
|
|
54
|
+
codes = array(
|
|
55
|
+
"b",
|
|
56
|
+
(
|
|
57
|
+
max(-INT8_MAX, min(INT8_MAX, int(round(value / scale))))
|
|
58
|
+
for value in values
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
return codes, scale
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class QuantizedIndex:
|
|
65
|
+
"""Exhaustive int8 index: full coverage, approximate scores."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, *, dim: int = 0) -> None:
|
|
68
|
+
self._dim = int(dim)
|
|
69
|
+
self._codes: Dict[str, array] = {}
|
|
70
|
+
self._scales: Dict[str, float] = {}
|
|
71
|
+
self._metadata: Dict[str, Dict[str, Any]] = {}
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def backend(self) -> str:
|
|
75
|
+
return QUANTIZED_BACKEND
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def approx(self) -> bool:
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def exhaustive(self) -> bool:
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
def add(
|
|
86
|
+
self,
|
|
87
|
+
id: str,
|
|
88
|
+
vector: Sequence[float],
|
|
89
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
codes, scale = quantize(vector)
|
|
92
|
+
if not self._dim:
|
|
93
|
+
self._dim = len(codes)
|
|
94
|
+
key = str(id)
|
|
95
|
+
self._codes[key] = codes
|
|
96
|
+
self._scales[key] = scale
|
|
97
|
+
self._metadata[key] = dict(metadata or {})
|
|
98
|
+
|
|
99
|
+
def remove(self, id: str) -> None:
|
|
100
|
+
key = str(id)
|
|
101
|
+
self._codes.pop(key, None)
|
|
102
|
+
self._scales.pop(key, None)
|
|
103
|
+
self._metadata.pop(key, None)
|
|
104
|
+
|
|
105
|
+
def rebuild(self, items: Iterable[IndexItem]) -> None:
|
|
106
|
+
self._codes.clear()
|
|
107
|
+
self._scales.clear()
|
|
108
|
+
self._metadata.clear()
|
|
109
|
+
for item_id, vector, metadata in items:
|
|
110
|
+
self.add(item_id, vector, metadata)
|
|
111
|
+
|
|
112
|
+
def metadata_for(self, id: str) -> Dict[str, Any]:
|
|
113
|
+
return dict(self._metadata.get(str(id), {}))
|
|
114
|
+
|
|
115
|
+
def search(
|
|
116
|
+
self,
|
|
117
|
+
query: Sequence[float],
|
|
118
|
+
top_k: int,
|
|
119
|
+
filter: Optional[Mapping[str, Any]] = None,
|
|
120
|
+
) -> List[ScoredId]:
|
|
121
|
+
floor = score_floor(filter)
|
|
122
|
+
query_codes, query_scale = quantize(query)
|
|
123
|
+
scored: List[ScoredId] = []
|
|
124
|
+
for item_id, codes in self._codes.items():
|
|
125
|
+
if len(codes) != len(query_codes):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"embedding dimension mismatch: {len(query_codes)} vs "
|
|
128
|
+
f"{len(codes)}; the vector index was built with a "
|
|
129
|
+
"different model"
|
|
130
|
+
)
|
|
131
|
+
# Integer dot product, then one float multiply for the two scales:
|
|
132
|
+
# all the arithmetic that decides the ranking stays in ints.
|
|
133
|
+
raw = sum(map(mul, query_codes, codes))
|
|
134
|
+
score = float(raw) * query_scale * self._scales[item_id]
|
|
135
|
+
if score < floor:
|
|
136
|
+
continue
|
|
137
|
+
scored.append((item_id, score))
|
|
138
|
+
return take_top(scored, top_k)
|
|
139
|
+
|
|
140
|
+
def stats(self) -> IndexStats:
|
|
141
|
+
return IndexStats(
|
|
142
|
+
backend=self.backend,
|
|
143
|
+
size=len(self._codes),
|
|
144
|
+
dim=self._dim,
|
|
145
|
+
approx=True,
|
|
146
|
+
exhaustive=True,
|
|
147
|
+
detail="scores are reconstructed from 8-bit codes (~1% error)",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = ["INT8_MAX", "QUANTIZED_BACKEND", "QuantizedIndex", "quantize"]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Which backend scores this search, and — when it is not the one you asked
|
|
2
|
+
for — why.
|
|
3
|
+
|
|
4
|
+
``LATTICEAI_VECTOR_INDEX`` selects ``brute`` (default) / ``quantized`` /
|
|
5
|
+
``hnsw``. Two failure modes have to stay loud, because both otherwise look
|
|
6
|
+
exactly like "search is a bit worse today":
|
|
7
|
+
|
|
8
|
+
* an unknown name (typo, stale config) must not silently mean "the default";
|
|
9
|
+
* asking for ``hnsw`` without the optional extra installed must not silently
|
|
10
|
+
mean "you have ANN".
|
|
11
|
+
|
|
12
|
+
Both resolve to the exact brute-force scan and carry a ``detail`` string
|
|
13
|
+
naming the cause, which ``index_status()`` and the search result surface.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, Dict, Optional
|
|
21
|
+
|
|
22
|
+
from .base import Similarity, VectorIndex
|
|
23
|
+
from .brute_force import BRUTE_FORCE_BACKEND, BruteForceIndex
|
|
24
|
+
from .hnsw import HNSW_BACKEND, HnswIndex, load_hnswlib
|
|
25
|
+
from .quantized import QUANTIZED_BACKEND, QuantizedIndex
|
|
26
|
+
|
|
27
|
+
VECTOR_INDEX_ENV = "LATTICEAI_VECTOR_INDEX"
|
|
28
|
+
DEFAULT_VECTOR_INDEX = "brute"
|
|
29
|
+
#: Accepted values of ``LATTICEAI_VECTOR_INDEX``.
|
|
30
|
+
VECTOR_INDEX_CHOICES = ("brute", "quantized", "hnsw")
|
|
31
|
+
|
|
32
|
+
_BACKEND_LABELS = {
|
|
33
|
+
"brute": BRUTE_FORCE_BACKEND,
|
|
34
|
+
"quantized": QUANTIZED_BACKEND,
|
|
35
|
+
"hnsw": HNSW_BACKEND,
|
|
36
|
+
}
|
|
37
|
+
_APPROX = {"brute": False, "quantized": True, "hnsw": True}
|
|
38
|
+
_EXHAUSTIVE = {"brute": True, "quantized": True, "hnsw": False}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class BackendSelection:
|
|
43
|
+
"""The resolved backend plus an honest account of any substitution."""
|
|
44
|
+
|
|
45
|
+
requested: str
|
|
46
|
+
name: str
|
|
47
|
+
backend: str
|
|
48
|
+
approx: bool
|
|
49
|
+
exhaustive: bool
|
|
50
|
+
detail: Optional[str] = None
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def honored(self) -> bool:
|
|
54
|
+
"""True when the caller got the backend they asked for."""
|
|
55
|
+
return self.requested == self.name
|
|
56
|
+
|
|
57
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"requested": self.requested,
|
|
60
|
+
"backend": self.backend,
|
|
61
|
+
"name": self.name,
|
|
62
|
+
"approx": self.approx,
|
|
63
|
+
"exhaustive": self.exhaustive,
|
|
64
|
+
"honored": self.honored,
|
|
65
|
+
"detail": self.detail,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _selection(name: str, *, requested: str, detail: Optional[str]) -> BackendSelection:
|
|
70
|
+
return BackendSelection(
|
|
71
|
+
requested=requested,
|
|
72
|
+
name=name,
|
|
73
|
+
backend=_BACKEND_LABELS[name],
|
|
74
|
+
approx=_APPROX[name],
|
|
75
|
+
exhaustive=_EXHAUSTIVE[name],
|
|
76
|
+
detail=detail,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_vector_index(requested: Optional[str] = None) -> BackendSelection:
|
|
81
|
+
"""Resolve the configured backend (never raises, always falls back safe)."""
|
|
82
|
+
raw = requested if requested is not None else os.getenv(VECTOR_INDEX_ENV, "")
|
|
83
|
+
name = str(raw or "").strip().lower() or DEFAULT_VECTOR_INDEX
|
|
84
|
+
if name not in VECTOR_INDEX_CHOICES:
|
|
85
|
+
return _selection(
|
|
86
|
+
DEFAULT_VECTOR_INDEX,
|
|
87
|
+
requested=name,
|
|
88
|
+
detail=(
|
|
89
|
+
f"unknown vector index backend {name!r}; expected one of "
|
|
90
|
+
f"{', '.join(VECTOR_INDEX_CHOICES)} — using the exact "
|
|
91
|
+
"brute-force scan"
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
if name == "hnsw":
|
|
95
|
+
module, reason = load_hnswlib()
|
|
96
|
+
if module is None:
|
|
97
|
+
return _selection(
|
|
98
|
+
DEFAULT_VECTOR_INDEX,
|
|
99
|
+
requested=name,
|
|
100
|
+
detail=f"{reason} — using the exact brute-force scan",
|
|
101
|
+
)
|
|
102
|
+
return _selection(name, requested=name, detail=None)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_index(
|
|
106
|
+
selection: BackendSelection,
|
|
107
|
+
*,
|
|
108
|
+
dim: int = 0,
|
|
109
|
+
similarity: Optional[Similarity] = None,
|
|
110
|
+
) -> VectorIndex:
|
|
111
|
+
"""Instantiate the backend named by ``selection``.
|
|
112
|
+
|
|
113
|
+
``similarity`` is threaded into the exact backend only: quantized and
|
|
114
|
+
HNSW score in their own representation, and pretending otherwise would
|
|
115
|
+
make an injected function look respected when it is not.
|
|
116
|
+
"""
|
|
117
|
+
if selection.name == "quantized":
|
|
118
|
+
return QuantizedIndex(dim=dim)
|
|
119
|
+
if selection.name == HNSW_BACKEND:
|
|
120
|
+
return HnswIndex(dim=dim)
|
|
121
|
+
return BruteForceIndex(dim=dim, similarity=similarity)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = [
|
|
125
|
+
"DEFAULT_VECTOR_INDEX",
|
|
126
|
+
"VECTOR_INDEX_CHOICES",
|
|
127
|
+
"VECTOR_INDEX_ENV",
|
|
128
|
+
"BackendSelection",
|
|
129
|
+
"build_index",
|
|
130
|
+
"resolve_vector_index",
|
|
131
|
+
]
|
|
@@ -46,6 +46,7 @@ from dataclasses import dataclass, field
|
|
|
46
46
|
from pathlib import Path
|
|
47
47
|
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
48
48
|
|
|
49
|
+
from .graph.vector_index import DEFAULT_TICK_LIMIT as VECTOR_TICK_LIMIT
|
|
49
50
|
from .runtime.hooks import dispatch_tool
|
|
50
51
|
from .utils import utc_now_iso
|
|
51
52
|
|
|
@@ -715,12 +716,32 @@ class IngestionPipeline:
|
|
|
715
716
|
"detail": "; ".join(p for p in parts if p),
|
|
716
717
|
}
|
|
717
718
|
|
|
719
|
+
def _queue_pending_embed(self, node_id: str, detail: str) -> bool:
|
|
720
|
+
"""Hand a node the inline sync could not embed to the background queue.
|
|
721
|
+
|
|
722
|
+
Before v11.1.0 ``indexing_status="pending"`` was the end of the story:
|
|
723
|
+
honest, but nobody was coming back for it, so the node stayed
|
|
724
|
+
unsearchable until a human ran a rebuild. The durable queue is who
|
|
725
|
+
comes back. A store without one (older stores, mocks) just keeps the
|
|
726
|
+
old behaviour — the node is still visible as ``index_status`` backlog.
|
|
727
|
+
"""
|
|
728
|
+
queue = getattr(self._kg, "vector_queue", None)
|
|
729
|
+
if queue is None:
|
|
730
|
+
return False
|
|
731
|
+
try:
|
|
732
|
+
return bool(queue.schedule(node_id, detail=detail))
|
|
733
|
+
except Exception: # noqa: BLE001 — queueing must never fail an ingest
|
|
734
|
+
quiet()
|
|
735
|
+
return False
|
|
736
|
+
|
|
718
737
|
def _sync_vector_index(self, node_id: str) -> Tuple[str, Optional[str]]:
|
|
719
738
|
"""Best-effort incremental vector sync → (indexing_status, detail).
|
|
720
739
|
|
|
721
740
|
Any failure — missing method on older stores, embedding provider down,
|
|
722
741
|
storage error — yields ``("pending", detail)`` so a later
|
|
723
|
-
``rebuild_vector_index`` run picks the node up from the backlog
|
|
742
|
+
``rebuild_vector_index`` run picks the node up from the backlog, and
|
|
743
|
+
the node is queued for background embedding so that pickup happens on
|
|
744
|
+
its own.
|
|
724
745
|
"""
|
|
725
746
|
sync = getattr(self._kg, "index_node_incremental", None)
|
|
726
747
|
if not callable(sync):
|
|
@@ -730,12 +751,38 @@ class IngestionPipeline:
|
|
|
730
751
|
try:
|
|
731
752
|
outcome = sync(node_id) or {}
|
|
732
753
|
except Exception as exc: # noqa: BLE001 — vector sync must never fail the ingest
|
|
733
|
-
return "pending", f"vector index sync failed: {exc}"
|
|
754
|
+
return "pending", self._pending_detail(node_id, f"vector index sync failed: {exc}")
|
|
734
755
|
if str(outcome.get("status") or "") == "failed":
|
|
735
756
|
reason = outcome.get("detail") or "unknown error"
|
|
736
|
-
return "pending",
|
|
757
|
+
return "pending", self._pending_detail(
|
|
758
|
+
node_id, f"vector index sync failed: {reason}"
|
|
759
|
+
)
|
|
737
760
|
return "indexed", None
|
|
738
761
|
|
|
762
|
+
def _pending_detail(self, node_id: str, reason: str) -> str:
|
|
763
|
+
"""``reason``, plus whether a background retry was actually scheduled."""
|
|
764
|
+
if self._queue_pending_embed(node_id, reason):
|
|
765
|
+
return f"{reason}; queued for background embedding"
|
|
766
|
+
return reason
|
|
767
|
+
|
|
768
|
+
def drain_vector_queue(self, limit: int = VECTOR_TICK_LIMIT) -> Dict[str, Any]:
|
|
769
|
+
"""Run one background-embedding tick over the store's pending backlog.
|
|
770
|
+
|
|
771
|
+
Deliberately caller-driven (a scheduler, a CLI, a test) rather than a
|
|
772
|
+
thread this pipeline owns: the queue is durable, so "who runs it" is a
|
|
773
|
+
deployment decision, not a property of having ingested something.
|
|
774
|
+
"""
|
|
775
|
+
queue = getattr(self._kg, "vector_queue", None)
|
|
776
|
+
if queue is None:
|
|
777
|
+
return {
|
|
778
|
+
"claimed": 0,
|
|
779
|
+
"indexed": 0,
|
|
780
|
+
"retried": 0,
|
|
781
|
+
"failed": 0,
|
|
782
|
+
"detail": "this store has no background vector queue",
|
|
783
|
+
}
|
|
784
|
+
return dict(queue.tick(limit))
|
|
785
|
+
|
|
739
786
|
# --- Large candidate #1: background / incremental scheduling (slice) ---
|
|
740
787
|
def schedule_background(
|
|
741
788
|
self,
|