ltcai 9.9.9 → 10.2.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 (228) hide show
  1. package/README.md +51 -38
  2. package/docs/CHANGELOG.md +197 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/HYBRID_CLOUD_KG_STREAMING.md +101 -0
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +1 -1
  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/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/archive.py +5 -4
  13. package/lattice_brain/conversations.py +14 -3
  14. package/lattice_brain/embeddings.py +12 -2
  15. package/lattice_brain/graph/_kg_common.py +5 -5
  16. package/lattice_brain/graph/_kg_fsutil.py +4 -3
  17. package/lattice_brain/graph/discovery.py +4 -3
  18. package/lattice_brain/graph/discovery_index.py +0 -1
  19. package/lattice_brain/graph/documents.py +3 -2
  20. package/lattice_brain/graph/fusion.py +4 -0
  21. package/lattice_brain/graph/ingest.py +12 -2
  22. package/lattice_brain/graph/projection.py +3 -2
  23. package/lattice_brain/graph/provenance.py +5 -3
  24. package/lattice_brain/graph/rerank.py +4 -1
  25. package/lattice_brain/graph/retrieval.py +0 -1
  26. package/lattice_brain/graph/retrieval_docgen.py +0 -1
  27. package/lattice_brain/graph/retrieval_reads.py +0 -1
  28. package/lattice_brain/graph/retrieval_vector.py +0 -1
  29. package/lattice_brain/graph/schema.py +4 -3
  30. package/lattice_brain/graph/store.py +18 -4
  31. package/lattice_brain/graph/write_master.py +46 -1
  32. package/lattice_brain/ingestion.py +4 -1
  33. package/lattice_brain/portability.py +5 -2
  34. package/lattice_brain/quality.py +12 -5
  35. package/lattice_brain/quiet.py +43 -0
  36. package/lattice_brain/runtime/agent_runtime.py +12 -8
  37. package/lattice_brain/runtime/hooks.py +2 -1
  38. package/lattice_brain/runtime/multi_agent.py +2 -3
  39. package/lattice_brain/sensitivity.py +94 -0
  40. package/lattice_brain/storage/base.py +30 -2
  41. package/lattice_brain/storage/migration.py +3 -2
  42. package/lattice_brain/storage/postgres.py +2 -2
  43. package/lattice_brain/storage/sqlite.py +6 -4
  44. package/lattice_brain/workflow.py +4 -2
  45. package/latticeai/__init__.py +1 -1
  46. package/latticeai/api/admin.py +1 -1
  47. package/latticeai/api/agents.py +4 -2
  48. package/latticeai/api/auth.py +5 -1
  49. package/latticeai/api/automation_intelligence.py +2 -1
  50. package/latticeai/api/browser.py +3 -2
  51. package/latticeai/api/chat.py +28 -17
  52. package/latticeai/api/chat_agent_http.py +22 -8
  53. package/latticeai/api/chat_contracts.py +4 -0
  54. package/latticeai/api/chat_documents.py +6 -2
  55. package/latticeai/api/chat_hybrid.py +82 -0
  56. package/latticeai/api/computer_use.py +8 -3
  57. package/latticeai/api/knowledge_graph.py +1 -1
  58. package/latticeai/api/mcp.py +4 -4
  59. package/latticeai/api/models.py +5 -2
  60. package/latticeai/api/network_boundary.py +220 -0
  61. package/latticeai/api/permissions.py +0 -1
  62. package/latticeai/api/realtime.py +1 -1
  63. package/latticeai/api/security_dashboard.py +1 -1
  64. package/latticeai/api/setup.py +16 -3
  65. package/latticeai/api/static_routes.py +2 -1
  66. package/latticeai/api/tools.py +12 -8
  67. package/latticeai/api/voice_capture.py +3 -1
  68. package/latticeai/api/workflow_designer.py +1 -1
  69. package/latticeai/api/workspace.py +1 -2
  70. package/latticeai/app_factory.py +131 -78
  71. package/latticeai/cli/entrypoint.py +6 -4
  72. package/latticeai/core/agent.py +55 -495
  73. package/latticeai/core/agent_eval.py +2 -2
  74. package/latticeai/core/agent_helpers.py +493 -0
  75. package/latticeai/core/agent_prompts.py +0 -1
  76. package/latticeai/core/agent_registry.py +3 -1
  77. package/latticeai/core/agent_state.py +41 -0
  78. package/latticeai/core/audit.py +1 -1
  79. package/latticeai/core/builtin_hooks.py +2 -1
  80. package/latticeai/core/config.py +0 -1
  81. package/latticeai/core/embedding_providers.py +12 -1
  82. package/latticeai/core/file_generation.py +3 -0
  83. package/latticeai/core/invitations.py +4 -1
  84. package/latticeai/core/io_utils.py +3 -1
  85. package/latticeai/core/legacy_compatibility.py +1 -2
  86. package/latticeai/core/local_embeddings.py +12 -1
  87. package/latticeai/core/marketplace.py +1 -2
  88. package/latticeai/core/mcp_registry.py +0 -1
  89. package/latticeai/core/model_compat.py +1 -1
  90. package/latticeai/core/model_resolution.py +1 -1
  91. package/latticeai/core/network_boundary.py +168 -0
  92. package/latticeai/core/oidc.py +3 -0
  93. package/latticeai/core/permission_mode.py +6 -6
  94. package/latticeai/core/plugins.py +3 -2
  95. package/latticeai/core/policy.py +0 -1
  96. package/latticeai/core/quiet.py +84 -0
  97. package/latticeai/core/realtime.py +3 -2
  98. package/latticeai/core/run_store.py +4 -2
  99. package/latticeai/core/security.py +4 -0
  100. package/latticeai/core/users.py +5 -3
  101. package/latticeai/core/workspace_graph_trace.py +3 -0
  102. package/latticeai/core/workspace_os.py +65 -273
  103. package/latticeai/core/workspace_os_constants.py +126 -0
  104. package/latticeai/core/workspace_os_state.py +180 -0
  105. package/latticeai/core/workspace_os_utils.py +3 -1
  106. package/latticeai/core/workspace_permissions.py +1 -0
  107. package/latticeai/core/workspace_snapshots.py +3 -1
  108. package/latticeai/core/workspace_timeline.py +3 -1
  109. package/latticeai/integrations/telegram_bot.py +25 -16
  110. package/latticeai/models/router.py +6 -3
  111. package/latticeai/runtime/access_runtime.py +3 -1
  112. package/latticeai/runtime/audit_runtime.py +3 -2
  113. package/latticeai/runtime/chat_wiring.py +4 -1
  114. package/latticeai/runtime/history_runtime.py +1 -1
  115. package/latticeai/runtime/lifespan_runtime.py +3 -1
  116. package/latticeai/runtime/network_boundary_wiring.py +124 -0
  117. package/latticeai/runtime/persistence_runtime.py +3 -1
  118. package/latticeai/runtime/router_registration.py +11 -1
  119. package/latticeai/services/architecture_readiness.py +1 -2
  120. package/latticeai/services/change_proposals.py +2 -1
  121. package/latticeai/services/cloud_egress_audit.py +85 -0
  122. package/latticeai/services/cloud_extraction.py +129 -0
  123. package/latticeai/services/cloud_streaming.py +266 -0
  124. package/latticeai/services/cloud_token_guard.py +84 -0
  125. package/latticeai/services/command_center.py +2 -1
  126. package/latticeai/services/folder_watch.py +3 -0
  127. package/latticeai/services/funnel_metrics.py +3 -1
  128. package/latticeai/services/hybrid_chat.py +265 -0
  129. package/latticeai/services/hybrid_context.py +228 -0
  130. package/latticeai/services/hybrid_policy.py +178 -0
  131. package/latticeai/services/memory_service.py +1 -1
  132. package/latticeai/services/model_catalog.py +10 -1
  133. package/latticeai/services/model_engines.py +35 -14
  134. package/latticeai/services/model_loading.py +3 -2
  135. package/latticeai/services/model_runtime.py +68 -17
  136. package/latticeai/services/multimodal_streaming.py +123 -0
  137. package/latticeai/services/network_boundary_service.py +154 -0
  138. package/latticeai/services/openai_compatible_adapter.py +100 -0
  139. package/latticeai/services/p_reinforce.py +4 -0
  140. package/latticeai/services/platform_runtime.py +9 -4
  141. package/latticeai/services/process_audit.py +0 -1
  142. package/latticeai/services/product_readiness.py +1 -1
  143. package/latticeai/services/run_executor.py +3 -2
  144. package/latticeai/services/search_service.py +1 -4
  145. package/latticeai/services/setup_detection.py +2 -1
  146. package/latticeai/services/tool_dispatch.py +7 -2
  147. package/latticeai/services/upload_service.py +2 -1
  148. package/latticeai/setup/auto_setup.py +10 -7
  149. package/latticeai/setup/wizard.py +15 -11
  150. package/latticeai/tools/__init__.py +3 -3
  151. package/latticeai/tools/commands.py +7 -7
  152. package/latticeai/tools/computer.py +3 -2
  153. package/latticeai/tools/documents.py +10 -9
  154. package/latticeai/tools/filesystem.py +7 -4
  155. package/latticeai/tools/knowledge.py +2 -0
  156. package/latticeai/tools/local_files.py +1 -1
  157. package/latticeai/tools/network.py +2 -1
  158. package/package.json +6 -4
  159. package/scripts/bench_agent_smoke.py +0 -1
  160. package/scripts/bench_models.py +0 -1
  161. package/scripts/brain_quality_eval.py +7 -2
  162. package/scripts/bump_version.py +2 -1
  163. package/scripts/check_current_release_docs.mjs +1 -1
  164. package/scripts/migrate_brain_storage.py +5 -1
  165. package/scripts/profile_kg.py +2 -7
  166. package/scripts/verify_hf_model_registry.py +2 -2
  167. package/src-tauri/Cargo.lock +1 -1
  168. package/src-tauri/Cargo.toml +1 -1
  169. package/src-tauri/tauri.conf.json +1 -1
  170. package/static/app/asset-manifest.json +37 -37
  171. package/static/app/assets/Act-CbdGD-2i.js +1 -0
  172. package/static/app/assets/AdminConsole-LgCkXpnf.js +1 -0
  173. package/static/app/assets/Brain-CoPGJI1L.js +321 -0
  174. package/static/app/assets/BrainHome-gVnaxSwp.js +2 -0
  175. package/static/app/assets/BrainSignals-ChxAYHtj.js +1 -0
  176. package/static/app/assets/Capture-DBtgkHZg.js +1 -0
  177. package/static/app/assets/CommandPalette-Ovtv5I0Y.js +1 -0
  178. package/static/app/assets/Library-CBia2Fvm.js +1 -0
  179. package/static/app/assets/LivingBrain-CNz-6NSd.js +1 -0
  180. package/static/app/assets/{ProductFlow-cB3EFinZ.js → ProductFlow-ePX-Y73J.js} +1 -1
  181. package/static/app/assets/ReviewCard-C4mpvkwH.js +3 -0
  182. package/static/app/assets/System-BvWNK1zc.js +1 -0
  183. package/static/app/assets/activity-CiauPV_3.js +1 -0
  184. package/static/app/assets/{bot-DtKkLfXj.js → bot-Dwct-Q1x.js} +1 -1
  185. package/static/app/assets/brain-BKDW1F0T.js +1 -0
  186. package/static/app/assets/{button-BTxZDMFJ.js → button-CCB9Kuw7.js} +1 -1
  187. package/static/app/assets/{circle-pause--qxF9-EO.js → circle-pause-DNa8WHC5.js} +1 -1
  188. package/static/app/assets/{circle-play-89x3S6jm.js → circle-play-8jmxn5mf.js} +1 -1
  189. package/static/app/assets/{cpu-DTUI63vo.js → cpu-mxvF3V1I.js} +1 -1
  190. package/static/app/assets/{download-EJS5_O0f.js → download--SmCcx0p.js} +1 -1
  191. package/static/app/assets/{folder-open-BL9zYTnc.js → folder-open-D4Wy5roo.js} +1 -1
  192. package/static/app/assets/{hard-drive-BFnwi1Lu.js → hard-drive-BwQcSPlS.js} +1 -1
  193. package/static/app/assets/index-CQWdDU3z.css +2 -0
  194. package/static/app/assets/index-DDV2YZwM.js +10 -0
  195. package/static/app/assets/input-DFhSjmLS.js +1 -0
  196. package/static/app/assets/{network-zKa3jAwl.js → network-Bts7VO94.js} +1 -1
  197. package/static/app/assets/primitives-BuSMEJY8.js +1 -0
  198. package/static/app/assets/search-DZzxhWaQ.js +1 -0
  199. package/static/app/assets/{shield-alert-CpHeCsaF.js → shield-alert-BfTO6X_G.js} +1 -1
  200. package/static/app/assets/textarea-BjctW1oh.js +1 -0
  201. package/static/app/assets/{useFocusTrap-58TNCONM.js → useFocusTrap-CE43-LrA.js} +1 -1
  202. package/static/app/assets/{useQuery-CcVlXlBd.js → useQuery-Bv3ejLL5.js} +1 -1
  203. package/static/app/assets/{users-C0cFMj6o.js → users-CsNqLZAj.js} +1 -1
  204. package/static/app/assets/utils-KFFdVG_t.js +7 -0
  205. package/static/app/assets/workspace-wdCvdyPF.js +1 -0
  206. package/static/app/index.html +4 -4
  207. package/static/sw.js +1 -1
  208. package/static/app/assets/Act-BFbpl2_Y.js +0 -1
  209. package/static/app/assets/AdminConsole-DaZPha9Y.js +0 -1
  210. package/static/app/assets/Brain-DGrV0heZ.js +0 -321
  211. package/static/app/assets/BrainHome-Re6Zxh_W.js +0 -2
  212. package/static/app/assets/BrainSignals-BQ11jDpJ.js +0 -1
  213. package/static/app/assets/Capture-DRaf1zLH.js +0 -1
  214. package/static/app/assets/CommandPalette-DAKPm4uJ.js +0 -1
  215. package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +0 -1
  216. package/static/app/assets/Library-BVtrSt12.js +0 -1
  217. package/static/app/assets/LivingBrain-7glOmK0R.js +0 -1
  218. package/static/app/assets/ReviewCard-CShhHS-w.js +0 -3
  219. package/static/app/assets/System-B4nQDy8a.js +0 -1
  220. package/static/app/assets/brain-wVV63_1z.js +0 -1
  221. package/static/app/assets/index-BFtDYI6l.js +0 -10
  222. package/static/app/assets/index-BY9bpxyx.css +0 -2
  223. package/static/app/assets/input-DEz5UjBz.js +0 -1
  224. package/static/app/assets/primitives-jNrZm2O9.js +0 -1
  225. package/static/app/assets/search-ku6-_6wY.js +0 -1
  226. package/static/app/assets/textarea-DpcQkn75.js +0 -1
  227. package/static/app/assets/utils-DYf7pqrR.js +0 -7
  228. package/static/app/assets/workspace-CjthHsLN.js +0 -1
