ltcai 10.0.0 → 10.3.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 (214) hide show
  1. package/README.md +43 -32
  2. package/docs/CHANGELOG.md +204 -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/MYPY_BACKLOG.md +98 -0
  7. package/docs/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.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 +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/archive.py +5 -4
  14. package/lattice_brain/conversations.py +14 -3
  15. package/lattice_brain/embeddings.py +12 -2
  16. package/lattice_brain/graph/_kg_common.py +5 -5
  17. package/lattice_brain/graph/_kg_fsutil.py +5 -4
  18. package/lattice_brain/graph/discovery.py +4 -3
  19. package/lattice_brain/graph/discovery_index.py +0 -1
  20. package/lattice_brain/graph/documents.py +3 -2
  21. package/lattice_brain/graph/fusion.py +4 -0
  22. package/lattice_brain/graph/ingest.py +12 -2
  23. package/lattice_brain/graph/projection.py +3 -2
  24. package/lattice_brain/graph/provenance.py +5 -3
  25. package/lattice_brain/graph/rerank.py +4 -1
  26. package/lattice_brain/graph/retrieval.py +0 -1
  27. package/lattice_brain/graph/retrieval_docgen.py +0 -1
  28. package/lattice_brain/graph/retrieval_reads.py +0 -1
  29. package/lattice_brain/graph/retrieval_vector.py +0 -1
  30. package/lattice_brain/graph/schema.py +4 -3
  31. package/lattice_brain/graph/store.py +18 -4
  32. package/lattice_brain/graph/write_master.py +46 -1
  33. package/lattice_brain/ingestion.py +4 -1
  34. package/lattice_brain/portability.py +5 -2
  35. package/lattice_brain/quality.py +12 -5
  36. package/lattice_brain/quiet.py +43 -0
  37. package/lattice_brain/runtime/agent_runtime.py +19 -10
  38. package/lattice_brain/runtime/hooks.py +3 -2
  39. package/lattice_brain/runtime/multi_agent.py +2 -3
  40. package/lattice_brain/sensitivity.py +94 -0
  41. package/lattice_brain/storage/base.py +30 -2
  42. package/lattice_brain/storage/migration.py +3 -2
  43. package/lattice_brain/storage/postgres.py +2 -2
  44. package/lattice_brain/storage/sqlite.py +6 -4
  45. package/lattice_brain/workflow.py +4 -2
  46. package/latticeai/__init__.py +1 -1
  47. package/latticeai/api/admin.py +1 -1
  48. package/latticeai/api/agents.py +4 -2
  49. package/latticeai/api/auth.py +5 -1
  50. package/latticeai/api/automation_intelligence.py +2 -1
  51. package/latticeai/api/browser.py +3 -2
  52. package/latticeai/api/chat.py +28 -17
  53. package/latticeai/api/chat_agent_http.py +22 -8
  54. package/latticeai/api/chat_contracts.py +4 -0
  55. package/latticeai/api/chat_documents.py +6 -2
  56. package/latticeai/api/chat_hybrid.py +82 -0
  57. package/latticeai/api/computer_use.py +8 -3
  58. package/latticeai/api/knowledge_graph.py +1 -1
  59. package/latticeai/api/mcp.py +4 -4
  60. package/latticeai/api/models.py +5 -2
  61. package/latticeai/api/network_boundary.py +220 -0
  62. package/latticeai/api/permissions.py +0 -1
  63. package/latticeai/api/realtime.py +1 -1
  64. package/latticeai/api/security_dashboard.py +1 -1
  65. package/latticeai/api/setup.py +16 -3
  66. package/latticeai/api/static_routes.py +2 -1
  67. package/latticeai/api/tools.py +12 -8
  68. package/latticeai/api/voice_capture.py +3 -1
  69. package/latticeai/api/workflow_designer.py +1 -1
  70. package/latticeai/api/workspace.py +1 -2
  71. package/latticeai/app_factory.py +174 -145
  72. package/latticeai/cli/entrypoint.py +6 -4
  73. package/latticeai/core/agent.py +55 -495
  74. package/latticeai/core/agent_eval.py +2 -2
  75. package/latticeai/core/agent_helpers.py +493 -0
  76. package/latticeai/core/agent_prompts.py +0 -1
  77. package/latticeai/core/agent_registry.py +3 -1
  78. package/latticeai/core/agent_state.py +41 -0
  79. package/latticeai/core/audit.py +1 -1
  80. package/latticeai/core/builtin_hooks.py +2 -1
  81. package/latticeai/core/config.py +0 -1
  82. package/latticeai/core/embedding_providers.py +12 -1
  83. package/latticeai/core/file_generation.py +3 -0
  84. package/latticeai/core/invitations.py +4 -1
  85. package/latticeai/core/io_utils.py +3 -1
  86. package/latticeai/core/legacy_compatibility.py +1 -2
  87. package/latticeai/core/local_embeddings.py +12 -1
  88. package/latticeai/core/marketplace.py +1 -2
  89. package/latticeai/core/mcp_registry.py +0 -1
  90. package/latticeai/core/model_compat.py +1 -1
  91. package/latticeai/core/model_resolution.py +1 -1
  92. package/latticeai/core/network_boundary.py +168 -0
  93. package/latticeai/core/oidc.py +3 -0
  94. package/latticeai/core/permission_mode.py +6 -6
  95. package/latticeai/core/plugins.py +3 -2
  96. package/latticeai/core/policy.py +0 -1
  97. package/latticeai/core/quiet.py +84 -0
  98. package/latticeai/core/realtime.py +3 -2
  99. package/latticeai/core/run_store.py +4 -2
  100. package/latticeai/core/security.py +4 -0
  101. package/latticeai/core/users.py +5 -3
  102. package/latticeai/core/workspace_graph_trace.py +3 -0
  103. package/latticeai/core/workspace_os.py +65 -273
  104. package/latticeai/core/workspace_os_constants.py +126 -0
  105. package/latticeai/core/workspace_os_state.py +180 -0
  106. package/latticeai/core/workspace_os_utils.py +3 -1
  107. package/latticeai/core/workspace_permissions.py +1 -0
  108. package/latticeai/core/workspace_snapshots.py +3 -1
  109. package/latticeai/core/workspace_timeline.py +3 -1
  110. package/latticeai/integrations/telegram_bot.py +25 -16
  111. package/latticeai/models/router.py +6 -3
  112. package/latticeai/runtime/access_runtime.py +3 -1
  113. package/latticeai/runtime/audit_runtime.py +3 -2
  114. package/latticeai/runtime/chat_wiring.py +4 -1
  115. package/latticeai/runtime/history_runtime.py +1 -1
  116. package/latticeai/runtime/history_writer.py +138 -0
  117. package/latticeai/runtime/lifespan_runtime.py +3 -1
  118. package/latticeai/runtime/network_boundary_wiring.py +124 -0
  119. package/latticeai/runtime/persistence_runtime.py +3 -1
  120. package/latticeai/runtime/router_registration.py +11 -1
  121. package/latticeai/services/architecture_readiness.py +1 -2
  122. package/latticeai/services/change_proposals.py +2 -1
  123. package/latticeai/services/cloud_egress_audit.py +85 -0
  124. package/latticeai/services/cloud_extraction.py +129 -0
  125. package/latticeai/services/cloud_streaming.py +266 -0
  126. package/latticeai/services/cloud_token_guard.py +84 -0
  127. package/latticeai/services/command_center.py +2 -1
  128. package/latticeai/services/folder_watch.py +3 -0
  129. package/latticeai/services/funnel_metrics.py +3 -1
  130. package/latticeai/services/hybrid_chat.py +265 -0
  131. package/latticeai/services/hybrid_context.py +228 -0
  132. package/latticeai/services/hybrid_policy.py +178 -0
  133. package/latticeai/services/memory_service.py +1 -1
  134. package/latticeai/services/model_catalog.py +10 -1
  135. package/latticeai/services/model_engines.py +35 -14
  136. package/latticeai/services/model_loading.py +3 -2
  137. package/latticeai/services/model_runtime.py +68 -17
  138. package/latticeai/services/multimodal_streaming.py +123 -0
  139. package/latticeai/services/network_boundary_service.py +154 -0
  140. package/latticeai/services/openai_compatible_adapter.py +100 -0
  141. package/latticeai/services/p_reinforce.py +4 -0
  142. package/latticeai/services/platform_runtime.py +9 -4
  143. package/latticeai/services/process_audit.py +0 -1
  144. package/latticeai/services/product_readiness.py +1 -1
  145. package/latticeai/services/run_executor.py +3 -2
  146. package/latticeai/services/search_service.py +1 -4
  147. package/latticeai/services/setup_detection.py +2 -1
  148. package/latticeai/services/tool_dispatch.py +7 -2
  149. package/latticeai/services/upload_service.py +2 -1
  150. package/latticeai/setup/auto_setup.py +10 -7
  151. package/latticeai/setup/wizard.py +15 -11
  152. package/latticeai/tools/__init__.py +3 -3
  153. package/latticeai/tools/commands.py +7 -7
  154. package/latticeai/tools/computer.py +3 -2
  155. package/latticeai/tools/documents.py +10 -9
  156. package/latticeai/tools/filesystem.py +7 -4
  157. package/latticeai/tools/knowledge.py +2 -0
  158. package/latticeai/tools/local_files.py +1 -1
  159. package/latticeai/tools/network.py +2 -1
  160. package/package.json +7 -3
  161. package/scripts/bench_agent_smoke.py +0 -1
  162. package/scripts/bench_models.py +0 -1
  163. package/scripts/brain_quality_eval.py +7 -2
  164. package/scripts/bump_version.py +2 -1
  165. package/scripts/check_current_release_docs.mjs +1 -1
  166. package/scripts/migrate_brain_storage.py +5 -1
  167. package/scripts/profile_kg.py +2 -7
  168. package/scripts/verify_hf_model_registry.py +2 -2
  169. package/src-tauri/Cargo.lock +1 -1
  170. package/src-tauri/Cargo.toml +1 -1
  171. package/src-tauri/tauri.conf.json +1 -1
  172. package/static/app/asset-manifest.json +37 -37
  173. package/static/app/assets/{Act-BtCREeN1.js → Act-DrKIGZeJ.js} +1 -1
  174. package/static/app/assets/{AdminConsole-TPeeN18T.js → AdminConsole-CEV1VefA.js} +1 -1
  175. package/static/app/assets/{Brain-BKs6JAp0.js → Brain-BZSXnwWU.js} +1 -1
  176. package/static/app/assets/{BrainHome-BPGOvSd6.js → BrainHome-CEBd86k6.js} +1 -1
  177. package/static/app/assets/{BrainSignals-CtzQZ15J.js → BrainSignals-O4s6R7av.js} +1 -1
  178. package/static/app/assets/{Capture-1_NaHWqB.js → Capture-AGbOxsg-.js} +1 -1
  179. package/static/app/assets/{CommandPalette-pqvQOXe4.js → CommandPalette-CeqBjCn2.js} +1 -1
  180. package/static/app/assets/Library-CXnbNiQW.js +1 -0
  181. package/static/app/assets/{LivingBrain-DlQ20Q75.js → LivingBrain-D9Iei2ZJ.js} +1 -1
  182. package/static/app/assets/{ProductFlow-BZvGDRi_.js → ProductFlow-JlXiDfMM.js} +1 -1
  183. package/static/app/assets/{ReviewCard-BWgI0D2s.js → ReviewCard-QQly2bTV.js} +1 -1
  184. package/static/app/assets/System-Bp9ZY4ER.js +1 -0
  185. package/static/app/assets/{activity-Dlfk8YC7.js → activity-CxRPOQ2F.js} +1 -1
  186. package/static/app/assets/{bot-CDvUB76P.js → bot-1bcsWB2s.js} +1 -1
  187. package/static/app/assets/{brain-xczrohrt.js → brain-CxaeKCRM.js} +1 -1
  188. package/static/app/assets/{button-SOdH3Oyf.js → button-ZYFqGan7.js} +1 -1
  189. package/static/app/assets/{circle-pause-CG1ythH4.js → circle-pause-B5NOIqvk.js} +1 -1
  190. package/static/app/assets/{circle-play-HXwvjS6W.js → circle-play-BiJVFXf7.js} +1 -1
  191. package/static/app/assets/{cpu-B0d-rGyk.js → cpu-Vvq0OKTr.js} +1 -1
  192. package/static/app/assets/{download-BGIkTQL6.js → download-SEcXzkx1.js} +1 -1
  193. package/static/app/assets/{folder-open-Dst_Z0_K.js → folder-open-Dw1ff_Qa.js} +1 -1
  194. package/static/app/assets/{hard-drive-D53MsWkV.js → hard-drive-B00fR7_r.js} +1 -1
  195. package/static/app/assets/index-CEkL4_M-.css +2 -0
  196. package/static/app/assets/{index-C_IrlQMV.js → index-Dm9VXl1o.js} +3 -3
  197. package/static/app/assets/{input-C5m0riF6.js → input-DtdWYQEa.js} +1 -1
  198. package/static/app/assets/{network-C5a-E5iS.js → network-CpjBu0kI.js} +1 -1
  199. package/static/app/assets/{primitives-vNXYf58F.js → primitives-CwsudBwc.js} +1 -1
  200. package/static/app/assets/search-CwEJWBEA.js +1 -0
  201. package/static/app/assets/{shield-alert-Cc-WVXqN.js → shield-alert-CpjBc-gQ.js} +1 -1
  202. package/static/app/assets/{textarea-BkZ0EqVO.js → textarea-DUPyjHQs.js} +1 -1
  203. package/static/app/assets/{useFocusTrap-CTtKbAOU.js → useFocusTrap-BWcMtCWH.js} +1 -1
  204. package/static/app/assets/{useQuery-Dx1fi4Wu.js → useQuery-Dg5fPqTS.js} +1 -1
  205. package/static/app/assets/{users-BFpQXtEF.js → users-BgcdTQGM.js} +1 -1
  206. package/static/app/assets/{utils-BA_lmW3J.js → utils-a3YXFtEO.js} +2 -2
  207. package/static/app/assets/workspace-BMUEiXUm.js +1 -0
  208. package/static/app/index.html +4 -4
  209. package/static/sw.js +1 -1
  210. package/static/app/assets/Library-DhvoPvC7.js +0 -1
  211. package/static/app/assets/System-CSMdYLMy.js +0 -1
  212. package/static/app/assets/index-FxDusbr0.css +0 -2
  213. package/static/app/assets/search-DhbSgW6m.js +0 -1
  214. package/static/app/assets/workspace-DBPB0jkX.js +0 -1
