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.
- package/README.md +43 -32
- package/docs/CHANGELOG.md +204 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/HYBRID_CLOUD_KG_STREAMING.md +101 -0
- package/docs/MYPY_BACKLOG.md +98 -0
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +5 -4
- package/lattice_brain/conversations.py +14 -3
- package/lattice_brain/embeddings.py +12 -2
- package/lattice_brain/graph/_kg_common.py +5 -5
- package/lattice_brain/graph/_kg_fsutil.py +5 -4
- package/lattice_brain/graph/discovery.py +4 -3
- package/lattice_brain/graph/discovery_index.py +0 -1
- package/lattice_brain/graph/documents.py +3 -2
- package/lattice_brain/graph/fusion.py +4 -0
- package/lattice_brain/graph/ingest.py +12 -2
- package/lattice_brain/graph/projection.py +3 -2
- package/lattice_brain/graph/provenance.py +5 -3
- package/lattice_brain/graph/rerank.py +4 -1
- package/lattice_brain/graph/retrieval.py +0 -1
- package/lattice_brain/graph/retrieval_docgen.py +0 -1
- package/lattice_brain/graph/retrieval_reads.py +0 -1
- package/lattice_brain/graph/retrieval_vector.py +0 -1
- package/lattice_brain/graph/schema.py +4 -3
- package/lattice_brain/graph/store.py +18 -4
- package/lattice_brain/graph/write_master.py +46 -1
- package/lattice_brain/ingestion.py +4 -1
- package/lattice_brain/portability.py +5 -2
- package/lattice_brain/quality.py +12 -5
- package/lattice_brain/quiet.py +43 -0
- package/lattice_brain/runtime/agent_runtime.py +19 -10
- package/lattice_brain/runtime/hooks.py +3 -2
- package/lattice_brain/runtime/multi_agent.py +2 -3
- package/lattice_brain/sensitivity.py +94 -0
- package/lattice_brain/storage/base.py +30 -2
- package/lattice_brain/storage/migration.py +3 -2
- package/lattice_brain/storage/postgres.py +2 -2
- package/lattice_brain/storage/sqlite.py +6 -4
- package/lattice_brain/workflow.py +4 -2
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agents.py +4 -2
- package/latticeai/api/auth.py +5 -1
- package/latticeai/api/automation_intelligence.py +2 -1
- package/latticeai/api/browser.py +3 -2
- package/latticeai/api/chat.py +28 -17
- package/latticeai/api/chat_agent_http.py +22 -8
- package/latticeai/api/chat_contracts.py +4 -0
- package/latticeai/api/chat_documents.py +6 -2
- package/latticeai/api/chat_hybrid.py +82 -0
- package/latticeai/api/computer_use.py +8 -3
- package/latticeai/api/knowledge_graph.py +1 -1
- package/latticeai/api/mcp.py +4 -4
- package/latticeai/api/models.py +5 -2
- package/latticeai/api/network_boundary.py +220 -0
- package/latticeai/api/permissions.py +0 -1
- package/latticeai/api/realtime.py +1 -1
- package/latticeai/api/security_dashboard.py +1 -1
- package/latticeai/api/setup.py +16 -3
- package/latticeai/api/static_routes.py +2 -1
- package/latticeai/api/tools.py +12 -8
- package/latticeai/api/voice_capture.py +3 -1
- package/latticeai/api/workflow_designer.py +1 -1
- package/latticeai/api/workspace.py +1 -2
- package/latticeai/app_factory.py +174 -145
- package/latticeai/cli/entrypoint.py +6 -4
- package/latticeai/core/agent.py +55 -495
- package/latticeai/core/agent_eval.py +2 -2
- package/latticeai/core/agent_helpers.py +493 -0
- package/latticeai/core/agent_prompts.py +0 -1
- package/latticeai/core/agent_registry.py +3 -1
- package/latticeai/core/agent_state.py +41 -0
- package/latticeai/core/audit.py +1 -1
- package/latticeai/core/builtin_hooks.py +2 -1
- package/latticeai/core/config.py +0 -1
- package/latticeai/core/embedding_providers.py +12 -1
- package/latticeai/core/file_generation.py +3 -0
- package/latticeai/core/invitations.py +4 -1
- package/latticeai/core/io_utils.py +3 -1
- package/latticeai/core/legacy_compatibility.py +1 -2
- package/latticeai/core/local_embeddings.py +12 -1
- package/latticeai/core/marketplace.py +1 -2
- package/latticeai/core/mcp_registry.py +0 -1
- package/latticeai/core/model_compat.py +1 -1
- package/latticeai/core/model_resolution.py +1 -1
- package/latticeai/core/network_boundary.py +168 -0
- package/latticeai/core/oidc.py +3 -0
- package/latticeai/core/permission_mode.py +6 -6
- package/latticeai/core/plugins.py +3 -2
- package/latticeai/core/policy.py +0 -1
- package/latticeai/core/quiet.py +84 -0
- package/latticeai/core/realtime.py +3 -2
- package/latticeai/core/run_store.py +4 -2
- package/latticeai/core/security.py +4 -0
- package/latticeai/core/users.py +5 -3
- package/latticeai/core/workspace_graph_trace.py +3 -0
- package/latticeai/core/workspace_os.py +65 -273
- package/latticeai/core/workspace_os_constants.py +126 -0
- package/latticeai/core/workspace_os_state.py +180 -0
- package/latticeai/core/workspace_os_utils.py +3 -1
- package/latticeai/core/workspace_permissions.py +1 -0
- package/latticeai/core/workspace_snapshots.py +3 -1
- package/latticeai/core/workspace_timeline.py +3 -1
- package/latticeai/integrations/telegram_bot.py +25 -16
- package/latticeai/models/router.py +6 -3
- package/latticeai/runtime/access_runtime.py +3 -1
- package/latticeai/runtime/audit_runtime.py +3 -2
- package/latticeai/runtime/chat_wiring.py +4 -1
- package/latticeai/runtime/history_runtime.py +1 -1
- package/latticeai/runtime/history_writer.py +138 -0
- package/latticeai/runtime/lifespan_runtime.py +3 -1
- package/latticeai/runtime/network_boundary_wiring.py +124 -0
- package/latticeai/runtime/persistence_runtime.py +3 -1
- package/latticeai/runtime/router_registration.py +11 -1
- package/latticeai/services/architecture_readiness.py +1 -2
- package/latticeai/services/change_proposals.py +2 -1
- package/latticeai/services/cloud_egress_audit.py +85 -0
- package/latticeai/services/cloud_extraction.py +129 -0
- package/latticeai/services/cloud_streaming.py +266 -0
- package/latticeai/services/cloud_token_guard.py +84 -0
- package/latticeai/services/command_center.py +2 -1
- package/latticeai/services/folder_watch.py +3 -0
- package/latticeai/services/funnel_metrics.py +3 -1
- package/latticeai/services/hybrid_chat.py +265 -0
- package/latticeai/services/hybrid_context.py +228 -0
- package/latticeai/services/hybrid_policy.py +178 -0
- package/latticeai/services/memory_service.py +1 -1
- package/latticeai/services/model_catalog.py +10 -1
- package/latticeai/services/model_engines.py +35 -14
- package/latticeai/services/model_loading.py +3 -2
- package/latticeai/services/model_runtime.py +68 -17
- package/latticeai/services/multimodal_streaming.py +123 -0
- package/latticeai/services/network_boundary_service.py +154 -0
- package/latticeai/services/openai_compatible_adapter.py +100 -0
- package/latticeai/services/p_reinforce.py +4 -0
- package/latticeai/services/platform_runtime.py +9 -4
- package/latticeai/services/process_audit.py +0 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/run_executor.py +3 -2
- package/latticeai/services/search_service.py +1 -4
- package/latticeai/services/setup_detection.py +2 -1
- package/latticeai/services/tool_dispatch.py +7 -2
- package/latticeai/services/upload_service.py +2 -1
- package/latticeai/setup/auto_setup.py +10 -7
- package/latticeai/setup/wizard.py +15 -11
- package/latticeai/tools/__init__.py +3 -3
- package/latticeai/tools/commands.py +7 -7
- package/latticeai/tools/computer.py +3 -2
- package/latticeai/tools/documents.py +10 -9
- package/latticeai/tools/filesystem.py +7 -4
- package/latticeai/tools/knowledge.py +2 -0
- package/latticeai/tools/local_files.py +1 -1
- package/latticeai/tools/network.py +2 -1
- package/package.json +7 -3
- package/scripts/bench_agent_smoke.py +0 -1
- package/scripts/bench_models.py +0 -1
- package/scripts/brain_quality_eval.py +7 -2
- package/scripts/bump_version.py +2 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/migrate_brain_storage.py +5 -1
- package/scripts/profile_kg.py +2 -7
- package/scripts/verify_hf_model_registry.py +2 -2
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -37
- package/static/app/assets/{Act-BtCREeN1.js → Act-DrKIGZeJ.js} +1 -1
- package/static/app/assets/{AdminConsole-TPeeN18T.js → AdminConsole-CEV1VefA.js} +1 -1
- package/static/app/assets/{Brain-BKs6JAp0.js → Brain-BZSXnwWU.js} +1 -1
- package/static/app/assets/{BrainHome-BPGOvSd6.js → BrainHome-CEBd86k6.js} +1 -1
- package/static/app/assets/{BrainSignals-CtzQZ15J.js → BrainSignals-O4s6R7av.js} +1 -1
- package/static/app/assets/{Capture-1_NaHWqB.js → Capture-AGbOxsg-.js} +1 -1
- package/static/app/assets/{CommandPalette-pqvQOXe4.js → CommandPalette-CeqBjCn2.js} +1 -1
- package/static/app/assets/Library-CXnbNiQW.js +1 -0
- package/static/app/assets/{LivingBrain-DlQ20Q75.js → LivingBrain-D9Iei2ZJ.js} +1 -1
- package/static/app/assets/{ProductFlow-BZvGDRi_.js → ProductFlow-JlXiDfMM.js} +1 -1
- package/static/app/assets/{ReviewCard-BWgI0D2s.js → ReviewCard-QQly2bTV.js} +1 -1
- package/static/app/assets/System-Bp9ZY4ER.js +1 -0
- package/static/app/assets/{activity-Dlfk8YC7.js → activity-CxRPOQ2F.js} +1 -1
- package/static/app/assets/{bot-CDvUB76P.js → bot-1bcsWB2s.js} +1 -1
- package/static/app/assets/{brain-xczrohrt.js → brain-CxaeKCRM.js} +1 -1
- package/static/app/assets/{button-SOdH3Oyf.js → button-ZYFqGan7.js} +1 -1
- package/static/app/assets/{circle-pause-CG1ythH4.js → circle-pause-B5NOIqvk.js} +1 -1
- package/static/app/assets/{circle-play-HXwvjS6W.js → circle-play-BiJVFXf7.js} +1 -1
- package/static/app/assets/{cpu-B0d-rGyk.js → cpu-Vvq0OKTr.js} +1 -1
- package/static/app/assets/{download-BGIkTQL6.js → download-SEcXzkx1.js} +1 -1
- package/static/app/assets/{folder-open-Dst_Z0_K.js → folder-open-Dw1ff_Qa.js} +1 -1
- package/static/app/assets/{hard-drive-D53MsWkV.js → hard-drive-B00fR7_r.js} +1 -1
- package/static/app/assets/index-CEkL4_M-.css +2 -0
- package/static/app/assets/{index-C_IrlQMV.js → index-Dm9VXl1o.js} +3 -3
- package/static/app/assets/{input-C5m0riF6.js → input-DtdWYQEa.js} +1 -1
- package/static/app/assets/{network-C5a-E5iS.js → network-CpjBu0kI.js} +1 -1
- package/static/app/assets/{primitives-vNXYf58F.js → primitives-CwsudBwc.js} +1 -1
- package/static/app/assets/search-CwEJWBEA.js +1 -0
- package/static/app/assets/{shield-alert-Cc-WVXqN.js → shield-alert-CpjBc-gQ.js} +1 -1
- package/static/app/assets/{textarea-BkZ0EqVO.js → textarea-DUPyjHQs.js} +1 -1
- package/static/app/assets/{useFocusTrap-CTtKbAOU.js → useFocusTrap-BWcMtCWH.js} +1 -1
- package/static/app/assets/{useQuery-Dx1fi4Wu.js → useQuery-Dg5fPqTS.js} +1 -1
- package/static/app/assets/{users-BFpQXtEF.js → users-BgcdTQGM.js} +1 -1
- package/static/app/assets/{utils-BA_lmW3J.js → utils-a3YXFtEO.js} +2 -2
- package/static/app/assets/workspace-BMUEiXUm.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Library-DhvoPvC7.js +0 -1
- package/static/app/assets/System-CSMdYLMy.js +0 -1
- package/static/app/assets/index-FxDusbr0.css +0 -2
- package/static/app/assets/search-DhbSgW6m.js +0 -1
- 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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
+
]
|