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.
Files changed (126) hide show
  1. package/README.md +46 -64
  2. package/docs/CHANGELOG.md +72 -237
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/PERFORMANCE.md +78 -7
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/docs/v11.1.0_PRODUCT_INTELLIGENCE_PLAN.md +313 -0
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/graph/_kg_contract.py +11 -0
  14. package/lattice_brain/graph/curator.py +1 -1
  15. package/lattice_brain/graph/fusion.py +184 -3
  16. package/lattice_brain/graph/proactive.py +138 -1
  17. package/lattice_brain/graph/projection.py +10 -2
  18. package/lattice_brain/graph/retrieval.py +137 -7
  19. package/lattice_brain/graph/retrieval_docgen.py +20 -20
  20. package/lattice_brain/graph/retrieval_policy.py +6 -0
  21. package/lattice_brain/graph/retrieval_reads.py +188 -1
  22. package/lattice_brain/graph/retrieval_vector.py +474 -110
  23. package/lattice_brain/graph/schema.py +125 -2
  24. package/lattice_brain/graph/vector_index/__init__.py +85 -0
  25. package/lattice_brain/graph/vector_index/base.py +170 -0
  26. package/lattice_brain/graph/vector_index/brute_force.py +114 -0
  27. package/lattice_brain/graph/vector_index/hnsw.py +293 -0
  28. package/lattice_brain/graph/vector_index/jobs.py +287 -0
  29. package/lattice_brain/graph/vector_index/quantized.py +151 -0
  30. package/lattice_brain/graph/vector_index/selector.py +131 -0
  31. package/lattice_brain/ingestion.py +50 -3
  32. package/lattice_brain/portability.py +654 -2
  33. package/lattice_brain/runtime/agent_runtime.py +1 -1
  34. package/lattice_brain/runtime/contracts.py +1 -1
  35. package/lattice_brain/runtime/multi_agent.py +1 -1
  36. package/lattice_brain/self_model.py +620 -0
  37. package/lattice_brain/synthesis.py +801 -0
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/brain_intelligence.py +83 -1
  40. package/latticeai/api/chat_stream.py +4 -1
  41. package/latticeai/api/local_files.py +62 -0
  42. package/latticeai/api/models.py +1 -1
  43. package/latticeai/api/portability.py +130 -1
  44. package/latticeai/api/security_dashboard.py +48 -13
  45. package/latticeai/api/voice_capture.py +4 -1
  46. package/latticeai/api/workspace.py +11 -5
  47. package/latticeai/core/embedding_providers.py +20 -1
  48. package/latticeai/core/legacy_compatibility.py +1 -1
  49. package/latticeai/core/marketplace.py +1 -1
  50. package/latticeai/core/messages.py +14 -0
  51. package/latticeai/core/model_compat.py +2 -2
  52. package/latticeai/core/tool_registry.py +0 -7
  53. package/latticeai/core/workspace_os_constants.py +1 -1
  54. package/latticeai/core/workspace_os_utils.py +4 -50
  55. package/latticeai/core/workspace_review_items.py +12 -1
  56. package/latticeai/integrations/telegram_bot.py +28 -10
  57. package/latticeai/models/router.py +1 -1
  58. package/latticeai/runtime/access_runtime.py +1 -1
  59. package/latticeai/runtime/network_boundary_wiring.py +9 -5
  60. package/latticeai/runtime/permission_mode_wiring.py +9 -6
  61. package/latticeai/runtime/router_registration.py +3 -0
  62. package/latticeai/services/architecture_readiness.py +1 -1
  63. package/latticeai/services/brain_intelligence.py +253 -0
  64. package/latticeai/services/memory_service.py +1 -1
  65. package/latticeai/services/model_catalog.py +4 -3
  66. package/latticeai/services/model_engines.py +28 -14
  67. package/latticeai/services/obsidian_bridge.py +618 -0
  68. package/latticeai/services/product_readiness.py +5 -3
  69. package/latticeai/tools/filesystem.py +4 -1
  70. package/package.json +1 -1
  71. package/scripts/bench_vector_index.py +295 -0
  72. package/scripts/check_current_release_docs.mjs +4 -2
  73. package/scripts/release_screen_claims.json +31 -0
  74. package/src-tauri/Cargo.lock +1 -1
  75. package/src-tauri/Cargo.toml +1 -1
  76. package/src-tauri/tauri.conf.json +1 -1
  77. package/static/app/asset-manifest.json +37 -37
  78. package/static/app/assets/{Act-CS9IeqUX.js → Act-D4zSxFR-.js} +1 -1
  79. package/static/app/assets/{AdminConsole-3UkIEWGA.js → AdminConsole-w5jBfPt2.js} +1 -1
  80. package/static/app/assets/{Brain-B22EmNqS.js → Brain-C2EqQg74.js} +2 -2
  81. package/static/app/assets/BrainHome-CvXS6XiQ.js +2 -0
  82. package/static/app/assets/BrainSignals-DOE_KhOU.js +1 -0
  83. package/static/app/assets/Capture-DPqpGK8d.js +1 -0
  84. package/static/app/assets/{CommandPalette-86m4FCcN.js → CommandPalette-CNf7h5fp.js} +1 -1
  85. package/static/app/assets/Library-BN0HYOfc.js +1 -0
  86. package/static/app/assets/LivingBrain-Dfq_wEDI.js +1 -0
  87. package/static/app/assets/ProductFlow-B-3O0rNV.js +1 -0
  88. package/static/app/assets/{ReviewCard-BepjSDpN.js → ReviewCard-gZ-tdqFM.js} +1 -1
  89. package/static/app/assets/System-BElUcSSw.js +1 -0
  90. package/static/app/assets/arrow-left-CFNIMjhv.js +1 -0
  91. package/static/app/assets/{bot-DQj0-LkM.js → bot--qYHMtkP.js} +1 -1
  92. package/static/app/assets/brain-DDCLjRqO.js +1 -0
  93. package/static/app/assets/{button-CmTknyAP.js → button-51Z3rsuv.js} +1 -1
  94. package/static/app/assets/{circle-pause-yTCWRziJ.js → circle-pause-CMIiMaQl.js} +1 -1
  95. package/static/app/assets/{circle-play-Ccrva84R.js → circle-play-DZoO_cfG.js} +1 -1
  96. package/static/app/assets/{cpu-CbJqWTlS.js → cpu-Bs6uc9W9.js} +1 -1
  97. package/static/app/assets/{download-CkSzbzU-.js → download-G-2olkWz.js} +1 -1
  98. package/static/app/assets/{folder-open-CKyjQ4PU.js → folder-open-CTOspnmb.js} +1 -1
  99. package/static/app/assets/{hard-drive-DAzk9um0.js → hard-drive-CewHWJhn.js} +1 -1
  100. package/static/app/assets/index-CkzokZAj.css +2 -0
  101. package/static/app/assets/{index-CxOcwsHV.js → index-D7Rr-J2Y.js} +3 -3
  102. package/static/app/assets/{input-DcMETmZ7.js → input-D4w_BZWl.js} +1 -1
  103. package/static/app/assets/{permissionCopy-BVf13_25.js → permissionCopy-CosBEXAZ.js} +1 -1
  104. package/static/app/assets/primitives-d0g9pvzS.js +1 -0
  105. package/static/app/assets/search-BLCYt75v.js +1 -0
  106. package/static/app/assets/{share-2-COWCHNZm.js → share-2-NmD7e_oV.js} +1 -1
  107. package/static/app/assets/{shield-alert-BDrvilyK.js → shield-alert-CcQeMuju.js} +1 -1
  108. package/static/app/assets/{textarea-rUmsc8cP.js → textarea-BPAJDc-0.js} +1 -1
  109. package/static/app/assets/{useFocusTrap-Bi5UY_8v.js → useFocusTrap-C7YLdTBC.js} +1 -1
  110. package/static/app/assets/{useQuery-C-AicB-3.js → useQuery-DRyD9opW.js} +1 -1
  111. package/static/app/assets/{utils-BMwWg78e.js → utils-DG1_ExrP.js} +3 -3
  112. package/static/app/assets/{workspace-Y93tls8P.js → workspace-CWVf3gsI.js} +1 -1
  113. package/static/app/index.html +4 -4
  114. package/static/sw.js +1 -1
  115. package/static/app/assets/BrainHome-CMDqgJF4.js +0 -2
  116. package/static/app/assets/BrainSignals-BeE8RJo3.js +0 -1
  117. package/static/app/assets/Capture-ZX9bQh68.js +0 -1
  118. package/static/app/assets/Library-Bhz5LUca.js +0 -1
  119. package/static/app/assets/LivingBrain-BSa0wpFG.js +0 -1
  120. package/static/app/assets/ProductFlow-IP4Q-5aQ.js +0 -1
  121. package/static/app/assets/System-Bwx1h_jT.js +0 -1
  122. package/static/app/assets/arrow-left-ig7AZU8B.js +0 -1
  123. package/static/app/assets/brain-D8OEwmVj.js +0 -1
  124. package/static/app/assets/index-BfD-jhA9.css +0 -2
  125. package/static/app/assets/primitives-CQV9Q2YM.js +0 -1
  126. package/static/app/assets/search-CXGASMMH.js +0 -1