@@ -0,0 +1,84 @@
1
+ """Token / cost guardrails for hybrid cloud turns (Phase 2).
2
+
3
+ Keeps cloud usage bounded:
4
+
5
+ * per-turn token estimate must stay under ``max_tokens_per_turn``
6
+ * optional session budget (in-memory, process-local) tracks cumulative usage
7
+
8
+ These are soft product guardrails, not hard billing meters. Real provider
9
+ usage is still authoritative when the cloud returns usage fields.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import threading
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Dict, Optional
18
+
19
+
20
+ def _env_int(name: str, default: int) -> int:
21
+ raw = os.environ.get(name, "").strip()
22
+ if not raw:
23
+ return default
24
+ try:
25
+ return max(0, int(raw))
26
+ except ValueError:
27
+ return default
28
+
29
+
30
+ @dataclass
31
+ class TokenBudget:
32
+ max_tokens_per_turn: int = field(
33
+ default_factory=lambda: _env_int("LATTICEAI_CLOUD_MAX_TOKENS_PER_TURN", 2500)
34
+ )
35
+ max_tokens_per_session: int = field(
36
+ default_factory=lambda: _env_int("LATTICEAI_CLOUD_MAX_TOKENS_PER_SESSION", 50000)
37
+ )
38
+ session_used: int = 0
39
+
40
+ def check_turn(self, estimated_input_tokens: int) -> Optional[str]:
41
+ """Return a refusal reason, or None if the turn is allowed."""
42
+ if estimated_input_tokens > self.max_tokens_per_turn:
43
+ return (
44
+ f"estimated input tokens {estimated_input_tokens} exceed "
45
+ f"per-turn limit {self.max_tokens_per_turn}"
46
+ )
47
+ if self.session_used + estimated_input_tokens > self.max_tokens_per_session:
48
+ return (
49
+ f"session budget would exceed {self.max_tokens_per_session} "
50
+ f"(used={self.session_used}, turn={estimated_input_tokens})"
51
+ )
52
+ return None
53
+
54
+ def record(self, tokens: int) -> None:
55
+ self.session_used += max(0, int(tokens or 0))
56
+
57
+ def snapshot(self) -> Dict[str, Any]:
58
+ return {
59
+ "max_tokens_per_turn": self.max_tokens_per_turn,
60
+ "max_tokens_per_session": self.max_tokens_per_session,
61
+ "session_used": self.session_used,
62
+ "session_remaining": max(0, self.max_tokens_per_session - self.session_used),
63
+ }
64
+
65
+
66
+ _LOCK = threading.Lock()
67
+ _BUDGETS: Dict[str, TokenBudget] = {}
68
+
69
+
70
+ def budget_for(scope_key: str) -> TokenBudget:
71
+ """Process-local budget keyed by user|workspace."""
72
+ key = str(scope_key or "global")
73
+ with _LOCK:
74
+ if key not in _BUDGETS:
75
+ _BUDGETS[key] = TokenBudget()
76
+ return _BUDGETS[key]
77
+
78
+
79
+ def reset_budget(scope_key: str) -> None:
80
+ with _LOCK:
81
+ _BUDGETS.pop(str(scope_key or "global"), None)
82
+
83
+
84
+ __all__ = ["TokenBudget", "budget_for", "reset_budget"]
@@ -26,7 +26,8 @@ import logging
26
26
  from datetime import datetime, timedelta
27
27
  from typing import Any, Dict, List, Optional
28
28
 
29
- from latticeai.core.timeutil import local_now, now_iso as _now, parse_iso
29
+ from latticeai.core.timeutil import local_now, parse_iso
30
+ from latticeai.core.timeutil import now_iso as _now
30
31
 
31
32
  LOGGER = logging.getLogger(__name__)
32
33
 
@@ -39,6 +39,7 @@ from lattice_brain.ingestion import (
39
39
  _load_latticeignore,
40
40
  _matches_ignore,
41
41
  )
42
+ from latticeai.core.quiet import quiet
42
43
  from latticeai.core.timeutil import now_iso as _now_iso
43
44
 
44
45
  WATCH_INTERVAL_ENV = "LATTICEAI_FOLDER_WATCH_INTERVAL"
@@ -339,6 +340,7 @@ class FolderWatchService:
339
340
  try:
340
341
  stat = path.stat()
341
342
  except OSError:
343
+ quiet()
342
344
  continue
343
345
  if stat.st_size > DEFAULT_MAX_FILE_BYTES:
344
346
  continue
@@ -390,6 +392,7 @@ class FolderWatchService:
390
392
  try:
391
393
  self.scan_once(watch_id)
392
394
  except Exception: # noqa: BLE001 — one bad watch must not kill the poller
395
+ quiet()
393
396
  continue
394
397
 
395
398
 
@@ -37,6 +37,8 @@ from datetime import datetime, timezone
37
37
  from pathlib import Path
38
38
  from typing import Any, Dict, Optional
39
39
 
40
+ from latticeai.core.quiet import quiet
41
+
40
42
  LOGGER = logging.getLogger(__name__)
41
43
 
42
44
  COUNTER_NAMES = (
@@ -124,7 +126,7 @@ class FunnelMetricsService:
124
126
  try:
125
127
  os.unlink(tmp_name)
126
128
  except OSError:
127
- pass
129
+ quiet()
128
130
  except Exception as exc: # noqa: BLE001 — metrics persistence is best-effort
129
131
  LOGGER.warning("funnel metrics save failed: %s", exc)
130
132
 
@@ -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
+ ]