@@ -0,0 +1,265 @@
1
+ """Hybrid chat turn: minimal KG context → cloud stream → local KG expansion.
2
+
3
+ Phase 1–2 entry point used by the chat path when NetworkBoundaryMode is
4
+ CLOUD_ALLOWED. Local-only sessions never enter this module's cloud path.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ from typing import Any, AsyncIterator, Dict, Optional
12
+
13
+ from latticeai.core.network_boundary import (
14
+ NetworkBoundaryMode,
15
+ normalize_network_mode,
16
+ )
17
+ from latticeai.services.cloud_egress_audit import record_cloud_egress
18
+ from latticeai.services.cloud_extraction import plan_kg_expansion_rich
19
+ from latticeai.services.cloud_streaming import (
20
+ CloudResponseIngestor,
21
+ CloudStreamingBridge,
22
+ CloudTurnResult,
23
+ )
24
+ from latticeai.services.cloud_token_guard import budget_for
25
+ from latticeai.services.hybrid_context import MinimalContext, build_minimal_context
26
+ from latticeai.services.openai_compatible_adapter import OpenAICompatibleAdapter
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def _sse(data: Dict[str, Any]) -> str:
32
+ return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
33
+
34
+
35
+ def _scope_key(user_email: Optional[str], workspace_id: Optional[str]) -> str:
36
+ return f"{user_email or 'anon'}|{workspace_id or 'global'}"
37
+
38
+
39
+ async def run_hybrid_cloud_turn(
40
+ *,
41
+ user_message: str,
42
+ knowledge_graph: Any,
43
+ mode: NetworkBoundaryMode | str,
44
+ workspace_id: Optional[str] = None,
45
+ user_email: Optional[str] = None,
46
+ model: Optional[str] = None,
47
+ top_k: int = 6,
48
+ adapter: Optional[Any] = None,
49
+ ) -> CloudTurnResult:
50
+ """Non-streaming helper: full answer + expansion plan."""
51
+ mode = normalize_network_mode(mode)
52
+ if mode != NetworkBoundaryMode.CLOUD_ALLOWED:
53
+ raise PermissionError(
54
+ f"hybrid cloud turn refused under NetworkBoundaryMode={mode.value!r}"
55
+ )
56
+
57
+ minimal = build_minimal_context(
58
+ user_message,
59
+ store=knowledge_graph,
60
+ mode=mode,
61
+ top_k=top_k,
62
+ allowed_workspaces={workspace_id} if workspace_id else None,
63
+ )
64
+ budget = budget_for(_scope_key(user_email, workspace_id))
65
+ refusal = budget.check_turn(minimal.token_estimate)
66
+ if refusal:
67
+ record_cloud_egress(
68
+ node_ids=minimal.node_ids, token_estimate=minimal.token_estimate,
69
+ mode=mode.value, provider="(refused)", model=model,
70
+ user_email=user_email, workspace_id=workspace_id,
71
+ outcome="refused_token_guard", detail=refusal,
72
+ )
73
+ raise PermissionError(f"cloud token guard: {refusal}")
74
+
75
+ chosen_adapter = adapter or OpenAICompatibleAdapter()
76
+ record_cloud_egress(
77
+ node_ids=minimal.node_ids, token_estimate=minimal.token_estimate,
78
+ mode=mode.value, provider=getattr(chosen_adapter, "name", type(chosen_adapter).__name__),
79
+ model=model, user_email=user_email, workspace_id=workspace_id,
80
+ )
81
+ bridge = CloudStreamingBridge(adapter=chosen_adapter)
82
+ result = await bridge.run_turn(
83
+ user_message=user_message,
84
+ minimal=minimal,
85
+ mode=mode,
86
+ model=model,
87
+ )
88
+ plan = plan_kg_expansion_rich(result)
89
+ ingestor = CloudResponseIngestor(store=knowledge_graph)
90
+ ingest_status = ingestor.ingest(plan)
91
+ used = minimal.token_estimate + max(1, len(result.answer_text) // 4)
92
+ budget.record(used)
93
+ result.usage = {
94
+ **(result.usage or {}),
95
+ "minimal_nodes": len(minimal.node_ids),
96
+ "token_estimate": minimal.token_estimate,
97
+ "context_quality": minimal.quality,
98
+ "kg_expansion": ingest_status,
99
+ "token_budget": budget.snapshot(),
100
+ }
101
+ return result
102
+
103
+
104
+ async def stream_hybrid_cloud_turn(
105
+ *,
106
+ user_message: str,
107
+ knowledge_graph: Any,
108
+ mode: NetworkBoundaryMode | str,
109
+ workspace_id: Optional[str] = None,
110
+ user_email: Optional[str] = None,
111
+ model: Optional[str] = None,
112
+ top_k: int = 6,
113
+ adapter: Optional[Any] = None,
114
+ chat_service: Any = None,
115
+ history_meta: Optional[Dict[str, Any]] = None,
116
+ history_user: Optional[Dict[str, Any]] = None,
117
+ notify: Any = None,
118
+ source: Optional[str] = None,
119
+ ) -> AsyncIterator[str]:
120
+ """SSE generator for a hybrid cloud turn (Phase 2 chat path).
121
+
122
+ Events:
123
+ * ``hybrid_context`` — which local nodes were selected
124
+ * ``token`` / classic ``chunk`` — streamed text
125
+ * ``hybrid_done`` — final answer + KG expansion status
126
+ * ``error`` — honest failure
127
+ """
128
+ mode = normalize_network_mode(mode)
129
+ if mode != NetworkBoundaryMode.CLOUD_ALLOWED:
130
+ yield _sse(
131
+ {
132
+ "type": "error",
133
+ "detail": f"NetworkBoundaryMode is {mode.value!r}; cloud path disabled",
134
+ }
135
+ )
136
+ yield "data: [DONE]\n\n"
137
+ return
138
+
139
+ minimal = build_minimal_context(
140
+ user_message,
141
+ store=knowledge_graph,
142
+ mode=mode,
143
+ top_k=top_k,
144
+ allowed_workspaces={workspace_id} if workspace_id else None,
145
+ )
146
+ budget = budget_for(_scope_key(user_email, workspace_id))
147
+ refusal = budget.check_turn(minimal.token_estimate)
148
+ if refusal:
149
+ # A refusal is auditable too: "nothing left the machine, and here is why".
150
+ record_cloud_egress(
151
+ node_ids=minimal.node_ids, token_estimate=minimal.token_estimate,
152
+ mode=mode.value, provider="(refused)", model=model,
153
+ user_email=user_email, workspace_id=workspace_id,
154
+ outcome="refused_token_guard", detail=refusal,
155
+ )
156
+ yield _sse({"type": "error", "detail": f"cloud token guard: {refusal}"})
157
+ yield "data: [DONE]\n\n"
158
+ return
159
+
160
+ yield _sse(
161
+ {
162
+ "type": "hybrid_context",
163
+ "node_ids": minimal.node_ids,
164
+ "keywords": minimal.keywords,
165
+ "token_estimate": minimal.token_estimate,
166
+ "quality": minimal.quality,
167
+ "titles": [str(n.get("title") or n.get("id") or "") for n in minimal.nodes],
168
+ "token_budget": budget.snapshot(),
169
+ }
170
+ )
171
+
172
+ chosen_adapter = adapter or OpenAICompatibleAdapter()
173
+ bridge = CloudStreamingBridge(adapter=chosen_adapter)
174
+
175
+ # Recorded before the call, not after: if the provider hangs or the process
176
+ # dies mid-stream, the record of what was about to leave must already exist.
177
+ record_cloud_egress(
178
+ node_ids=minimal.node_ids, token_estimate=minimal.token_estimate,
179
+ mode=mode.value, provider=getattr(chosen_adapter, "name", type(chosen_adapter).__name__),
180
+ model=model, user_email=user_email, workspace_id=workspace_id,
181
+ )
182
+
183
+ try:
184
+ chunks: list[str] = []
185
+ if chosen_adapter is not None and hasattr(chosen_adapter, "stream"):
186
+ system = (
187
+ "You are assisting a user whose private Knowledge Graph lives on their machine. "
188
+ "Use only the provided context. If the context is insufficient, say so honestly."
189
+ )
190
+ async for piece in chosen_adapter.stream(
191
+ system=system,
192
+ user=user_message,
193
+ context=minimal.compact_text,
194
+ model=model,
195
+ ):
196
+ chunks.append(piece)
197
+ # dual shape: hybrid token + classic chunk for existing clients
198
+ yield _sse({"type": "token", "text": piece, "chunk": piece, "model": model})
199
+ answer = "".join(chunks)
200
+ result = CloudTurnResult(
201
+ user_message=user_message,
202
+ answer_text=answer,
203
+ sent_node_ids=list(minimal.node_ids),
204
+ provider=getattr(chosen_adapter, "provider_name", "cloud"),
205
+ model=model or getattr(chosen_adapter, "default_model", ""),
206
+ )
207
+ else:
208
+ result = await bridge.run_turn(
209
+ user_message=user_message,
210
+ minimal=minimal,
211
+ mode=mode,
212
+ model=model,
213
+ )
214
+ yield _sse(
215
+ {
216
+ "type": "token",
217
+ "text": result.answer_text,
218
+ "chunk": result.answer_text,
219
+ "model": model,
220
+ }
221
+ )
222
+
223
+ plan = plan_kg_expansion_rich(result)
224
+ ingest_status = CloudResponseIngestor(store=knowledge_graph).ingest(plan)
225
+ used = minimal.token_estimate + max(1, len(result.answer_text) // 4)
226
+ budget.record(used)
227
+
228
+ if chat_service is not None:
229
+ try:
230
+ await chat_service.persist_entry(
231
+ "assistant",
232
+ result.answer_text,
233
+ history_meta=history_meta or {},
234
+ history_user=history_user or {},
235
+ )
236
+ if notify is not None:
237
+ notify("assistant", result.answer_text, source)
238
+ except Exception as exc: # noqa: BLE001
239
+ logger.warning("hybrid chat persistence failed: %s", exc)
240
+
241
+ yield _sse(
242
+ {
243
+ "type": "hybrid_done",
244
+ "chunk": "",
245
+ "answer": result.answer_text,
246
+ "sent_node_ids": result.sent_node_ids,
247
+ "provider": result.provider,
248
+ "model": result.model,
249
+ "kg_expansion": ingest_status,
250
+ "token_estimate": minimal.token_estimate,
251
+ "token_budget": budget.snapshot(),
252
+ }
253
+ )
254
+ yield "data: [DONE]\n\n"
255
+ except Exception as exc: # noqa: BLE001 — surface honest error to client
256
+ logger.warning("hybrid cloud turn failed: %s", exc)
257
+ yield _sse({"type": "error", "detail": str(exc), "error": str(exc)})
258
+ yield "data: [DONE]\n\n"
259
+
260
+
261
+ __all__ = [
262
+ "run_hybrid_cloud_turn",
263
+ "stream_hybrid_cloud_turn",
264
+ "MinimalContext",
265
+ ]
@@ -0,0 +1,228 @@
1
+ """Minimal context extraction for hybrid cloud LLM turns.
2
+
3
+ Given a user message and a local Knowledge Graph store, select only the
4
+ smallest useful set of related nodes, assemble a compact text payload, and
5
+ estimate token cost. This is the only knowledge that is ever allowed to leave
6
+ the machine when NetworkBoundaryMode is CLOUD_ALLOWED.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Dict, List, Optional, Protocol, Sequence
14
+
15
+ from latticeai.core.network_boundary import (
16
+ NetworkBoundaryMode,
17
+ is_node_blocked_for_cloud,
18
+ normalize_network_mode,
19
+ )
20
+ from latticeai.core.security import redact_secret_text
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @dataclass
26
+ class MinimalContext:
27
+ """What may be sent to a cloud LLM."""
28
+
29
+ query: str
30
+ keywords: List[str] = field(default_factory=list)
31
+ node_ids: List[str] = field(default_factory=list)
32
+ compact_text: str = ""
33
+ nodes: List[Dict[str, Any]] = field(default_factory=list)
34
+ token_estimate: int = 0
35
+ quality: Dict[str, Any] = field(default_factory=dict)
36
+
37
+ def to_dict(self) -> Dict[str, Any]:
38
+ return {
39
+ "query": self.query,
40
+ "keywords": list(self.keywords),
41
+ "node_ids": list(self.node_ids),
42
+ "compact_text": self.compact_text,
43
+ "nodes": list(self.nodes),
44
+ "token_estimate": self.token_estimate,
45
+ "quality": dict(self.quality),
46
+ }
47
+
48
+
49
+ class SupportsHybridSearch(Protocol):
50
+ """Minimal surface required from a KnowledgeGraph store."""
51
+
52
+ def hybrid_search(
53
+ self,
54
+ query: str,
55
+ *,
56
+ top_k: int = 20,
57
+ allowed_workspaces: Any = None,
58
+ include_legacy_global: bool = False,
59
+ ) -> Dict[str, Any]:
60
+ ...
61
+
62
+ def context_for_query_with_meta(
63
+ self,
64
+ query: str,
65
+ limit: int = 6,
66
+ *,
67
+ allowed_workspaces: Any = None,
68
+ include_legacy_global: bool = False,
69
+ use_hybrid: bool = True,
70
+ ) -> Dict[str, Any]:
71
+ ...
72
+
73
+
74
+ def _rough_token_estimate(text: str) -> int:
75
+ """Cheap heuristic: ~4 characters per token for mixed EN/KO."""
76
+ if not text:
77
+ return 0
78
+ return max(1, (len(text) + 3) // 4)
79
+
80
+
81
+ def _extract_keywords(message: str, limit: int = 12) -> List[str]:
82
+ """Very lightweight keyword / entity candidates.
83
+
84
+ Real extraction can later be replaced by a small local model or the
85
+ existing topic candidate helpers inside lattice_brain. For scaffolding we
86
+ keep this deterministic and dependency-free.
87
+ """
88
+ import re
89
+
90
+ text = str(message or "").strip()
91
+ if not text:
92
+ return []
93
+ # Split on whitespace and common punctuation; keep tokens of length >= 2.
94
+ tokens = re.findall(r"[\w가-힣]{2,}", text, flags=re.UNICODE)
95
+ seen: set[str] = set()
96
+ out: List[str] = []
97
+ for tok in tokens:
98
+ key = tok.lower()
99
+ if key in seen:
100
+ continue
101
+ seen.add(key)
102
+ out.append(tok)
103
+ if len(out) >= limit:
104
+ break
105
+ return out
106
+
107
+
108
+ def build_minimal_context(
109
+ message: str,
110
+ *,
111
+ store: Optional[SupportsHybridSearch] = None,
112
+ mode: NetworkBoundaryMode | str = NetworkBoundaryMode.LOCAL_ONLY,
113
+ top_k: int = 6,
114
+ allowed_workspaces: Any = None,
115
+ include_legacy_global: bool = False,
116
+ preferred_types: Optional[Sequence[str]] = None,
117
+ ) -> MinimalContext:
118
+ """Select the smallest useful set of local nodes for a cloud turn.
119
+
120
+ When mode is LOCAL_ONLY this still returns a MinimalContext (for local
121
+ RAG) but callers must not transmit it.
122
+ """
123
+ mode = normalize_network_mode(mode)
124
+ query = str(message or "").strip()
125
+ keywords = _extract_keywords(query)
126
+
127
+ empty = MinimalContext(
128
+ query=query,
129
+ keywords=keywords,
130
+ quality={"mode": "none", "nodes": 0, "limited": True, "reason": "no store or empty query"},
131
+ )
132
+ if not query or store is None:
133
+ return empty
134
+
135
+ preferred = set(
136
+ preferred_types
137
+ or (
138
+ "Decision",
139
+ "Concept",
140
+ "Task",
141
+ "Document",
142
+ "File",
143
+ "CodeFile",
144
+ "Person",
145
+ "Feature",
146
+ )
147
+ )
148
+
149
+ # One retrieval, not two. This used to call
150
+ # ``context_for_query_with_meta`` first — a full retrieval — and throw away
151
+ # everything but its ``quality`` dict, then run ``hybrid_search`` for the
152
+ # matches it actually needed. Every cloud turn paid for retrieval twice.
153
+ # ``context_quality_signal`` derives the same signal from the match list we
154
+ # already have.
155
+ matches: List[Dict[str, Any]] = []
156
+ quality: Dict[str, Any] = {}
157
+
158
+ try:
159
+ hybrid = store.hybrid_search(
160
+ query,
161
+ top_k=max(top_k * 2, top_k),
162
+ allowed_workspaces=allowed_workspaces,
163
+ include_legacy_global=include_legacy_global,
164
+ )
165
+ matches = list(hybrid.get("matches") or [])
166
+ from lattice_brain.graph.retrieval import context_quality_signal
167
+
168
+ quality = context_quality_signal(
169
+ str(hybrid.get("mode") or "hybrid"),
170
+ len(matches),
171
+ )
172
+ except Exception: # noqa: BLE001
173
+ # Fail closed: an unusable retrieval sends nothing rather than
174
+ # falling back to a broader, unfiltered context.
175
+ logger.warning("hybrid context retrieval failed; sending no context", exc_info=True)
176
+ matches = []
177
+
178
+ # Filter blocked + prefer useful types, then cut to top_k.
179
+ selected: List[Dict[str, Any]] = []
180
+ for match in matches:
181
+ if is_node_blocked_for_cloud(match):
182
+ continue
183
+ # Soft preference: keep preferred types first by stable partition later.
184
+ selected.append(match)
185
+
186
+ selected.sort(
187
+ key=lambda m: (
188
+ 0 if str(m.get("type") or "") in preferred else 1,
189
+ -float(m.get("score") or 0.0),
190
+ str(m.get("node_id") or m.get("id") or ""),
191
+ )
192
+ )
193
+ selected = selected[: max(1, min(int(top_k), 12))]
194
+
195
+ lines: List[str] = []
196
+ node_ids: List[str] = []
197
+ for match in selected:
198
+ nid = str(match.get("node_id") or match.get("id") or "")
199
+ if not nid:
200
+ continue
201
+ node_ids.append(nid)
202
+ title = str(match.get("title") or nid)
203
+ summary = str(match.get("summary") or "")[:400]
204
+ ntype = str(match.get("type") or "Node")
205
+ lines.append(f"- [{ntype}] {title}: {summary}".rstrip(": "))
206
+
207
+ # Last gate before the text can leave the machine. `redact_secret_text` was
208
+ # already applied to logs, audit records, and API previews — everywhere
209
+ # except the one path where bytes actually go to a third party. A token
210
+ # pasted into a note is not marked sensitive by anyone, so pattern
211
+ # redaction is the only thing standing between it and the provider.
212
+ compact = redact_secret_text("\n".join(lines))
213
+ return MinimalContext(
214
+ query=query,
215
+ keywords=keywords,
216
+ node_ids=node_ids,
217
+ compact_text=compact,
218
+ nodes=selected,
219
+ token_estimate=_rough_token_estimate(compact),
220
+ quality=quality or {"mode": "none", "nodes": len(selected), "limited": len(selected) <= 1},
221
+ )
222
+
223
+
224
+ __all__ = [
225
+ "MinimalContext",
226
+ "SupportsHybridSearch",
227
+ "build_minimal_context",
228
+ ]
@@ -0,0 +1,178 @@
1
+ """User-configurable hybrid policy (Phase 3).
2
+
3
+ Controls:
4
+
5
+ * which node types / metadata flags are blocked from leaving the machine
6
+ * whether cloud-derived KG expansion auto-commits or stages for review
7
+ * whether multimodal (video) cloud calls are allowed when cloud mode is on
8
+
9
+ Persisted under the data dir, scoped like NetworkBoundaryService
10
+ (workspace → user → default).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import threading
17
+ from pathlib import Path
18
+ from typing import Any, Callable, Dict, List, Optional, Set
19
+
20
+ from latticeai.core.io_utils import atomic_write_json
21
+ from latticeai.core.network_boundary import (
22
+ HARD_BLOCK_METADATA_FLAGS,
23
+ HARD_BLOCK_NODE_TYPES,
24
+ )
25
+
26
+ DEFAULT_BLOCKED_TYPES: List[str] = []
27
+ DEFAULT_BLOCKED_FLAGS: List[str] = sorted(HARD_BLOCK_METADATA_FLAGS)
28
+ DEFAULT_AUTO_COMMIT = False
29
+ DEFAULT_ALLOW_MULTIMODAL = False
30
+
31
+
32
+ def _default_policy() -> Dict[str, Any]:
33
+ return {
34
+ "blocked_node_types": list(DEFAULT_BLOCKED_TYPES),
35
+ "blocked_metadata_flags": list(DEFAULT_BLOCKED_FLAGS),
36
+ "auto_commit": DEFAULT_AUTO_COMMIT,
37
+ "allow_multimodal": DEFAULT_ALLOW_MULTIMODAL,
38
+ "min_extraction_confidence": 0.55,
39
+ }
40
+
41
+
42
+ class HybridPolicyService:
43
+ """Load / save hybrid cloud policy."""
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ data_dir: Path,
49
+ audit: Optional[Callable[..., None]] = None,
50
+ ) -> None:
51
+ self._path = Path(data_dir) / "hybrid_policy.json"
52
+ self._audit = audit or (lambda *a, **kw: None)
53
+ self._lock = threading.Lock()
54
+
55
+ def rebind_data_dir(self, data_dir: Path) -> None:
56
+ with self._lock:
57
+ self._path = Path(data_dir) / "hybrid_policy.json"
58
+
59
+ def rebind_audit(self, audit: Callable[..., None]) -> None:
60
+ with self._lock:
61
+ self._audit = audit
62
+
63
+ def _read(self) -> Dict[str, Any]:
64
+ base = {
65
+ "default": _default_policy(),
66
+ "users": {},
67
+ "workspaces": {},
68
+ }
69
+ if not self._path.exists():
70
+ return base
71
+ try:
72
+ data = json.loads(self._path.read_text(encoding="utf-8"))
73
+ except Exception:
74
+ return base
75
+ if not isinstance(data, dict):
76
+ return base
77
+ data.setdefault("default", _default_policy())
78
+ data.setdefault("users", {})
79
+ data.setdefault("workspaces", {})
80
+ return data
81
+
82
+ def _write(self, data: Dict[str, Any]) -> None:
83
+ self._path.parent.mkdir(parents=True, exist_ok=True)
84
+ atomic_write_json(self._path, data)
85
+
86
+ def _resolve_raw(
87
+ self,
88
+ data: Dict[str, Any],
89
+ *,
90
+ user_email: Optional[str],
91
+ workspace_id: Optional[str],
92
+ ) -> Dict[str, Any]:
93
+ policy = dict(data.get("default") or _default_policy())
94
+ if user_email:
95
+ user = (data.get("users") or {}).get(str(user_email).lower())
96
+ if isinstance(user, dict):
97
+ policy.update(user)
98
+ if workspace_id:
99
+ ws = (data.get("workspaces") or {}).get(str(workspace_id))
100
+ if isinstance(ws, dict):
101
+ policy.update(ws)
102
+ # Always union hard-coded circuit breakers.
103
+ blocked_types: Set[str] = set(policy.get("blocked_node_types") or [])
104
+ blocked_types |= set(HARD_BLOCK_NODE_TYPES)
105
+ blocked_flags: Set[str] = set(policy.get("blocked_metadata_flags") or [])
106
+ blocked_flags |= set(HARD_BLOCK_METADATA_FLAGS)
107
+ policy["blocked_node_types"] = sorted(blocked_types)
108
+ policy["blocked_metadata_flags"] = sorted(blocked_flags)
109
+ policy["auto_commit"] = bool(policy.get("auto_commit", False))
110
+ policy["allow_multimodal"] = bool(policy.get("allow_multimodal", False))
111
+ try:
112
+ policy["min_extraction_confidence"] = float(
113
+ policy.get("min_extraction_confidence", 0.55)
114
+ )
115
+ except (TypeError, ValueError):
116
+ policy["min_extraction_confidence"] = 0.55
117
+ return policy
118
+
119
+ def resolve(
120
+ self,
121
+ *,
122
+ user_email: Optional[str] = None,
123
+ workspace_id: Optional[str] = None,
124
+ ) -> Dict[str, Any]:
125
+ with self._lock:
126
+ data = self._read()
127
+ return self._resolve_raw(data, user_email=user_email, workspace_id=workspace_id)
128
+
129
+ def set_policy(
130
+ self,
131
+ patch: Dict[str, Any],
132
+ *,
133
+ user_email: Optional[str] = None,
134
+ workspace_id: Optional[str] = None,
135
+ source: str = "api",
136
+ ) -> Dict[str, Any]:
137
+ allowed = {
138
+ "blocked_node_types",
139
+ "blocked_metadata_flags",
140
+ "auto_commit",
141
+ "allow_multimodal",
142
+ "min_extraction_confidence",
143
+ }
144
+ clean = {k: v for k, v in (patch or {}).items() if k in allowed}
145
+ with self._lock:
146
+ data = self._read()
147
+ previous = self._resolve_raw(
148
+ data, user_email=user_email, workspace_id=workspace_id
149
+ )
150
+ if workspace_id:
151
+ bucket = data.setdefault("workspaces", {})
152
+ current = dict(bucket.get(str(workspace_id)) or {})
153
+ current.update(clean)
154
+ bucket[str(workspace_id)] = current
155
+ elif user_email:
156
+ bucket = data.setdefault("users", {})
157
+ key = str(user_email).lower()
158
+ current = dict(bucket.get(key) or {})
159
+ current.update(clean)
160
+ bucket[key] = current
161
+ else:
162
+ current = dict(data.get("default") or _default_policy())
163
+ current.update(clean)
164
+ data["default"] = current
165
+ self._write(data)
166
+ resolved = self.resolve(user_email=user_email, workspace_id=workspace_id)
167
+ self._audit(
168
+ "hybrid_policy_changed",
169
+ user_email=user_email,
170
+ workspace_id=workspace_id,
171
+ previous=previous,
172
+ policy=resolved,
173
+ source=source,
174
+ )
175
+ return resolved
176
+
177
+
178
+ __all__ = ["HybridPolicyService", "DEFAULT_AUTO_COMMIT", "DEFAULT_ALLOW_MULTIMODAL"]
@@ -26,8 +26,8 @@ import logging
26
26
  from pathlib import Path
27
27
  from typing import Any, Dict, List, Optional
28
28
 
29
- from latticeai.core.workspace_os_utils import _file_size
30
29
  from latticeai.core.timeutil import now_iso as _now
30
+ from latticeai.core.workspace_os_utils import _file_size
31
31
 
32
32
  # Personal workspace memory kinds (from WorkspaceOS.MEMORY_KINDS).
33
33
  WORKSPACE_KINDS = (