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.
Files changed (247) hide show
  1. package/README.md +46 -53
  2. package/docs/CHANGELOG.md +61 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +6 -2
  8. package/docs/PERMISSION_MODE.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +2 -2
  12. package/docs/v11.3.0_PLAN.md +202 -0
  13. package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/graph/_kg_common/__init__.py +287 -0
  16. package/lattice_brain/graph/_kg_common/extraction.py +516 -0
  17. package/lattice_brain/graph/_kg_common/relations.py +161 -0
  18. package/lattice_brain/graph/_kg_common/text.py +479 -0
  19. package/lattice_brain/graph/discovery_index/__init__.py +35 -0
  20. package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
  21. package/lattice_brain/graph/discovery_index/extract.py +137 -0
  22. package/lattice_brain/graph/discovery_index/scan.py +411 -0
  23. package/lattice_brain/graph/discovery_index/upsert.py +495 -0
  24. package/lattice_brain/graph/projection/__init__.py +42 -0
  25. package/lattice_brain/graph/projection/curation.py +500 -0
  26. package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +15 -477
  27. package/lattice_brain/graph/retrieval/__init__.py +54 -0
  28. package/lattice_brain/graph/retrieval/context.py +197 -0
  29. package/lattice_brain/graph/retrieval/graph_view.py +319 -0
  30. package/lattice_brain/graph/retrieval/hybrid.py +488 -0
  31. package/lattice_brain/graph/retrieval/maintenance.py +121 -0
  32. package/lattice_brain/graph/retrieval/signals.py +95 -0
  33. package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
  34. package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
  35. package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
  36. package/lattice_brain/graph/retrieval_vector/search.py +560 -0
  37. package/lattice_brain/graph/retrieval_vector/status.py +374 -0
  38. package/lattice_brain/ingestion/__init__.py +130 -0
  39. package/lattice_brain/ingestion/_contract.py +90 -0
  40. package/lattice_brain/ingestion/constants.py +127 -0
  41. package/lattice_brain/ingestion/folder_scan.py +57 -0
  42. package/lattice_brain/ingestion/folders.py +258 -0
  43. package/lattice_brain/ingestion/hashing.py +26 -0
  44. package/lattice_brain/ingestion/jobs_api.py +107 -0
  45. package/lattice_brain/ingestion/models.py +80 -0
  46. package/lattice_brain/ingestion/pipeline.py +486 -0
  47. package/lattice_brain/ingestion/quality.py +209 -0
  48. package/lattice_brain/ingestion/routing.py +295 -0
  49. package/lattice_brain/multimodal/__init__.py +164 -0
  50. package/lattice_brain/multimodal/audio.py +77 -0
  51. package/lattice_brain/multimodal/common.py +118 -0
  52. package/lattice_brain/multimodal/images.py +498 -0
  53. package/lattice_brain/multimodal/ports.py +169 -0
  54. package/lattice_brain/multimodal/video.py +410 -0
  55. package/lattice_brain/portability/__init__.py +90 -0
  56. package/lattice_brain/portability/_contract.py +42 -0
  57. package/lattice_brain/portability/backups.py +338 -0
  58. package/lattice_brain/portability/bundles.py +136 -0
  59. package/lattice_brain/portability/constants.py +93 -0
  60. package/lattice_brain/portability/fsops.py +138 -0
  61. package/lattice_brain/portability/service.py +41 -0
  62. package/lattice_brain/{portability.py → portability/sharing.py} +44 -677
  63. package/lattice_brain/runtime/__init__.py +1 -1
  64. package/lattice_brain/runtime/multi_agent.py +1 -1
  65. package/latticeai/__init__.py +1 -1
  66. package/latticeai/api/chronicle.py +63 -0
  67. package/latticeai/core/agent/__init__.py +93 -0
  68. package/latticeai/core/agent/_contract.py +79 -0
  69. package/latticeai/core/agent/context.py +57 -0
  70. package/latticeai/core/agent/deps.py +125 -0
  71. package/latticeai/core/agent/execution.py +622 -0
  72. package/latticeai/core/agent/planning.py +145 -0
  73. package/latticeai/core/agent/recovery.py +157 -0
  74. package/latticeai/core/agent/runtime.py +210 -0
  75. package/latticeai/core/agent/verification.py +231 -0
  76. package/latticeai/core/embedding_providers/__init__.py +151 -0
  77. package/latticeai/core/embedding_providers/base.py +199 -0
  78. package/latticeai/core/embedding_providers/captions.py +162 -0
  79. package/latticeai/core/embedding_providers/profiles.py +126 -0
  80. package/latticeai/core/embedding_providers/text.py +350 -0
  81. package/latticeai/core/embedding_providers/vision.py +352 -0
  82. package/latticeai/core/file_generation/__init__.py +115 -0
  83. package/latticeai/core/file_generation/bundles.py +76 -0
  84. package/latticeai/core/file_generation/extraction.py +154 -0
  85. package/latticeai/core/file_generation/inference.py +235 -0
  86. package/latticeai/core/file_generation/orchestration.py +152 -0
  87. package/latticeai/core/file_generation/prompting.py +117 -0
  88. package/latticeai/core/file_generation/repair.py +114 -0
  89. package/latticeai/core/file_generation/sanitize.py +61 -0
  90. package/latticeai/core/file_generation/validation.py +201 -0
  91. package/latticeai/core/legacy_compatibility.py +1 -1
  92. package/latticeai/core/marketplace.py +1 -1
  93. package/latticeai/core/messages.py +9 -0
  94. package/latticeai/core/workspace_os_constants.py +1 -1
  95. package/latticeai/integrations/telegram_bot/__init__.py +123 -0
  96. package/latticeai/integrations/telegram_bot/__main__.py +17 -0
  97. package/latticeai/integrations/telegram_bot/config.py +86 -0
  98. package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
  99. package/latticeai/integrations/telegram_bot/flows.py +478 -0
  100. package/latticeai/integrations/telegram_bot/helpers.py +322 -0
  101. package/latticeai/integrations/telegram_bot/screens.py +394 -0
  102. package/latticeai/models/router/__init__.py +88 -0
  103. package/latticeai/models/router/_contract.py +66 -0
  104. package/latticeai/models/router/branding.py +56 -0
  105. package/latticeai/models/router/catalog.py +69 -0
  106. package/latticeai/models/router/documents.py +199 -0
  107. package/latticeai/models/router/errors.py +37 -0
  108. package/latticeai/models/router/generation.py +258 -0
  109. package/latticeai/models/router/loading.py +291 -0
  110. package/latticeai/models/router/local_models.py +85 -0
  111. package/latticeai/models/router/registry.py +147 -0
  112. package/latticeai/runtime/build_phases/__init__.py +82 -0
  113. package/latticeai/runtime/build_phases/features.py +407 -0
  114. package/latticeai/runtime/build_phases/foundation.py +555 -0
  115. package/latticeai/runtime/build_phases/web.py +492 -0
  116. package/latticeai/runtime/runtime_context.py +1 -0
  117. package/latticeai/services/architecture_readiness.py +48 -19
  118. package/latticeai/services/brain_intelligence/__init__.py +58 -0
  119. package/latticeai/services/brain_intelligence/_contract.py +71 -0
  120. package/latticeai/services/brain_intelligence/consistency.py +193 -0
  121. package/latticeai/services/brain_intelligence/constants.py +47 -0
  122. package/latticeai/services/brain_intelligence/digest.py +258 -0
  123. package/latticeai/services/brain_intelligence/health.py +331 -0
  124. package/latticeai/services/brain_intelligence/proposals.py +264 -0
  125. package/latticeai/services/brain_intelligence/sampling.py +84 -0
  126. package/latticeai/services/brain_intelligence/service.py +48 -0
  127. package/latticeai/services/chronicle.py +557 -0
  128. package/latticeai/services/memory_service/__init__.py +52 -0
  129. package/latticeai/services/memory_service/_contract.py +100 -0
  130. package/latticeai/services/memory_service/brief.py +431 -0
  131. package/latticeai/services/memory_service/constants.py +57 -0
  132. package/latticeai/services/memory_service/maintenance.py +138 -0
  133. package/latticeai/services/memory_service/manager.py +186 -0
  134. package/latticeai/services/memory_service/proof.py +136 -0
  135. package/latticeai/services/memory_service/recall.py +225 -0
  136. package/latticeai/services/memory_service/service.py +48 -0
  137. package/latticeai/services/memory_service/stores.py +110 -0
  138. package/latticeai/services/model_runtime/__init__.py +322 -0
  139. package/latticeai/services/model_runtime/cloud.py +87 -0
  140. package/latticeai/services/model_runtime/download.py +282 -0
  141. package/latticeai/services/model_runtime/engines.py +341 -0
  142. package/latticeai/services/model_runtime/loading.py +178 -0
  143. package/latticeai/services/model_runtime/service.py +129 -0
  144. package/latticeai/services/model_runtime/state.py +131 -0
  145. package/latticeai/services/model_runtime/status.py +255 -0
  146. package/latticeai/services/product_readiness.py +15 -7
  147. package/latticeai/setup/wizard/__init__.py +126 -0
  148. package/latticeai/setup/wizard/catalog.py +172 -0
  149. package/latticeai/setup/wizard/detect.py +323 -0
  150. package/latticeai/setup/wizard/install.py +348 -0
  151. package/latticeai/setup/wizard/paths.py +168 -0
  152. package/latticeai/setup/wizard/plans.py +74 -0
  153. package/latticeai/setup/wizard/recommend.py +320 -0
  154. package/package.json +6 -2
  155. package/scripts/bump_version.py +14 -0
  156. package/scripts/capture_release_evidence.mjs +33 -21
  157. package/scripts/check_current_release_docs.mjs +1 -1
  158. package/scripts/check_i18n_namespace_coverage.mjs +41 -4
  159. package/scripts/check_max_file_lines.mjs +102 -0
  160. package/scripts/check_release_evidence_bound.mjs +30 -15
  161. package/scripts/check_screenshot_pixel_delta.py +34 -4
  162. package/scripts/check_server_i18n.mjs +1 -0
  163. package/scripts/generate_rust_parity_fixtures.py +562 -0
  164. package/scripts/lib/mock_server_fingerprint.mjs +94 -0
  165. package/scripts/release_screen_claims.json +31 -2
  166. package/src-tauri/Cargo.lock +361 -3
  167. package/src-tauri/Cargo.toml +6 -1
  168. package/src-tauri/src/backend.rs +349 -0
  169. package/src-tauri/src/folder.rs +33 -0
  170. package/src-tauri/src/main.rs +97 -399
  171. package/src-tauri/tauri.conf.json +1 -1
  172. package/static/app/asset-manifest.json +41 -37
  173. package/static/app/assets/Act-yYpYnn0v.js +1 -0
  174. package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
  175. package/static/app/assets/{Brain-tuhI4sOC.js → Brain-C1HBN0Wf.js} +2 -2
  176. package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
  177. package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
  178. package/static/app/assets/Capture-CFIRsFNE.js +1 -0
  179. package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
  180. package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
  181. package/static/app/assets/Library-DwO3yZST.js +1 -0
  182. package/static/app/assets/{LivingBrain-DBwhto14.js → LivingBrain-Jn1GK0-S.js} +1 -1
  183. package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
  184. package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
  185. package/static/app/assets/System-DW8F-2xL.js +1 -0
  186. package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
  187. package/static/app/assets/{bot-Cia42c2h.js → bot-IM_E_Y12.js} +1 -1
  188. package/static/app/assets/brain-Ci1CkWjM.js +1 -0
  189. package/static/app/assets/{button-2j2Ijzgq.js → button-COwyqfHM.js} +1 -1
  190. package/static/app/assets/circle-check-DfInj-qD.js +1 -0
  191. package/static/app/assets/{circle-pause-BEFeWpVW.js → circle-pause-DEM4A1Y5.js} +1 -1
  192. package/static/app/assets/{circle-play-ujXMcHxl.js → circle-play-C9djDuLd.js} +1 -1
  193. package/static/app/assets/{cpu-k4awryFq.js → cpu-DFdo1gw-.js} +1 -1
  194. package/static/app/assets/{download-DFbLJ_ig.js → download-SnJL6oqk.js} +1 -1
  195. package/static/app/assets/{folder-open-7y_b6xkM.js → folder-open-CqZeDkjE.js} +1 -1
  196. package/static/app/assets/{hard-drive-Bidh02Kr.js → hard-drive-j1jJXYYf.js} +1 -1
  197. package/static/app/assets/{index-DwDl9-8Y.css → index-BLPb5lmE.css} +1 -1
  198. package/static/app/assets/index-_u5iUHDr.js +10 -0
  199. package/static/app/assets/input-B0lPdRQZ.js +1 -0
  200. package/static/app/assets/link-2-CoFbooHS.js +1 -0
  201. package/static/app/assets/{permissionCopy-Bpb83Hx9.js → permissionCopy-BsyLxtao.js} +1 -1
  202. package/static/app/assets/primitives-DEbN-d6p.js +1 -0
  203. package/static/app/assets/search-BybIWPNd.js +1 -0
  204. package/static/app/assets/{share-2-BH1M-WNi.js → share-2-CVtZ_ewX.js} +1 -1
  205. package/static/app/assets/{shield-alert-BlKdBXcG.js → shield-alert-CBi2GNWM.js} +1 -1
  206. package/static/app/assets/{textarea-CCWbUfFB.js → textarea-DNMpB5ih.js} +1 -1
  207. package/static/app/assets/{useFocusTrap-YdHQ7pJ1.js → useFocusTrap-C83t3GXF.js} +1 -1
  208. package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
  209. package/static/app/assets/{useQuery-CXQiwbVT.js → useQuery-Dcp1OChy.js} +1 -1
  210. package/static/app/assets/utils-BlZr7Pd4.js +4 -0
  211. package/static/app/assets/workspace-jJY4RuAV.js +1 -0
  212. package/static/app/index.html +4 -4
  213. package/static/sw.js +1 -1
  214. package/lattice_brain/graph/_kg_common.py +0 -1331
  215. package/lattice_brain/graph/discovery_index.py +0 -1141
  216. package/lattice_brain/graph/retrieval.py +0 -1120
  217. package/lattice_brain/graph/retrieval_vector.py +0 -1293
  218. package/lattice_brain/ingestion.py +0 -1525
  219. package/lattice_brain/multimodal.py +0 -1258
  220. package/latticeai/core/agent.py +0 -1465
  221. package/latticeai/core/embedding_providers.py +0 -1196
  222. package/latticeai/core/file_generation.py +0 -1047
  223. package/latticeai/integrations/telegram_bot.py +0 -1390
  224. package/latticeai/models/router.py +0 -1007
  225. package/latticeai/runtime/build_phases.py +0 -1450
  226. package/latticeai/services/brain_intelligence.py +0 -1083
  227. package/latticeai/services/memory_service.py +0 -1177
  228. package/latticeai/services/model_runtime.py +0 -1281
  229. package/latticeai/setup/wizard.py +0 -1310
  230. package/static/app/assets/Act-AWf0SAKp.js +0 -1
  231. package/static/app/assets/AdminConsole-D0u8Tiyj.js +0 -1
  232. package/static/app/assets/BrainHome-Ts7G_Ila.js +0 -2
  233. package/static/app/assets/BrainSignals-jMYgQ2Ar.js +0 -1
  234. package/static/app/assets/Capture-CqOSzyPr.js +0 -1
  235. package/static/app/assets/CommandPalette-DC0Bzh-I.js +0 -1
  236. package/static/app/assets/Library-CX-bbhmK.js +0 -1
  237. package/static/app/assets/ProductFlow-BHA2cfKI.js +0 -1
  238. package/static/app/assets/ReviewCard-BUhCKRNM.js +0 -3
  239. package/static/app/assets/System-Bu2t5hn1.js +0 -1
  240. package/static/app/assets/arrow-left-Dzwa5zRb.js +0 -1
  241. package/static/app/assets/brain-DJMoqrwx.js +0 -1
  242. package/static/app/assets/index-BpYkzcVm.js +0 -10
  243. package/static/app/assets/input-DSlJJxRs.js +0 -1
  244. package/static/app/assets/primitives-BCx6TvfG.js +0 -1
  245. package/static/app/assets/search-Cgy8cCFJ.js +0 -1
  246. package/static/app/assets/utils-zqPZJxdx.js +0 -4
  247. package/static/app/assets/workspace-DXTihhfU.js +0 -1
