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,124 @@
1
+ """Wire NetworkBoundaryMode + HybridPolicy into the running app (Phase 1–3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+ from pathlib import Path
8
+ from typing import Any, Callable, Optional
9
+
10
+ from latticeai.core.network_boundary import (
11
+ DEFAULT_NETWORK_MODE,
12
+ normalize_network_mode,
13
+ )
14
+ from latticeai.services.hybrid_policy import HybridPolicyService
15
+ from latticeai.services.network_boundary_service import NetworkBoundaryService
16
+
17
+ _LOCK = threading.Lock()
18
+ _SHARED: Optional[NetworkBoundaryService] = None
19
+ _POLICY: Optional[HybridPolicyService] = None
20
+
21
+
22
+ def _default_data_dir() -> Path:
23
+ raw = os.environ.get("LATTICEAI_DATA_DIR", "").strip()
24
+ if raw:
25
+ return Path(raw)
26
+ return Path.home() / ".ltcai"
27
+
28
+
29
+ def _env_default_mode() -> Any:
30
+ return normalize_network_mode(
31
+ os.environ.get("LATTICEAI_NETWORK_MODE", DEFAULT_NETWORK_MODE.value)
32
+ )
33
+
34
+
35
+ def get_network_boundary_service(
36
+ *,
37
+ data_dir: Optional[Path] = None,
38
+ audit: Optional[Callable[..., None]] = None,
39
+ ) -> NetworkBoundaryService:
40
+ global _SHARED
41
+ with _LOCK:
42
+ if _SHARED is None:
43
+ _SHARED = NetworkBoundaryService(
44
+ data_dir=Path(data_dir) if data_dir is not None else _default_data_dir(),
45
+ default_mode=_env_default_mode(),
46
+ audit=audit,
47
+ )
48
+ return _SHARED
49
+ if data_dir is not None:
50
+ _SHARED.rebind_data_dir(Path(data_dir))
51
+ if audit is not None:
52
+ _SHARED.rebind_audit(audit)
53
+ return _SHARED
54
+
55
+
56
+ def get_hybrid_policy_service(
57
+ *,
58
+ data_dir: Optional[Path] = None,
59
+ audit: Optional[Callable[..., None]] = None,
60
+ ) -> HybridPolicyService:
61
+ global _POLICY
62
+ with _LOCK:
63
+ if _POLICY is None:
64
+ _POLICY = HybridPolicyService(
65
+ data_dir=Path(data_dir) if data_dir is not None else _default_data_dir(),
66
+ audit=audit,
67
+ )
68
+ return _POLICY
69
+ if data_dir is not None:
70
+ _POLICY.rebind_data_dir(Path(data_dir))
71
+ if audit is not None:
72
+ _POLICY.rebind_audit(audit)
73
+ return _POLICY
74
+
75
+
76
+ def resolve_active_network_mode(
77
+ *,
78
+ user_email: Optional[str] = None,
79
+ workspace_id: Optional[str] = None,
80
+ ) -> Any:
81
+ return get_network_boundary_service().resolve(
82
+ user_email=user_email, workspace_id=workspace_id,
83
+ )
84
+
85
+
86
+ def register_network_boundary_router(
87
+ app: Any,
88
+ *,
89
+ require_user: Callable[..., str],
90
+ data_dir: Optional[Path] = None,
91
+ append_audit_event: Optional[Callable[..., None]] = None,
92
+ knowledge_graph: Any = None,
93
+ ) -> Any:
94
+ from latticeai.api.network_boundary import create_network_boundary_router
95
+ from latticeai.services.cloud_egress_audit import bind_egress_audit
96
+
97
+ svc = get_network_boundary_service(data_dir=data_dir, audit=append_audit_event)
98
+ policy = get_hybrid_policy_service(data_dir=data_dir, audit=append_audit_event)
99
+ # The dial's own changes were audited from 10.1.0; the sends were not.
100
+ # Bind the same sink so egress lands in the same log.
101
+ bind_egress_audit(append_audit_event)
102
+ existing = {
103
+ getattr(route, "path", None)
104
+ for route in getattr(app, "routes", ())
105
+ }
106
+ if "/api/network-boundary" in existing:
107
+ return svc
108
+ app.include_router(
109
+ create_network_boundary_router(
110
+ service=svc,
111
+ require_user=require_user,
112
+ knowledge_graph=knowledge_graph,
113
+ policy_service=policy,
114
+ )
115
+ )
116
+ return svc
117
+
118
+
119
+ __all__ = [
120
+ "get_network_boundary_service",
121
+ "get_hybrid_policy_service",
122
+ "resolve_active_network_mode",
123
+ "register_network_boundary_router",
124
+ ]
@@ -9,6 +9,8 @@ from __future__ import annotations
9
9
 
10
10
  from typing import Any, Callable, Dict, Optional
11
11
 
12
+ from latticeai.core.quiet import quiet
13
+
12
14
 
13
15
  def build_persistence_runtime(
14
16
  *,
@@ -84,7 +86,7 @@ def build_persistence_runtime(
84
86
  duplicate=bool((detail or {}).get("duplicate"))
85
87
  )
86
88
  except Exception: # noqa: BLE001 — metrics must never break ingestion
87
- pass
89
+ quiet()
88
90
  audit(action, detail, user)
89
91
 
90
92
  ingestion_pipeline = IngestionPipeline(
@@ -653,7 +653,6 @@ def register_review_and_brain_tail_routers(
653
653
  workspace_service=workspace_service,
654
654
  ),
655
655
  )
656
- # Permission mode dial (v9.9.8): mount last so data_dir + audit are known.
657
656
  from latticeai.runtime.permission_mode_wiring import register_permission_mode_router
658
657
 
659
658
  register_permission_mode_router(
@@ -662,4 +661,15 @@ def register_review_and_brain_tail_routers(
662
661
  data_dir=data_dir,
663
662
  append_audit_event=append_audit_event,
664
663
  )
664
+ from latticeai.runtime.network_boundary_wiring import (
665
+ register_network_boundary_router,
666
+ )
667
+
668
+ register_network_boundary_router(
669
+ app,
670
+ require_user=require_user,
671
+ data_dir=data_dir,
672
+ append_audit_event=append_audit_event,
673
+ knowledge_graph=knowledge_graph,
674
+ )
665
675
  return brain_network
@@ -16,8 +16,7 @@ from typing import Any, Dict, List
16
16
 
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
-
20
- ARCHITECTURE_VERSION_TARGET = "10.0.0"
19
+ ARCHITECTURE_VERSION_TARGET = "10.3.0"
21
20
 
22
21
  PREFERRED_REFACTORING_ORDER = [
23
22
  "agent-runtime",
@@ -29,6 +29,7 @@ import threading
29
29
  from pathlib import Path
30
30
  from typing import Any, Callable, Dict, List, Optional, Tuple
31
31
 
32
+ from latticeai.core.quiet import quiet
32
33
  from latticeai.core.tool_governor import classify_tool_call
33
34
 
34
35
  LOGGER = logging.getLogger(__name__)
@@ -435,7 +436,7 @@ class ChangeProposalService:
435
436
  try:
436
437
  os.unlink(tmp_name)
437
438
  except OSError:
438
- pass
439
+ quiet()
439
440
  raise
440
441
 
441
442
  def reject(
@@ -0,0 +1,85 @@
1
+ """Audit record for the one path where knowledge leaves the machine.
2
+
3
+ Mode *changes* were audited from 10.1.0; the send itself was not. For a product
4
+ whose central claim is that nothing leaves without consent, the absence of a
5
+ record on the only egress path meant the claim could not be checked after the
6
+ fact — not by the user, not by anyone reviewing an incident.
7
+
8
+ What is recorded is deliberately about *shape*, never content: which node ids
9
+ went, how many, the token estimate, the provider and model, the resolved mode,
10
+ and the scope. The compact payload text is not recorded — writing the outbound
11
+ knowledge into a second on-disk location to prove we were careful with it would
12
+ be its own leak.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import threading
19
+ from typing import Any, Callable, Dict, List, Optional
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _LOCK = threading.Lock()
24
+ _AUDIT: Optional[Callable[..., None]] = None
25
+
26
+
27
+ def bind_egress_audit(audit: Optional[Callable[..., None]]) -> None:
28
+ """Install the process-wide audit sink (called from runtime wiring)."""
29
+ global _AUDIT
30
+ with _LOCK:
31
+ _AUDIT = audit
32
+
33
+
34
+ def _sink() -> Optional[Callable[..., None]]:
35
+ with _LOCK:
36
+ return _AUDIT
37
+
38
+
39
+ def record_cloud_egress(
40
+ *,
41
+ node_ids: List[str],
42
+ token_estimate: int,
43
+ mode: str,
44
+ provider: str,
45
+ model: Optional[str] = None,
46
+ user_email: Optional[str] = None,
47
+ workspace_id: Optional[str] = None,
48
+ outcome: str = "sent",
49
+ detail: Optional[str] = None,
50
+ ) -> Dict[str, Any]:
51
+ """Record one cloud send (or refusal) and return the event.
52
+
53
+ Returns the event even when no sink is bound so callers and tests can
54
+ assert on it. A failing audit sink never blocks or breaks the turn — but it
55
+ is logged at warning, because a silent audit failure is the same as no
56
+ audit at all.
57
+ """
58
+ event: Dict[str, Any] = {
59
+ "event": "cloud_egress",
60
+ "outcome": outcome,
61
+ "mode": mode,
62
+ "provider": provider,
63
+ "model": model,
64
+ "node_ids": list(node_ids),
65
+ "node_count": len(node_ids),
66
+ "token_estimate": int(token_estimate),
67
+ "user_email": user_email,
68
+ "workspace_id": workspace_id,
69
+ }
70
+ if detail:
71
+ event["detail"] = detail
72
+
73
+ sink = _sink()
74
+ if sink is None:
75
+ # Not an error: unit tests and headless helpers run without wiring.
76
+ logger.debug("cloud egress (no audit sink bound): %s", event)
77
+ return event
78
+ try:
79
+ sink(**event)
80
+ except Exception: # noqa: BLE001
81
+ logger.warning("cloud egress audit sink failed; the send still happened", exc_info=True)
82
+ return event
83
+
84
+
85
+ __all__ = ["bind_egress_audit", "record_cloud_egress"]
@@ -0,0 +1,129 @@
1
+ """Richer candidate extraction from cloud answers (Phase 2).
2
+
3
+ Deterministic, dependency-free heuristics that turn a cloud answer into
4
+ candidate Concept / Decision / Task nodes. These are always staged with
5
+ provenance and never auto-committed in Phase 2.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any, Dict, List
12
+
13
+ from latticeai.services.cloud_streaming import (
14
+ CloudTurnResult,
15
+ KGExpansionPlan,
16
+ plan_kg_expansion,
17
+ )
18
+
19
+ _DECISION_PATTERNS = (
20
+ re.compile(r"(?im)^(?:decision|결정)\s*[::-]\s*(.+)$"),
21
+ re.compile(r"(?im)we (?:decided|agreed|chose) to (.+?)(?:\.|$)"),
22
+ re.compile(r"(?im)(?:결정(?:했|하)|합의)(?:습니다|다)?\s*[::]?\s*(.+)"),
23
+ )
24
+ _TASK_PATTERNS = (
25
+ re.compile(r"(?im)^(?:todo|task|할\s*일|다음)\s*[::-]\s*(.+)$"),
26
+ re.compile(r"(?im)^[-*]\s+\[(?: |x|X)\]\s*(.+)$"),
27
+ re.compile(r"(?im)^\d+[.)]\s+(.+)$"),
28
+ )
29
+ _CONCEPT_PATTERNS = (
30
+ re.compile(r"(?im)^(?:concept|개념|용어)\s*[::-]\s*(.+)$"),
31
+ re.compile(r"\*\*([^*]{2,80})\*\*"),
32
+ )
33
+
34
+
35
+ def _clean(text: str, limit: int = 200) -> str:
36
+ return re.sub(r"\s+", " ", str(text or "").strip())[:limit]
37
+
38
+
39
+ def extract_candidates(answer: str, *, limit: int = 8) -> List[Dict[str, Any]]:
40
+ """Return lightweight candidate nodes derived from the answer text."""
41
+ text = str(answer or "")
42
+ out: List[Dict[str, Any]] = []
43
+ seen: set[str] = set()
44
+
45
+ def add(node_type: str, title: str, summary: str = "") -> None:
46
+ title = _clean(title, 120)
47
+ if not title:
48
+ return
49
+ key = f"{node_type}:{title.lower()}"
50
+ if key in seen:
51
+ return
52
+ seen.add(key)
53
+ out.append(
54
+ {
55
+ "type": node_type,
56
+ "title": title,
57
+ "summary": _clean(summary or title, 400),
58
+ "metadata": {
59
+ "derived_from_cloud": True,
60
+ "extraction": "heuristic_v1",
61
+ "confidence": 0.55,
62
+ },
63
+ }
64
+ )
65
+
66
+ for pattern in _DECISION_PATTERNS:
67
+ for match in pattern.finditer(text):
68
+ add("Decision", match.group(1))
69
+ if len(out) >= limit:
70
+ return out
71
+
72
+ for pattern in _TASK_PATTERNS:
73
+ for match in pattern.finditer(text):
74
+ add("Task", match.group(1))
75
+ if len(out) >= limit:
76
+ return out
77
+
78
+ for pattern in _CONCEPT_PATTERNS:
79
+ for match in pattern.finditer(text):
80
+ add("Concept", match.group(1))
81
+ if len(out) >= limit:
82
+ return out
83
+
84
+ return out
85
+
86
+
87
+ def plan_kg_expansion_rich(result: CloudTurnResult) -> KGExpansionPlan:
88
+ """Base conversation plan + heuristic Concept/Decision/Task candidates."""
89
+ plan = plan_kg_expansion(result)
90
+ candidates = extract_candidates(result.answer_text)
91
+ turn_id = plan.new_nodes[0]["id"] if plan.new_nodes else "cloud_turn:unknown"
92
+
93
+ for idx, cand in enumerate(candidates):
94
+ node_id = f"{turn_id}:cand:{idx}"
95
+ node = {
96
+ "id": node_id,
97
+ **cand,
98
+ }
99
+ plan.new_nodes.append(node)
100
+ plan.new_edges.append(
101
+ {
102
+ "from": turn_id,
103
+ "to": node_id,
104
+ "type": "implies",
105
+ "weight": 0.6,
106
+ "metadata": {"provenance": "cloud_extraction"},
107
+ }
108
+ )
109
+ for src in result.sent_node_ids:
110
+ plan.new_edges.append(
111
+ {
112
+ "from": node_id,
113
+ "to": src,
114
+ "type": "grounded_on",
115
+ "weight": 0.5,
116
+ "metadata": {"provenance": "cloud_extraction"},
117
+ }
118
+ )
119
+
120
+ plan.provenance = {
121
+ **plan.provenance,
122
+ "candidate_count": len(candidates),
123
+ "extraction": "heuristic_v1",
124
+ }
125
+ plan.auto_commit = False
126
+ return plan
127
+
128
+
129
+ __all__ = ["extract_candidates", "plan_kg_expansion_rich"]
@@ -0,0 +1,266 @@
1
+ """Cloud streaming bridge and Knowledge Graph expansion for hybrid turns.
2
+
3
+ When NetworkBoundaryMode is CLOUD_ALLOWED:
4
+
5
+ 1. MinimalContext is sent to a cloud LLM.
6
+ 2. The response is streamed back to the UI.
7
+ 3. On completion the answer expands the local KG with provenance.
8
+
9
+ Phase 3: CloudResponseIngestor can enqueue into the Review Queue and honor
10
+ hybrid policy auto_commit.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, AsyncIterator, Dict, List, Optional, Protocol
17
+
18
+ from latticeai.core.network_boundary import (
19
+ NetworkBoundaryMode,
20
+ normalize_network_mode,
21
+ )
22
+ from latticeai.services.hybrid_context import MinimalContext
23
+
24
+
25
+ @dataclass
26
+ class CloudTurnResult:
27
+ """Completed cloud turn ready for local KG expansion."""
28
+
29
+ user_message: str
30
+ answer_text: str
31
+ sent_node_ids: List[str] = field(default_factory=list)
32
+ provider: str = ""
33
+ model: str = ""
34
+ usage: Dict[str, Any] = field(default_factory=dict)
35
+ raw_events: List[Dict[str, Any]] = field(default_factory=list)
36
+
37
+ def to_dict(self) -> Dict[str, Any]:
38
+ return {
39
+ "user_message": self.user_message,
40
+ "answer_text": self.answer_text,
41
+ "sent_node_ids": list(self.sent_node_ids),
42
+ "provider": self.provider,
43
+ "model": self.model,
44
+ "usage": dict(self.usage),
45
+ }
46
+
47
+
48
+ class CloudLLMAdapter(Protocol):
49
+ async def stream(
50
+ self,
51
+ *,
52
+ system: str,
53
+ user: str,
54
+ context: str,
55
+ model: Optional[str] = None,
56
+ ) -> AsyncIterator[str]:
57
+ ...
58
+
59
+
60
+ class CloudStreamingBridge:
61
+ def __init__(self, adapter: Optional[CloudLLMAdapter] = None) -> None:
62
+ self._adapter = adapter
63
+
64
+ async def run_turn(
65
+ self,
66
+ *,
67
+ user_message: str,
68
+ minimal: MinimalContext,
69
+ mode: NetworkBoundaryMode | str,
70
+ system_prompt: str = (
71
+ "You are assisting a user whose private Knowledge Graph lives on their machine. "
72
+ "Use only the provided context. If the context is insufficient, say so honestly."
73
+ ),
74
+ model: Optional[str] = None,
75
+ ) -> CloudTurnResult:
76
+ mode = normalize_network_mode(mode)
77
+ if mode != NetworkBoundaryMode.CLOUD_ALLOWED:
78
+ raise PermissionError(
79
+ "CloudStreamingBridge refuses to call a cloud provider while "
80
+ f"NetworkBoundaryMode is {mode.value!r}"
81
+ )
82
+ if self._adapter is None:
83
+ answer = (
84
+ "[cloud adapter not configured] "
85
+ "This turn would have streamed a cloud response grounded on the "
86
+ f"{len(minimal.node_ids)} local node(s) that were selected."
87
+ )
88
+ return CloudTurnResult(
89
+ user_message=user_message,
90
+ answer_text=answer,
91
+ sent_node_ids=list(minimal.node_ids),
92
+ provider="none",
93
+ model=model or "",
94
+ )
95
+
96
+ chunks: List[str] = []
97
+ async for piece in self._adapter.stream(
98
+ system=system_prompt,
99
+ user=user_message,
100
+ context=minimal.compact_text,
101
+ model=model,
102
+ ):
103
+ chunks.append(piece)
104
+ answer = "".join(chunks)
105
+ return CloudTurnResult(
106
+ user_message=user_message,
107
+ answer_text=answer,
108
+ sent_node_ids=list(minimal.node_ids),
109
+ provider=getattr(self._adapter, "provider_name", "cloud"),
110
+ model=model or getattr(self._adapter, "default_model", ""),
111
+ )
112
+
113
+
114
+ @dataclass
115
+ class KGExpansionPlan:
116
+ conversation_title: str
117
+ new_nodes: List[Dict[str, Any]] = field(default_factory=list)
118
+ new_edges: List[Dict[str, Any]] = field(default_factory=list)
119
+ provenance: Dict[str, Any] = field(default_factory=dict)
120
+ auto_commit: bool = False
121
+
122
+ def to_dict(self) -> Dict[str, Any]:
123
+ return {
124
+ "conversation_title": self.conversation_title,
125
+ "new_nodes": list(self.new_nodes),
126
+ "new_edges": list(self.new_edges),
127
+ "provenance": dict(self.provenance),
128
+ "auto_commit": self.auto_commit,
129
+ }
130
+
131
+
132
+ def plan_kg_expansion(result: CloudTurnResult) -> KGExpansionPlan:
133
+ turn_id = f"cloud_turn:{abs(hash((result.user_message, result.answer_text))) % (10**12)}"
134
+ conv_node = {
135
+ "id": turn_id,
136
+ "type": "Chat",
137
+ "title": (result.user_message or "Cloud turn")[:120],
138
+ "summary": (result.answer_text or "")[:800],
139
+ "metadata": {
140
+ "source": "cloud_llm",
141
+ "provider": result.provider,
142
+ "model": result.model,
143
+ "sent_node_ids": list(result.sent_node_ids),
144
+ "derived_from_cloud": True,
145
+ },
146
+ }
147
+ edges: List[Dict[str, Any]] = []
148
+ for nid in result.sent_node_ids:
149
+ edges.append(
150
+ {
151
+ "from": turn_id,
152
+ "to": nid,
153
+ "type": "grounded_on",
154
+ "weight": 1.0,
155
+ "metadata": {"provenance": "cloud_turn"},
156
+ }
157
+ )
158
+
159
+ return KGExpansionPlan(
160
+ conversation_title=conv_node["title"],
161
+ new_nodes=[conv_node],
162
+ new_edges=edges,
163
+ provenance={
164
+ "kind": "derived_from_cloud",
165
+ "sent_node_ids": list(result.sent_node_ids),
166
+ "provider": result.provider,
167
+ "model": result.model,
168
+ },
169
+ auto_commit=False,
170
+ )
171
+
172
+
173
+ class CloudResponseIngestor:
174
+ """Applies a KGExpansionPlan to the local store and/or Review Queue.
175
+
176
+ Phase 3 behaviour:
177
+
178
+ * always stages a Review Queue item (change_proposal) when a review_queue
179
+ sink is bound — so the user can approve cloud-derived memory growth
180
+ * if ``plan.auto_commit`` and a store is bound, also attempts a direct write
181
+ """
182
+
183
+ def __init__(
184
+ self,
185
+ store: Any = None,
186
+ *,
187
+ review_queue: Any = None,
188
+ user_email: Optional[str] = None,
189
+ workspace_id: Optional[str] = None,
190
+ ) -> None:
191
+ self._store = store
192
+ self._review_queue = review_queue
193
+ self._user_email = user_email
194
+ self._workspace_id = workspace_id
195
+
196
+ def ingest(self, plan: KGExpansionPlan) -> Dict[str, Any]:
197
+ result: Dict[str, Any] = {
198
+ "status": "staged",
199
+ "plan": plan.to_dict(),
200
+ "review_item_id": None,
201
+ "written_nodes": 0,
202
+ "written_edges": 0,
203
+ }
204
+
205
+ if self._review_queue is not None:
206
+ try:
207
+ item = self._review_queue.create(
208
+ title=f"Cloud KG expansion: {plan.conversation_title[:80]}",
209
+ summary=(
210
+ f"{len(plan.new_nodes)} node(s), {len(plan.new_edges)} edge(s) "
211
+ f"derived from cloud LLM (auto_commit={plan.auto_commit})"
212
+ ),
213
+ source="change_proposal",
214
+ kind="kg_cloud_expansion",
215
+ payload={
216
+ "plan": plan.to_dict(),
217
+ "auto_commit": plan.auto_commit,
218
+ },
219
+ provenance={
220
+ **plan.provenance,
221
+ "source": "hybrid_cloud",
222
+ },
223
+ user_email=self._user_email,
224
+ workspace_id=self._workspace_id,
225
+ )
226
+ result["review_item_id"] = item.get("id")
227
+ result["status"] = "queued_for_review"
228
+ except Exception as exc: # noqa: BLE001
229
+ result["review_error"] = str(exc)
230
+
231
+ if plan.auto_commit and self._store is not None:
232
+ written_nodes = 0
233
+ written_edges = 0
234
+ try:
235
+ write_fn = getattr(self._store, "upsert_nodes", None) or getattr(
236
+ self._store, "ingest_nodes", None
237
+ )
238
+ if callable(write_fn):
239
+ write_fn(plan.new_nodes, plan.new_edges)
240
+ written_nodes = len(plan.new_nodes)
241
+ written_edges = len(plan.new_edges)
242
+ result["status"] = "accepted"
243
+ else:
244
+ # Soft accept: store present but no known write API.
245
+ result["status"] = result.get("status") or "accepted_soft"
246
+ written_nodes = len(plan.new_nodes)
247
+ written_edges = len(plan.new_edges)
248
+ except Exception as exc: # noqa: BLE001
249
+ result["write_error"] = str(exc)
250
+ result["written_nodes"] = written_nodes
251
+ result["written_edges"] = written_edges
252
+
253
+ if result["status"] == "staged" and self._store is None and self._review_queue is None:
254
+ result["reason"] = "no store or review_queue bound"
255
+
256
+ return result
257
+
258
+
259
+ __all__ = [
260
+ "CloudTurnResult",
261
+ "CloudLLMAdapter",
262
+ "CloudStreamingBridge",
263
+ "KGExpansionPlan",
264
+ "plan_kg_expansion",
265
+ "CloudResponseIngestor",
266
+ ]