@@ -0,0 +1,293 @@
1
+ """Approximate nearest-neighbour index backed by optional ``hnswlib``.
2
+
3
+ ``hnswlib`` is a compiled extension. It is **not** a core dependency: the
4
+ install stays pure-Python-plus-wheels for everyone who never asked for ANN,
5
+ and this backend is reachable only through the ``hnsw`` optional extra
6
+ (``pip install "ltcai[hnsw]"``) plus an explicit
7
+ ``LATTICEAI_VECTOR_INDEX=hnsw``. When the module is missing the import is
8
+ caught here and reported as a reason string; the selector then falls back to
9
+ the exact scan and says so, rather than failing a search.
10
+
11
+ The graph search index is a **derivative**. SQLite remains the source of
12
+ truth, so the ``.hnsw`` sidecar next to the brain database is disposable: if
13
+ it is deleted, corrupt, or built for a different embedder/row-set, the
14
+ fingerprint check rejects it and the caller rebuilds from
15
+ ``vector_embeddings``.
16
+
17
+ Approximation is reported, never hidden: this backend sets ``approx = True``
18
+ and ``exhaustive = False``, and ``scripts/bench_vector_index.py`` measures
19
+ recall@k against the brute-force backend so the cost of the speed is a
20
+ number, not a promise.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from pathlib import Path
27
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
28
+
29
+ from .base import IndexItem, IndexStats, ScoredId, score_floor
30
+
31
+ HNSW_BACKEND = "hnsw"
32
+ HNSWLIB_MODULE = "hnswlib"
33
+ #: Sidecar suffix for the persisted graph (a derivative of the SQLite rows).
34
+ HNSW_SUFFIX = ".hnsw"
35
+ #: Companion JSON: fingerprint + label→id mapping. Without it the binary
36
+ #: graph is unreadable, because hnswlib only stores integer labels.
37
+ HNSW_META_SUFFIX = ".hnsw.meta.json"
38
+
39
+
40
+ def load_hnswlib() -> Tuple[Optional[Any], Optional[str]]:
41
+ """Import ``hnswlib`` or explain why it is unavailable (never raises)."""
42
+ try:
43
+ import hnswlib # noqa: PLC0415 — guarded, optional, import-on-demand
44
+ except Exception as exc: # noqa: BLE001 — any import failure is "no ANN"
45
+ return None, (
46
+ f"{HNSWLIB_MODULE} is not available ({exc}); install the optional "
47
+ 'extra with `pip install "ltcai[hnsw]"`'
48
+ )
49
+ return hnswlib, None
50
+
51
+
52
+ def hnswlib_available() -> bool:
53
+ """True when the optional ANN engine can actually be imported."""
54
+ module, _ = load_hnswlib()
55
+ return module is not None
56
+
57
+
58
+ def sidecar_paths(db_path: Any) -> Tuple[Path, Path]:
59
+ """``(index file, meta file)`` for a brain database path."""
60
+ base = Path(db_path)
61
+ return (
62
+ base.with_suffix(HNSW_SUFFIX),
63
+ base.with_suffix(HNSW_META_SUFFIX),
64
+ )
65
+
66
+
67
+ class HnswIndex:
68
+ """Small-M HNSW graph over the brain's embeddings."""
69
+
70
+ def __init__(
71
+ self,
72
+ *,
73
+ dim: int,
74
+ space: str = "cosine",
75
+ ef_construction: int = 200,
76
+ m: int = 16,
77
+ ef_search: int = 64,
78
+ ) -> None:
79
+ self._dim = int(dim)
80
+ self._space = str(space)
81
+ self._ef_construction = int(ef_construction)
82
+ self._m = int(m)
83
+ self._ef_search = int(ef_search)
84
+ self._module, self._detail = load_hnswlib()
85
+ self._vectors: Dict[str, List[float]] = {}
86
+ self._metadata: Dict[str, Dict[str, Any]] = {}
87
+ self._labels: List[str] = []
88
+ self._ann: Any = None
89
+ self._dirty = True
90
+ self._loaded = False
91
+
92
+ # ── identity ─────────────────────────────────────────────────────────────
93
+ @property
94
+ def backend(self) -> str:
95
+ return HNSW_BACKEND
96
+
97
+ @property
98
+ def approx(self) -> bool:
99
+ return True
100
+
101
+ @property
102
+ def exhaustive(self) -> bool:
103
+ return False
104
+
105
+ @property
106
+ def available(self) -> bool:
107
+ return self._module is not None
108
+
109
+ @property
110
+ def unavailable_detail(self) -> Optional[str]:
111
+ return self._detail
112
+
113
+ @property
114
+ def loaded_from_sidecar(self) -> bool:
115
+ """True when the graph came off disk instead of being rebuilt."""
116
+ return self._loaded
117
+
118
+ # ── mutation ─────────────────────────────────────────────────────────────
119
+ def _reject_mutation_after_load(self) -> None:
120
+ if self._loaded:
121
+ raise RuntimeError(
122
+ "this HnswIndex was loaded from a sidecar and holds no source "
123
+ "vectors; rebuild it from vector_embeddings before mutating"
124
+ )
125
+
126
+ def add(
127
+ self,
128
+ id: str,
129
+ vector: Sequence[float],
130
+ metadata: Optional[Mapping[str, Any]] = None,
131
+ ) -> None:
132
+ self._reject_mutation_after_load()
133
+ key = str(id)
134
+ self._vectors[key] = [float(value) for value in vector]
135
+ self._metadata[key] = dict(metadata or {})
136
+ self._dirty = True
137
+
138
+ def remove(self, id: str) -> None:
139
+ self._reject_mutation_after_load()
140
+ key = str(id)
141
+ self._vectors.pop(key, None)
142
+ self._metadata.pop(key, None)
143
+ self._dirty = True
144
+
145
+ def rebuild(self, items: Iterable[IndexItem]) -> None:
146
+ self._vectors.clear()
147
+ self._metadata.clear()
148
+ self._loaded = False
149
+ for item_id, vector, metadata in items:
150
+ self.add(item_id, vector, metadata)
151
+ self._dirty = True
152
+
153
+ def metadata_for(self, id: str) -> Dict[str, Any]:
154
+ return dict(self._metadata.get(str(id), {}))
155
+
156
+ # ── graph construction ───────────────────────────────────────────────────
157
+ def _new_graph(self, module: Any, capacity: int) -> Any:
158
+ graph = module.Index(space=self._space, dim=self._dim)
159
+ graph.init_index(
160
+ max_elements=max(1, capacity),
161
+ ef_construction=self._ef_construction,
162
+ M=self._m,
163
+ )
164
+ return graph
165
+
166
+ def _ensure_graph(self) -> Any:
167
+ """Build the graph if the held vectors changed (None when disabled)."""
168
+ module = self._module
169
+ if module is None:
170
+ return None
171
+ if self._ann is not None and not self._dirty:
172
+ return self._ann
173
+ labels = list(self._vectors)
174
+ graph = self._new_graph(module, len(labels))
175
+ if labels:
176
+ graph.add_items(
177
+ [self._vectors[key] for key in labels],
178
+ list(range(len(labels))),
179
+ )
180
+ self._ann = graph
181
+ self._labels = labels
182
+ self._dirty = False
183
+ return graph
184
+
185
+ # ── search ───────────────────────────────────────────────────────────────
186
+ def search(
187
+ self,
188
+ query: Sequence[float],
189
+ top_k: int,
190
+ filter: Optional[Mapping[str, Any]] = None,
191
+ ) -> List[ScoredId]:
192
+ graph = self._ensure_graph()
193
+ if graph is None or not self._labels:
194
+ return []
195
+ floor = score_floor(filter)
196
+ wanted = max(1, min(int(top_k), len(self._labels)))
197
+ graph.set_ef(max(self._ef_search, wanted))
198
+ labels, distances = graph.knn_query([list(query)], k=wanted)
199
+ scored: List[ScoredId] = []
200
+ for label, distance in zip(labels[0], distances[0], strict=True):
201
+ # cosine/ip space: hnswlib returns 1 - similarity as the distance.
202
+ score = 1.0 - float(distance)
203
+ if score < floor:
204
+ continue
205
+ scored.append((self._labels[int(label)], score))
206
+ return scored
207
+
208
+ def stats(self) -> IndexStats:
209
+ return IndexStats(
210
+ backend=self.backend,
211
+ size=len(self._labels) if self._loaded else len(self._vectors),
212
+ dim=self._dim,
213
+ approx=True,
214
+ exhaustive=False,
215
+ available=self.available,
216
+ detail=self._detail,
217
+ )
218
+
219
+ # ── sidecar persistence (a derivative — safe to delete) ──────────────────
220
+ def save(self, db_path: Any, *, fingerprint: str) -> bool:
221
+ """Write the graph + label map beside the brain database.
222
+
223
+ Returns False (never raises) when ANN is unavailable, the index is
224
+ empty, or the filesystem refuses the write: a missing sidecar only
225
+ costs a rebuild on the next search.
226
+ """
227
+ graph = self._ensure_graph()
228
+ if graph is None or not self._labels:
229
+ return False
230
+ index_path, meta_path = sidecar_paths(db_path)
231
+ try:
232
+ index_path.parent.mkdir(parents=True, exist_ok=True)
233
+ graph.save_index(str(index_path))
234
+ meta_path.write_text(
235
+ json.dumps(
236
+ {
237
+ "fingerprint": str(fingerprint),
238
+ "dim": self._dim,
239
+ "space": self._space,
240
+ "labels": self._labels,
241
+ },
242
+ ensure_ascii=False,
243
+ ),
244
+ encoding="utf-8",
245
+ )
246
+ except Exception: # noqa: BLE001 — persistence is best-effort
247
+ return False
248
+ return True
249
+
250
+ def load(self, db_path: Any, *, fingerprint: str) -> bool:
251
+ """Adopt a sidecar graph when it provably matches ``fingerprint``.
252
+
253
+ Any mismatch — missing file, unreadable JSON, different embedder or
254
+ row-set, wrong dimension — returns False so the caller rebuilds. The
255
+ sidecar is never trusted over SQLite.
256
+ """
257
+ module = self._module
258
+ if module is None:
259
+ return False
260
+ index_path, meta_path = sidecar_paths(db_path)
261
+ try:
262
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
263
+ matches = str(meta["fingerprint"]) == str(fingerprint)
264
+ matches = matches and int(meta["dim"]) == self._dim
265
+ labels = [str(label) for label in meta["labels"]]
266
+ except Exception: # noqa: BLE001 — absent/corrupt sidecar = rebuild
267
+ return False
268
+ if not (matches and labels):
269
+ return False
270
+ try:
271
+ graph = module.Index(space=self._space, dim=self._dim)
272
+ graph.load_index(str(index_path), max_elements=len(labels))
273
+ except Exception: # noqa: BLE001 — corrupt binary = rebuild
274
+ return False
275
+ self._ann = graph
276
+ self._labels = labels
277
+ self._vectors.clear()
278
+ self._metadata.clear()
279
+ self._dirty = False
280
+ self._loaded = True
281
+ return True
282
+
283
+
284
+ __all__ = [
285
+ "HNSW_BACKEND",
286
+ "HNSW_META_SUFFIX",
287
+ "HNSW_SUFFIX",
288
+ "HNSWLIB_MODULE",
289
+ "HnswIndex",
290
+ "hnswlib_available",
291
+ "load_hnswlib",
292
+ "sidecar_paths",
293
+ ]
@@ -0,0 +1,287 @@
1
+ """Durable pending-embed queue: ingest now, embed a moment later.
2
+
3
+ The write path already degrades honestly — when the incremental vector sync
4
+ fails, ``IngestionResult.indexing_status`` becomes ``"pending"`` and
5
+ ``index_status()`` keeps the node visible as backlog. What was missing is
6
+ anyone whose job it is to *come back for it*. Until a human ran a rebuild,
7
+ "pending" meant "unsearchable, indefinitely".
8
+
9
+ This is that worker's memory, and it is on disk for the same reason
10
+ ``ingestion_jobs`` is: a queue that lives in one process's heap forgets its
11
+ backlog on restart, which is exactly when a backlog exists. The table is
12
+ created lazily inside whichever SQLite file the brain already uses, so the
13
+ existing backup/restore covers it with no manifest change.
14
+
15
+ The queue is a pure service — schedule + tick, no threads, no server, no
16
+ event loop. :meth:`VectorEmbedQueue.tick_async` exists for callers that live
17
+ on the event loop and hands the blocking work to a thread.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import logging
24
+ import sqlite3
25
+ from contextlib import contextmanager
26
+ from pathlib import Path
27
+ from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
28
+
29
+ from ...utils import utc_now_iso
30
+
31
+ #: Job lifecycle. ``failed`` is terminal — the retry budget is exhausted.
32
+ VECTOR_JOB_STATUSES = ("pending", "running", "done", "failed")
33
+ #: How many times one node may fail before the queue stops retrying it.
34
+ DEFAULT_MAX_ATTEMPTS = 3
35
+ #: Nodes claimed per :meth:`VectorEmbedQueue.tick`.
36
+ DEFAULT_TICK_LIMIT = 25
37
+
38
+ LOGGER = logging.getLogger(__name__)
39
+
40
+
41
+ class VectorJobStore:
42
+ """SQLite persistence for the pending-embed backlog.
43
+
44
+ Own connection per operation, always closed — ``with sqlite3.connect(...)``
45
+ commits but never closes, which is how 70 leaks accumulated before 10.2.0.
46
+ """
47
+
48
+ def __init__(self, db_path: Any) -> None:
49
+ self.db_path = Path(db_path)
50
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
51
+ self._init_db()
52
+
53
+ @contextmanager
54
+ def _connect(self) -> Iterator[sqlite3.Connection]:
55
+ conn = sqlite3.connect(str(self.db_path))
56
+ conn.row_factory = sqlite3.Row
57
+ try:
58
+ with conn:
59
+ yield conn
60
+ finally:
61
+ conn.close()
62
+
63
+ def _init_db(self) -> None:
64
+ with self._connect() as conn:
65
+ conn.executescript(
66
+ """
67
+ CREATE TABLE IF NOT EXISTS vector_jobs (
68
+ node_id TEXT PRIMARY KEY,
69
+ status TEXT NOT NULL DEFAULT 'pending',
70
+ attempts INTEGER NOT NULL DEFAULT 0,
71
+ detail TEXT,
72
+ created_at TEXT NOT NULL,
73
+ updated_at TEXT NOT NULL
74
+ );
75
+ CREATE INDEX IF NOT EXISTS idx_vector_jobs_status
76
+ ON vector_jobs(status, created_at);
77
+ """
78
+ )
79
+
80
+ def enqueue(self, node_id: str, *, detail: Optional[str] = None) -> bool:
81
+ """Mark ``node_id`` pending. Re-queuing an existing row is expected."""
82
+ node_id = str(node_id or "").strip()
83
+ if not node_id:
84
+ return False
85
+ now = utc_now_iso()
86
+ with self._connect() as conn:
87
+ conn.execute(
88
+ """
89
+ INSERT INTO vector_jobs(node_id, status, attempts, detail,
90
+ created_at, updated_at)
91
+ VALUES (?, 'pending', 0, ?, ?, ?)
92
+ ON CONFLICT(node_id) DO UPDATE SET
93
+ status='pending',
94
+ detail=excluded.detail,
95
+ updated_at=excluded.updated_at
96
+ """,
97
+ (node_id, detail, now, now),
98
+ )
99
+ return True
100
+
101
+ def claim(self, limit: int) -> List[str]:
102
+ """Take up to ``limit`` pending node ids and mark them running."""
103
+ limit = max(1, int(limit))
104
+ now = utc_now_iso()
105
+ with self._connect() as conn:
106
+ rows = conn.execute(
107
+ """
108
+ SELECT node_id FROM vector_jobs
109
+ WHERE status='pending'
110
+ ORDER BY created_at ASC, node_id ASC
111
+ LIMIT ?
112
+ """,
113
+ (limit,),
114
+ ).fetchall()
115
+ claimed = [str(row["node_id"]) for row in rows]
116
+ for node_id in claimed:
117
+ conn.execute(
118
+ """
119
+ UPDATE vector_jobs
120
+ SET status='running', attempts=attempts+1, updated_at=?
121
+ WHERE node_id=?
122
+ """,
123
+ (now, node_id),
124
+ )
125
+ return claimed
126
+
127
+ def finish(self, node_id: str, *, status: str, detail: Optional[str]) -> None:
128
+ with self._connect() as conn:
129
+ conn.execute(
130
+ "UPDATE vector_jobs SET status=?, detail=?, updated_at=? "
131
+ "WHERE node_id=?",
132
+ (status, detail, utc_now_iso(), str(node_id)),
133
+ )
134
+
135
+ def attempts_for(self, node_id: str) -> int:
136
+ with self._connect() as conn:
137
+ row = conn.execute(
138
+ "SELECT attempts FROM vector_jobs WHERE node_id=?", (str(node_id),)
139
+ ).fetchone()
140
+ return int(row["attempts"]) if row is not None else 0
141
+
142
+ def counts(self) -> Dict[str, int]:
143
+ """``{status: count}`` with every known status present (zero-filled)."""
144
+ counts = dict.fromkeys(VECTOR_JOB_STATUSES, 0)
145
+ with self._connect() as conn:
146
+ for row in conn.execute(
147
+ "SELECT status, COUNT(*) AS total FROM vector_jobs GROUP BY status"
148
+ ):
149
+ counts[str(row["status"])] = int(row["total"])
150
+ return counts
151
+
152
+
153
+ class VectorEmbedQueue:
154
+ """Schedule nodes for background embedding, then drain them a tick at a time.
155
+
156
+ ``indexer`` is normally ``KnowledgeGraphStore.index_node_incremental``: it
157
+ returns a status dict and never raises. A queue built without a usable
158
+ database is *disabled*, not silently in-memory — :meth:`describe` says so
159
+ and :meth:`schedule` returns False, because a backlog that evaporates on
160
+ restart is worse than an honest "not tracked".
161
+ """
162
+
163
+ def __init__(
164
+ self,
165
+ *,
166
+ db_path: Optional[Any] = None,
167
+ indexer: Optional[Callable[[str], Any]] = None,
168
+ max_attempts: int = DEFAULT_MAX_ATTEMPTS,
169
+ ) -> None:
170
+ self._indexer = indexer
171
+ self._max_attempts = max(1, int(max_attempts))
172
+ self._store: Optional[VectorJobStore] = None
173
+ self._detail: Optional[str] = None
174
+ if db_path is None:
175
+ self._detail = "no database configured; the embed backlog is not tracked"
176
+ else:
177
+ try:
178
+ self._store = VectorJobStore(db_path)
179
+ except Exception as exc: # noqa: BLE001 — never block ingestion
180
+ self._detail = f"vector job persistence unavailable: {exc}"
181
+ LOGGER.warning("vector job persistence unavailable: %s", exc)
182
+
183
+ @property
184
+ def available(self) -> bool:
185
+ return self._store is not None
186
+
187
+ def describe(self) -> Dict[str, Any]:
188
+ """Whether the backlog survives a restart, and where it lives."""
189
+ return {
190
+ "persistent": self._store is not None,
191
+ "db_path": str(self._store.db_path) if self._store is not None else None,
192
+ "detail": self._detail,
193
+ }
194
+
195
+ def schedule(self, node_id: str, *, detail: Optional[str] = None) -> bool:
196
+ if self._store is None:
197
+ return False
198
+ try:
199
+ return self._store.enqueue(node_id, detail=detail)
200
+ except Exception as exc: # noqa: BLE001 — queueing is best-effort
201
+ LOGGER.warning("vector job %s could not be queued: %s", node_id, exc)
202
+ return False
203
+
204
+ def snapshot(self) -> Dict[str, int]:
205
+ """Backlog counts by status (all zero when the queue is disabled)."""
206
+ if self._store is None:
207
+ return dict.fromkeys(VECTOR_JOB_STATUSES, 0)
208
+ try:
209
+ return self._store.counts()
210
+ except Exception: # noqa: BLE001 — a status read must never raise
211
+ return dict.fromkeys(VECTOR_JOB_STATUSES, 0)
212
+
213
+ def pending_count(self) -> int:
214
+ """Nodes still owed an embedding (queued plus in flight)."""
215
+ counts = self.snapshot()
216
+ return int(counts["pending"]) + int(counts["running"])
217
+
218
+ def tick(self, limit: int = DEFAULT_TICK_LIMIT) -> Dict[str, Any]:
219
+ """Drain up to ``limit`` queued nodes. Never raises.
220
+
221
+ A node whose indexing fails goes back to ``pending`` until its retry
222
+ budget runs out, then stays ``failed`` with the last reason attached —
223
+ visible backlog, not a silent retry loop.
224
+ """
225
+ summary: Dict[str, Any] = {
226
+ "claimed": 0,
227
+ "indexed": 0,
228
+ "retried": 0,
229
+ "failed": 0,
230
+ "detail": self._detail,
231
+ }
232
+ store = self._store
233
+ if store is None:
234
+ return summary
235
+ indexer = self._indexer
236
+ if indexer is None:
237
+ summary["detail"] = "no indexer configured; queued nodes were left pending"
238
+ return summary
239
+ claimed = store.claim(limit)
240
+ summary["claimed"] = len(claimed)
241
+ for node_id in claimed:
242
+ status, detail = self._run_one(store, indexer, node_id)
243
+ if status == "done":
244
+ summary["indexed"] += 1
245
+ elif status == "pending":
246
+ summary["retried"] += 1
247
+ else:
248
+ summary["failed"] += 1
249
+ store.finish(node_id, status=status, detail=detail)
250
+ return summary
251
+
252
+ async def tick_async(self, limit: int = DEFAULT_TICK_LIMIT) -> Dict[str, Any]:
253
+ """:meth:`tick` off the event loop — embedding and SQLite both block."""
254
+ return await asyncio.to_thread(self.tick, limit)
255
+
256
+ def _run_one(
257
+ self,
258
+ store: VectorJobStore,
259
+ indexer: Callable[[str], Any],
260
+ node_id: str,
261
+ ) -> Tuple[str, Optional[str]]:
262
+ """Index one node → the status to persist and the reason for it."""
263
+ try:
264
+ outcome = indexer(node_id) or {}
265
+ except Exception as exc: # noqa: BLE001 — one bad node must not stop the drain
266
+ return self._retry_or_fail(store, node_id, str(exc))
267
+ if str(outcome.get("status") or "") == "failed":
268
+ detail = str(outcome.get("detail") or "unknown error")
269
+ return self._retry_or_fail(store, node_id, detail)
270
+ return "done", None
271
+
272
+ def _retry_or_fail(
273
+ self, store: VectorJobStore, node_id: str, detail: str
274
+ ) -> Tuple[str, Optional[str]]:
275
+ attempts = store.attempts_for(node_id)
276
+ if attempts >= self._max_attempts:
277
+ return "failed", f"{detail} (gave up after {attempts} attempts)"
278
+ return "pending", detail
279
+
280
+
281
+ __all__ = [
282
+ "DEFAULT_MAX_ATTEMPTS",
283
+ "DEFAULT_TICK_LIMIT",
284
+ "VECTOR_JOB_STATUSES",
285
+ "VectorEmbedQueue",
286
+ "VectorJobStore",
287
+ ]