@@ -0,0 +1,486 @@
1
+ """The single normalized entrypoint every source is fed through.
2
+
3
+ Source → normalize → content hash → (file | text) ingest → provenance
4
+
5
+ :meth:`IngestionPipeline.ingest` owns normalization, the gate reads, idempotency
6
+ reporting, provenance capture, the advisory quality annotation, and routing
7
+ every ingest through the shared ``dispatch_tool`` lifecycle so ``pre_tool`` /
8
+ ``post_tool`` hooks fire on data ingestion exactly as they do on tool calls.
9
+ The per-source doors live in ``routing.py``, the folder walk in ``folders.py``
10
+ and background scheduling in ``jobs_api.py``; this module is what they compose
11
+ into, and it owns the constructor state all three read.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Dict, List, Optional, Tuple
17
+
18
+ from ..graph.vector_index import DEFAULT_TICK_LIMIT as VECTOR_TICK_LIMIT
19
+ from ..ingestion_jobs import BackgroundIngestionQueue
20
+ from ..multimodal import (
21
+ DEFAULT_KEYFRAMES,
22
+ MODALITY_AUDIO,
23
+ MODALITY_IMAGE,
24
+ MODALITY_VIDEO,
25
+ VIDEO_UNAVAILABLE_DETAIL,
26
+ MultimodalPorts,
27
+ ffmpeg_available,
28
+ )
29
+ from ..quiet import quiet
30
+ from ..runtime.hooks import dispatch_tool
31
+ from ..utils import utc_now_iso
32
+ from .constants import (
33
+ ALLOW_MULTIMODAL_ENV,
34
+ ALLOW_VIDEO_ENV,
35
+ AUTO_VECTOR_INDEX_GATE,
36
+ CHAT_SOURCE_TYPES,
37
+ DEFAULT_MAX_TEXT_BYTES,
38
+ FILE_SOURCE_TYPES,
39
+ MEMORY_SOURCE_TYPES,
40
+ MULTIMODAL_GATE,
41
+ VIDEO_GATE,
42
+ )
43
+ from .folders import IngestionFolderMixin
44
+ from .jobs_api import IngestionJobsMixin
45
+ from .models import IngestionItem, IngestionResult
46
+ from .quality import QUALITY_LOW_WARNING, assess_extraction_quality
47
+ from .routing import IngestionRoutingMixin
48
+
49
+
50
+ class IngestionPipeline(
51
+ IngestionRoutingMixin, IngestionFolderMixin, IngestionJobsMixin
52
+ ):
53
+ """Single normalized entrypoint that feeds every source into the graph."""
54
+
55
+ def __init__(
56
+ self,
57
+ knowledge_graph: Any,
58
+ *,
59
+ hooks: Any = None,
60
+ enable_graph: bool = True,
61
+ audit: Optional[Any] = None,
62
+ max_text_bytes: int = DEFAULT_MAX_TEXT_BYTES,
63
+ pipeline_name: str = "unified-ingestion",
64
+ bg_queue: Optional[BackgroundIngestionQueue] = None,
65
+ auto_vector_index: bool = True,
66
+ allow_multimodal: bool = False,
67
+ multimodal: Optional[MultimodalPorts] = None,
68
+ ) -> None:
69
+ self._kg = knowledge_graph
70
+ self._hooks = hooks
71
+ self._enable = bool(enable_graph)
72
+ self._audit = audit
73
+ self._max_text_bytes = int(max_text_bytes)
74
+ self._pipeline_name = pipeline_name
75
+ # Background job state lives in the graph database by default, so a
76
+ # restart resumes from the last completed item instead of replaying the
77
+ # whole corpus. A store without a usable ``db_path`` (mocks, disabled
78
+ # graph) degrades to the historical in-memory queue, which reports
79
+ # itself as non-durable through ``BackgroundIngestionQueue.describe()``.
80
+ self._bg_queue = bg_queue or BackgroundIngestionQueue(
81
+ db_path=getattr(knowledge_graph, "db_path", None)
82
+ )
83
+ # Incremental vector sync after each successful non-duplicate ingest.
84
+ # Constructor opt-out AND gate opt-out (LATTICEAI_AUTO_VECTOR_INDEX=0,
85
+ # or the settings toggle bound to it) both disable it; a vector failure
86
+ # never fails the ingest. The gate half is asked per ingest rather than
87
+ # frozen here, so turning it off takes effect on the next item.
88
+ self._auto_vector_index_opt_in = bool(auto_vector_index)
89
+ # Multi-modal routing. Off unless the caller asks for it *or* the gate
90
+ # says yes — the env behind that gate is the escape hatch for an
91
+ # install with no code path to the constructor (CLI, background
92
+ # worker), and the gate is now asked per call so a runtime toggle can
93
+ # reach it. A constructor ``True`` is still a permanent yes.
94
+ self._multimodal_opt_in = bool(allow_multimodal)
95
+ self._multimodal = multimodal or MultimodalPorts()
96
+ self._keyframes = DEFAULT_KEYFRAMES
97
+
98
+ @property
99
+ def _auto_vector_index(self) -> bool:
100
+ """Whether a landed ingest also syncs its vector, asked *now*."""
101
+ return self._auto_vector_index_opt_in and AUTO_VECTOR_INDEX_GATE.enabled()
102
+
103
+ @property
104
+ def _allow_multimodal(self) -> bool:
105
+ """Whether pictures and recordings route by modality, asked *now*."""
106
+ return self._multimodal_opt_in or MULTIMODAL_GATE.enabled()
107
+
108
+ @property
109
+ def _allow_video(self) -> bool:
110
+ """Video needs multi-modal on, its own sub-switch on, and a decoder."""
111
+ return self._allow_multimodal and VIDEO_GATE.enabled() and self._can_decode_video()
112
+
113
+ def _can_decode_video(self) -> bool:
114
+ """An injected keyframe port counts as a decoder; otherwise, ffmpeg."""
115
+ return self._multimodal.keyframe_extractor is not None or ffmpeg_available()
116
+
117
+ def available(self) -> bool:
118
+ return self._enable and self._kg is not None
119
+
120
+ def multimodal_status(self) -> Dict[str, Any]:
121
+ """What this pipeline will do with a picture or a recording, honestly.
122
+
123
+ ``enabled`` is the flag; the rest is which model-backed capabilities
124
+ were actually injected. Video reports whether it can really run — the
125
+ answer is no on a machine with no ffmpeg, and it says which of the two
126
+ reasons applies rather than leaving the surface to guess.
127
+ """
128
+ allowed = self._allow_multimodal
129
+ video = self._allow_video
130
+ return {
131
+ "enabled": allowed,
132
+ "image": allowed,
133
+ "audio": allowed,
134
+ "video": video,
135
+ "video_detail": None if video else self._video_refusal(),
136
+ "gates": {
137
+ "multimodal": MULTIMODAL_GATE.describe(),
138
+ "video": VIDEO_GATE.describe(),
139
+ },
140
+ **self._multimodal.describe(),
141
+ }
142
+
143
+ def _video_refusal(self) -> str:
144
+ """Why a video would be refused right now — never a stale reason."""
145
+ if not self._allow_multimodal:
146
+ return (
147
+ "multi-modal ingestion is off; pictures, recordings and videos "
148
+ f"are only stored when {ALLOW_MULTIMODAL_ENV} is on"
149
+ )
150
+ if not VIDEO_GATE.enabled():
151
+ return (
152
+ "video ingestion is turned off for this install "
153
+ f"({ALLOW_VIDEO_ENV}); pictures and recordings are unaffected"
154
+ )
155
+ return VIDEO_UNAVAILABLE_DETAIL
156
+
157
+ # ── public API ───────────────────────────────────────────────────────────
158
+ def ingest(self, item: IngestionItem, *, user_email: Optional[str] = None) -> IngestionResult:
159
+ """Normalize, hash, route through dispatch_tool, and record provenance."""
160
+ source_type = str(item.source_type or "text").strip().lower()
161
+ if not self.available():
162
+ return IngestionResult(
163
+ status="unavailable", source_type=source_type,
164
+ indexing_status="skipped",
165
+ detail="Knowledge Graph is disabled (LATTICEAI_ENABLE_GRAPH).",
166
+ )
167
+
168
+ # Modality routing is a no-op while the flag is off: ``modality`` stays
169
+ # "text" and every branch below behaves exactly as it did before.
170
+ modality = self._modality_for(item, source_type)
171
+ if modality == MODALITY_VIDEO and not self._allow_video:
172
+ # Recognized and refused, with the reason that actually applies
173
+ # right now — a missing decoder is not the same answer as a
174
+ # switched-off feature, and the caller can act on the difference.
175
+ return IngestionResult(
176
+ status="unavailable", source_type=source_type,
177
+ indexing_status="skipped", detail=self._video_refusal(),
178
+ )
179
+
180
+ captured_at = item.captured_at or utc_now_iso()
181
+ owner = item.owner or user_email
182
+ tool_name = f"kg_ingest.{source_type}"
183
+ # Only the keys are read by the hook payload, so this dict is safe/cheap.
184
+ args = {
185
+ "source_type": source_type,
186
+ "source_uri": item.source_uri,
187
+ "owner": owner,
188
+ "workspace_id": item.workspace_id,
189
+ }
190
+
191
+ def _run() -> Dict[str, Any]:
192
+ if source_type in CHAT_SOURCE_TYPES:
193
+ return self._ingest_chat(item, source_type=source_type, owner=owner)
194
+ if source_type in MEMORY_SOURCE_TYPES:
195
+ return self._ingest_memory_record(item, source_type=source_type, owner=owner)
196
+ if modality == MODALITY_IMAGE:
197
+ return self._ingest_image(item, source_type=source_type, owner=owner, captured_at=captured_at)
198
+ if modality == MODALITY_AUDIO:
199
+ return self._ingest_audio(item, source_type=source_type, owner=owner, captured_at=captured_at)
200
+ if modality == MODALITY_VIDEO:
201
+ return self._ingest_video(item, source_type=source_type, owner=owner, captured_at=captured_at)
202
+ if source_type in FILE_SOURCE_TYPES or (item.path and not item.text):
203
+ return self._ingest_file(item, source_type=source_type, owner=owner, captured_at=captured_at)
204
+ return self._ingest_text(item, source_type=source_type, owner=owner, captured_at=captured_at)
205
+
206
+ # v9.8.0 observation-only quality gate: computed *before* the write so
207
+ # the search never matches the node we are about to create. It is
208
+ # recorded on the result and never skips an ingest (behavior unchanged).
209
+ quality_text = self._extractable_text(item)
210
+ quality_gate = self._observe_quality_gate(
211
+ item, source_type=source_type, text=quality_text,
212
+ )
213
+
214
+ try:
215
+ raw = dispatch_tool(
216
+ self._hooks, tool_name, args, _run,
217
+ user_email=user_email, workspace_id=item.workspace_id, source="ingestion",
218
+ )
219
+ except PermissionError as exc:
220
+ return IngestionResult(
221
+ status="blocked", source_type=source_type,
222
+ indexing_status="skipped", detail=str(exc),
223
+ )
224
+ except FileNotFoundError as exc:
225
+ return IngestionResult(
226
+ status="failed", source_type=source_type,
227
+ indexing_status="failed", detail=str(exc),
228
+ )
229
+ except Exception as exc: # noqa: BLE001 — surface as a failed result, never crash the caller
230
+ return IngestionResult(
231
+ status="failed", source_type=source_type,
232
+ indexing_status="failed", detail=str(exc),
233
+ )
234
+
235
+ node_id = raw.get("node_id")
236
+ content_hash = raw.get("content_hash") or raw.get("sha256")
237
+ chunk_ids = list(raw.get("chunk_ids") or [])
238
+ title = raw.get("title") or item.title
239
+
240
+ # Incremental vector-index sync (opt-in via auto_vector_index +
241
+ # LATTICEAI_AUTO_VECTOR_INDEX). Exception-safe by contract: the graph
242
+ # write above already landed, so a vector failure only downgrades
243
+ # indexing_status to "pending" — index_status()/rebuild_vector_index()
244
+ # discover the same node as backlog and pick it up later.
245
+ indexing_status = "indexed"
246
+ vector_detail: Optional[str] = None
247
+ if node_id and self._auto_vector_index and not bool(raw.get("duplicate")):
248
+ indexing_status, vector_detail = self._sync_vector_index(node_id)
249
+ embedded = bool(self._kg.node_is_embedded(node_id)) if node_id else False
250
+
251
+ # Provenance capture must never turn an already-persisted ingest into a
252
+ # caller-visible failure: the graph write above succeeded, so a broken
253
+ # provenance table degrades the result instead of raising.
254
+ provenance_detail: Optional[str] = None
255
+ try:
256
+ prov = self._kg.record_provenance(
257
+ node_id=node_id,
258
+ source_type=source_type,
259
+ pipeline=self._pipeline_name,
260
+ source_uri=item.source_uri,
261
+ content_hash=content_hash,
262
+ title=title,
263
+ owner=owner,
264
+ workspace_id=item.workspace_id,
265
+ captured_at=captured_at,
266
+ modified_at=item.modified_at,
267
+ embedded=embedded,
268
+ linked=bool(raw.get("source_node_id")),
269
+ duplicate=bool(raw.get("duplicate")),
270
+ agent_used=item.agent_used,
271
+ chunk_count=len(chunk_ids),
272
+ permissions=item.permissions,
273
+ metadata=item.metadata,
274
+ )
275
+ except Exception as exc: # noqa: BLE001 — the ingest itself already landed
276
+ prov = {}
277
+ provenance_detail = f"provenance capture failed: {exc}"
278
+ if self._audit is not None:
279
+ try:
280
+ self._audit(
281
+ "kg_ingest",
282
+ {
283
+ "source_type": source_type, "node_id": node_id,
284
+ "content_hash": content_hash, "duplicate": bool(raw.get("duplicate")),
285
+ },
286
+ user_email,
287
+ )
288
+ except Exception: # noqa: BLE001 — audit must never break ingestion
289
+ quiet()
290
+
291
+ # A modality-aware door scores its own extraction (a picture's quality
292
+ # is "how much of it can be retrieved", not "does the text read well"),
293
+ # so its verdict wins. Text/file doors never set the key and keep the
294
+ # historical scoring untouched.
295
+ extraction_quality = raw.get("extraction_quality") or self._assess_item_quality(
296
+ item, source_type=source_type, text=quality_text, chunk_ids=chunk_ids,
297
+ )
298
+ warnings: List[str] = []
299
+ if extraction_quality is not None and extraction_quality.get("level") == "low":
300
+ warnings.append(QUALITY_LOW_WARNING)
301
+
302
+ details = [d for d in (provenance_detail, vector_detail) if d]
303
+ return IngestionResult(
304
+ status="ok",
305
+ source_type=source_type,
306
+ node_id=node_id,
307
+ source_node_id=raw.get("source_node_id"),
308
+ content_hash=content_hash,
309
+ title=title,
310
+ chunk_ids=chunk_ids,
311
+ chunk_count=len(chunk_ids),
312
+ duplicate=bool(raw.get("duplicate")),
313
+ embedded=embedded,
314
+ indexing_status=indexing_status,
315
+ provenance_id=prov.get("id"),
316
+ detail="; ".join(details) if details else None,
317
+ extraction_quality=extraction_quality,
318
+ warnings=warnings,
319
+ quality_gate=quality_gate,
320
+ )
321
+
322
+ # ── extraction quality (v9.8.0 A1 — advisory, never gates) ───────────────
323
+ @staticmethod
324
+ def _extractable_text(item: IngestionItem) -> Optional[str]:
325
+ """Best available extracted text for quality scoring/gating."""
326
+ if item.text is not None:
327
+ return item.text
328
+ extracted = (item.metadata or {}).get("extracted")
329
+ if isinstance(extracted, dict):
330
+ content = extracted.get("content") or extracted.get("text")
331
+ if content is not None:
332
+ return str(content)
333
+ return None
334
+
335
+ @staticmethod
336
+ def _upstream_confidence(item: IngestionItem) -> Optional[Any]:
337
+ """Upstream extractor confidence, if the capture surface supplied one."""
338
+ meta = item.metadata or {}
339
+ extracted = meta.get("extracted")
340
+ if isinstance(extracted, dict) and extracted.get("confidence") is not None:
341
+ return extracted.get("confidence")
342
+ if meta.get("extraction_confidence") is not None:
343
+ return meta.get("extraction_confidence")
344
+ return None
345
+
346
+ def _assess_item_quality(
347
+ self,
348
+ item: IngestionItem,
349
+ *,
350
+ source_type: str,
351
+ text: Optional[str],
352
+ chunk_ids: List[str],
353
+ ) -> Optional[Dict[str, Any]]:
354
+ """Quality annotation for document-like sources (not chat/memory)."""
355
+ if source_type in CHAT_SOURCE_TYPES or source_type in MEMORY_SOURCE_TYPES:
356
+ return None
357
+ confidence = self._upstream_confidence(item)
358
+ if text is not None or confidence is not None:
359
+ return assess_extraction_quality(
360
+ text, source_type=source_type, upstream_confidence=confidence,
361
+ )
362
+ # File door without inline extraction (e.g. PDF): the pipeline never saw
363
+ # the text, so score honestly from the chunk output instead of guessing.
364
+ if chunk_ids:
365
+ return {
366
+ "score": 0.5,
367
+ "level": "medium",
368
+ "reasons": ["content_extracted_upstream_not_scored"],
369
+ }
370
+ return {"score": 0.0, "level": "low", "reasons": ["no_extracted_text"]}
371
+
372
+ def _observe_quality_gate(
373
+ self,
374
+ item: IngestionItem,
375
+ *,
376
+ source_type: str,
377
+ text: Optional[str],
378
+ ) -> Optional[Dict[str, Any]]:
379
+ """Observation-mode ``gate_ingest_candidate`` wiring.
380
+
381
+ Records what the proactive gate *would* decide (ingest /
382
+ skip_duplicate / review) without ever acting on it. Any failure —
383
+ import, search, gate — yields ``None``; the ingest proceeds untouched.
384
+ """
385
+ if source_type in CHAT_SOURCE_TYPES or source_type in MEMORY_SOURCE_TYPES:
386
+ return None
387
+ body = str(text or "").strip()
388
+ if not body:
389
+ return None
390
+ try:
391
+ from ..graph.proactive import gate_ingest_candidate
392
+ except Exception: # noqa: BLE001 — optional observation, never required
393
+ return None
394
+
395
+ def _search(query: str) -> Any:
396
+ snippet = str(query or "")[:400]
397
+ try:
398
+ if item.workspace_id:
399
+ return self._kg.search(
400
+ snippet, 20, allowed_workspaces={item.workspace_id},
401
+ )
402
+ return self._kg.search(snippet, 20)
403
+ except TypeError:
404
+ # Older store without workspace-scoped search.
405
+ return self._kg.search(snippet, 20)
406
+
407
+ try:
408
+ gate = gate_ingest_candidate(body, _search)
409
+ except Exception: # noqa: BLE001 — observation must never fail the ingest
410
+ return None
411
+ parts = [str(gate.get("reason") or "")]
412
+ if gate.get("similarity") is not None:
413
+ parts.append(f"similarity={gate.get('similarity')}")
414
+ if gate.get("match_id"):
415
+ parts.append(f"match={gate.get('match_id')}")
416
+ return {
417
+ "action": str(gate.get("action") or "review"),
418
+ "detail": "; ".join(p for p in parts if p),
419
+ }
420
+
421
+ def _queue_pending_embed(self, node_id: str, detail: str) -> bool:
422
+ """Hand a node the inline sync could not embed to the background queue.
423
+
424
+ Before v11.1.0 ``indexing_status="pending"`` was the end of the story:
425
+ honest, but nobody was coming back for it, so the node stayed
426
+ unsearchable until a human ran a rebuild. The durable queue is who
427
+ comes back. A store without one (older stores, mocks) just keeps the
428
+ old behaviour — the node is still visible as ``index_status`` backlog.
429
+ """
430
+ queue = getattr(self._kg, "vector_queue", None)
431
+ if queue is None:
432
+ return False
433
+ try:
434
+ return bool(queue.schedule(node_id, detail=detail))
435
+ except Exception: # noqa: BLE001 — queueing must never fail an ingest
436
+ quiet()
437
+ return False
438
+
439
+ def _sync_vector_index(self, node_id: str) -> Tuple[str, Optional[str]]:
440
+ """Best-effort incremental vector sync → (indexing_status, detail).
441
+
442
+ Any failure — missing method on older stores, embedding provider down,
443
+ storage error — yields ``("pending", detail)`` so a later
444
+ ``rebuild_vector_index`` run picks the node up from the backlog, and
445
+ the node is queued for background embedding so that pickup happens on
446
+ its own.
447
+ """
448
+ sync = getattr(self._kg, "index_node_incremental", None)
449
+ if not callable(sync):
450
+ # Older store without the incremental path: the write-side already
451
+ # embeds inline, so nothing extra to do.
452
+ return "indexed", None
453
+ try:
454
+ outcome = sync(node_id) or {}
455
+ except Exception as exc: # noqa: BLE001 — vector sync must never fail the ingest
456
+ return "pending", self._pending_detail(node_id, f"vector index sync failed: {exc}")
457
+ if str(outcome.get("status") or "") == "failed":
458
+ reason = outcome.get("detail") or "unknown error"
459
+ return "pending", self._pending_detail(
460
+ node_id, f"vector index sync failed: {reason}"
461
+ )
462
+ return "indexed", None
463
+
464
+ def _pending_detail(self, node_id: str, reason: str) -> str:
465
+ """``reason``, plus whether a background retry was actually scheduled."""
466
+ if self._queue_pending_embed(node_id, reason):
467
+ return f"{reason}; queued for background embedding"
468
+ return reason
469
+
470
+ def drain_vector_queue(self, limit: int = VECTOR_TICK_LIMIT) -> Dict[str, Any]:
471
+ """Run one background-embedding tick over the store's pending backlog.
472
+
473
+ Deliberately caller-driven (a scheduler, a CLI, a test) rather than a
474
+ thread this pipeline owns: the queue is durable, so "who runs it" is a
475
+ deployment decision, not a property of having ingested something.
476
+ """
477
+ queue = getattr(self._kg, "vector_queue", None)
478
+ if queue is None:
479
+ return {
480
+ "claimed": 0,
481
+ "indexed": 0,
482
+ "retried": 0,
483
+ "failed": 0,
484
+ "detail": "this store has no background vector queue",
485
+ }
486
+ return dict(queue.tick(limit))