ltcai 10.0.0 → 10.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -32
- package/docs/CHANGELOG.md +156 -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/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 +4 -3
- 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 +12 -8
- package/lattice_brain/runtime/hooks.py +2 -1
- 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 +131 -78
- 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/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 +5 -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-CbdGD-2i.js} +1 -1
- package/static/app/assets/{AdminConsole-TPeeN18T.js → AdminConsole-LgCkXpnf.js} +1 -1
- package/static/app/assets/{Brain-BKs6JAp0.js → Brain-CoPGJI1L.js} +1 -1
- package/static/app/assets/{BrainHome-BPGOvSd6.js → BrainHome-gVnaxSwp.js} +1 -1
- package/static/app/assets/{BrainSignals-CtzQZ15J.js → BrainSignals-ChxAYHtj.js} +1 -1
- package/static/app/assets/{Capture-1_NaHWqB.js → Capture-DBtgkHZg.js} +1 -1
- package/static/app/assets/{CommandPalette-pqvQOXe4.js → CommandPalette-Ovtv5I0Y.js} +1 -1
- package/static/app/assets/{Library-DhvoPvC7.js → Library-CBia2Fvm.js} +1 -1
- package/static/app/assets/{LivingBrain-DlQ20Q75.js → LivingBrain-CNz-6NSd.js} +1 -1
- package/static/app/assets/{ProductFlow-BZvGDRi_.js → ProductFlow-ePX-Y73J.js} +1 -1
- package/static/app/assets/{ReviewCard-BWgI0D2s.js → ReviewCard-C4mpvkwH.js} +1 -1
- package/static/app/assets/System-BvWNK1zc.js +1 -0
- package/static/app/assets/{activity-Dlfk8YC7.js → activity-CiauPV_3.js} +1 -1
- package/static/app/assets/{bot-CDvUB76P.js → bot-Dwct-Q1x.js} +1 -1
- package/static/app/assets/{brain-xczrohrt.js → brain-BKDW1F0T.js} +1 -1
- package/static/app/assets/{button-SOdH3Oyf.js → button-CCB9Kuw7.js} +1 -1
- package/static/app/assets/{circle-pause-CG1ythH4.js → circle-pause-DNa8WHC5.js} +1 -1
- package/static/app/assets/{circle-play-HXwvjS6W.js → circle-play-8jmxn5mf.js} +1 -1
- package/static/app/assets/{cpu-B0d-rGyk.js → cpu-mxvF3V1I.js} +1 -1
- package/static/app/assets/{download-BGIkTQL6.js → download--SmCcx0p.js} +1 -1
- package/static/app/assets/{folder-open-Dst_Z0_K.js → folder-open-D4Wy5roo.js} +1 -1
- package/static/app/assets/{hard-drive-D53MsWkV.js → hard-drive-BwQcSPlS.js} +1 -1
- package/static/app/assets/index-CQWdDU3z.css +2 -0
- package/static/app/assets/{index-C_IrlQMV.js → index-DDV2YZwM.js} +3 -3
- package/static/app/assets/{input-C5m0riF6.js → input-DFhSjmLS.js} +1 -1
- package/static/app/assets/{network-C5a-E5iS.js → network-Bts7VO94.js} +1 -1
- package/static/app/assets/{primitives-vNXYf58F.js → primitives-BuSMEJY8.js} +1 -1
- package/static/app/assets/search-DZzxhWaQ.js +1 -0
- package/static/app/assets/{shield-alert-Cc-WVXqN.js → shield-alert-BfTO6X_G.js} +1 -1
- package/static/app/assets/{textarea-BkZ0EqVO.js → textarea-BjctW1oh.js} +1 -1
- package/static/app/assets/{useFocusTrap-CTtKbAOU.js → useFocusTrap-CE43-LrA.js} +1 -1
- package/static/app/assets/{useQuery-Dx1fi4Wu.js → useQuery-Bv3ejLL5.js} +1 -1
- package/static/app/assets/{users-BFpQXtEF.js → users-CsNqLZAj.js} +1 -1
- package/static/app/assets/{utils-BA_lmW3J.js → utils-KFFdVG_t.js} +2 -2
- package/static/app/assets/workspace-wdCvdyPF.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -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,265 @@
|
|
|
1
|
+
"""Hybrid chat turn: minimal KG context → cloud stream → local KG expansion.
|
|
2
|
+
|
|
3
|
+
Phase 1–2 entry point used by the chat path when NetworkBoundaryMode is
|
|
4
|
+
CLOUD_ALLOWED. Local-only sessions never enter this module's cloud path.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, AsyncIterator, Dict, Optional
|
|
12
|
+
|
|
13
|
+
from latticeai.core.network_boundary import (
|
|
14
|
+
NetworkBoundaryMode,
|
|
15
|
+
normalize_network_mode,
|
|
16
|
+
)
|
|
17
|
+
from latticeai.services.cloud_egress_audit import record_cloud_egress
|
|
18
|
+
from latticeai.services.cloud_extraction import plan_kg_expansion_rich
|
|
19
|
+
from latticeai.services.cloud_streaming import (
|
|
20
|
+
CloudResponseIngestor,
|
|
21
|
+
CloudStreamingBridge,
|
|
22
|
+
CloudTurnResult,
|
|
23
|
+
)
|
|
24
|
+
from latticeai.services.cloud_token_guard import budget_for
|
|
25
|
+
from latticeai.services.hybrid_context import MinimalContext, build_minimal_context
|
|
26
|
+
from latticeai.services.openai_compatible_adapter import OpenAICompatibleAdapter
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _sse(data: Dict[str, Any]) -> str:
|
|
32
|
+
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _scope_key(user_email: Optional[str], workspace_id: Optional[str]) -> str:
|
|
36
|
+
return f"{user_email or 'anon'}|{workspace_id or 'global'}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def run_hybrid_cloud_turn(
|
|
40
|
+
*,
|
|
41
|
+
user_message: str,
|
|
42
|
+
knowledge_graph: Any,
|
|
43
|
+
mode: NetworkBoundaryMode | str,
|
|
44
|
+
workspace_id: Optional[str] = None,
|
|
45
|
+
user_email: Optional[str] = None,
|
|
46
|
+
model: Optional[str] = None,
|
|
47
|
+
top_k: int = 6,
|
|
48
|
+
adapter: Optional[Any] = None,
|
|
49
|
+
) -> CloudTurnResult:
|
|
50
|
+
"""Non-streaming helper: full answer + expansion plan."""
|
|
51
|
+
mode = normalize_network_mode(mode)
|
|
52
|
+
if mode != NetworkBoundaryMode.CLOUD_ALLOWED:
|
|
53
|
+
raise PermissionError(
|
|
54
|
+
f"hybrid cloud turn refused under NetworkBoundaryMode={mode.value!r}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
minimal = build_minimal_context(
|
|
58
|
+
user_message,
|
|
59
|
+
store=knowledge_graph,
|
|
60
|
+
mode=mode,
|
|
61
|
+
top_k=top_k,
|
|
62
|
+
allowed_workspaces={workspace_id} if workspace_id else None,
|
|
63
|
+
)
|
|
64
|
+
budget = budget_for(_scope_key(user_email, workspace_id))
|
|
65
|
+
refusal = budget.check_turn(minimal.token_estimate)
|
|
66
|
+
if refusal:
|
|
67
|
+
record_cloud_egress(
|
|
68
|
+
node_ids=minimal.node_ids, token_estimate=minimal.token_estimate,
|
|
69
|
+
mode=mode.value, provider="(refused)", model=model,
|
|
70
|
+
user_email=user_email, workspace_id=workspace_id,
|
|
71
|
+
outcome="refused_token_guard", detail=refusal,
|
|
72
|
+
)
|
|
73
|
+
raise PermissionError(f"cloud token guard: {refusal}")
|
|
74
|
+
|
|
75
|
+
chosen_adapter = adapter or OpenAICompatibleAdapter()
|
|
76
|
+
record_cloud_egress(
|
|
77
|
+
node_ids=minimal.node_ids, token_estimate=minimal.token_estimate,
|
|
78
|
+
mode=mode.value, provider=getattr(chosen_adapter, "name", type(chosen_adapter).__name__),
|
|
79
|
+
model=model, user_email=user_email, workspace_id=workspace_id,
|
|
80
|
+
)
|
|
81
|
+
bridge = CloudStreamingBridge(adapter=chosen_adapter)
|
|
82
|
+
result = await bridge.run_turn(
|
|
83
|
+
user_message=user_message,
|
|
84
|
+
minimal=minimal,
|
|
85
|
+
mode=mode,
|
|
86
|
+
model=model,
|
|
87
|
+
)
|
|
88
|
+
plan = plan_kg_expansion_rich(result)
|
|
89
|
+
ingestor = CloudResponseIngestor(store=knowledge_graph)
|
|
90
|
+
ingest_status = ingestor.ingest(plan)
|
|
91
|
+
used = minimal.token_estimate + max(1, len(result.answer_text) // 4)
|
|
92
|
+
budget.record(used)
|
|
93
|
+
result.usage = {
|
|
94
|
+
**(result.usage or {}),
|
|
95
|
+
"minimal_nodes": len(minimal.node_ids),
|
|
96
|
+
"token_estimate": minimal.token_estimate,
|
|
97
|
+
"context_quality": minimal.quality,
|
|
98
|
+
"kg_expansion": ingest_status,
|
|
99
|
+
"token_budget": budget.snapshot(),
|
|
100
|
+
}
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def stream_hybrid_cloud_turn(
|
|
105
|
+
*,
|
|
106
|
+
user_message: str,
|
|
107
|
+
knowledge_graph: Any,
|
|
108
|
+
mode: NetworkBoundaryMode | str,
|
|
109
|
+
workspace_id: Optional[str] = None,
|
|
110
|
+
user_email: Optional[str] = None,
|
|
111
|
+
model: Optional[str] = None,
|
|
112
|
+
top_k: int = 6,
|
|
113
|
+
adapter: Optional[Any] = None,
|
|
114
|
+
chat_service: Any = None,
|
|
115
|
+
history_meta: Optional[Dict[str, Any]] = None,
|
|
116
|
+
history_user: Optional[Dict[str, Any]] = None,
|
|
117
|
+
notify: Any = None,
|
|
118
|
+
source: Optional[str] = None,
|
|
119
|
+
) -> AsyncIterator[str]:
|
|
120
|
+
"""SSE generator for a hybrid cloud turn (Phase 2 chat path).
|
|
121
|
+
|
|
122
|
+
Events:
|
|
123
|
+
* ``hybrid_context`` — which local nodes were selected
|
|
124
|
+
* ``token`` / classic ``chunk`` — streamed text
|
|
125
|
+
* ``hybrid_done`` — final answer + KG expansion status
|
|
126
|
+
* ``error`` — honest failure
|
|
127
|
+
"""
|
|
128
|
+
mode = normalize_network_mode(mode)
|
|
129
|
+
if mode != NetworkBoundaryMode.CLOUD_ALLOWED:
|
|
130
|
+
yield _sse(
|
|
131
|
+
{
|
|
132
|
+
"type": "error",
|
|
133
|
+
"detail": f"NetworkBoundaryMode is {mode.value!r}; cloud path disabled",
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
yield "data: [DONE]\n\n"
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
minimal = build_minimal_context(
|
|
140
|
+
user_message,
|
|
141
|
+
store=knowledge_graph,
|
|
142
|
+
mode=mode,
|
|
143
|
+
top_k=top_k,
|
|
144
|
+
allowed_workspaces={workspace_id} if workspace_id else None,
|
|
145
|
+
)
|
|
146
|
+
budget = budget_for(_scope_key(user_email, workspace_id))
|
|
147
|
+
refusal = budget.check_turn(minimal.token_estimate)
|
|
148
|
+
if refusal:
|
|
149
|
+
# A refusal is auditable too: "nothing left the machine, and here is why".
|
|
150
|
+
record_cloud_egress(
|
|
151
|
+
node_ids=minimal.node_ids, token_estimate=minimal.token_estimate,
|
|
152
|
+
mode=mode.value, provider="(refused)", model=model,
|
|
153
|
+
user_email=user_email, workspace_id=workspace_id,
|
|
154
|
+
outcome="refused_token_guard", detail=refusal,
|
|
155
|
+
)
|
|
156
|
+
yield _sse({"type": "error", "detail": f"cloud token guard: {refusal}"})
|
|
157
|
+
yield "data: [DONE]\n\n"
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
yield _sse(
|
|
161
|
+
{
|
|
162
|
+
"type": "hybrid_context",
|
|
163
|
+
"node_ids": minimal.node_ids,
|
|
164
|
+
"keywords": minimal.keywords,
|
|
165
|
+
"token_estimate": minimal.token_estimate,
|
|
166
|
+
"quality": minimal.quality,
|
|
167
|
+
"titles": [str(n.get("title") or n.get("id") or "") for n in minimal.nodes],
|
|
168
|
+
"token_budget": budget.snapshot(),
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
chosen_adapter = adapter or OpenAICompatibleAdapter()
|
|
173
|
+
bridge = CloudStreamingBridge(adapter=chosen_adapter)
|
|
174
|
+
|
|
175
|
+
# Recorded before the call, not after: if the provider hangs or the process
|
|
176
|
+
# dies mid-stream, the record of what was about to leave must already exist.
|
|
177
|
+
record_cloud_egress(
|
|
178
|
+
node_ids=minimal.node_ids, token_estimate=minimal.token_estimate,
|
|
179
|
+
mode=mode.value, provider=getattr(chosen_adapter, "name", type(chosen_adapter).__name__),
|
|
180
|
+
model=model, user_email=user_email, workspace_id=workspace_id,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
chunks: list[str] = []
|
|
185
|
+
if chosen_adapter is not None and hasattr(chosen_adapter, "stream"):
|
|
186
|
+
system = (
|
|
187
|
+
"You are assisting a user whose private Knowledge Graph lives on their machine. "
|
|
188
|
+
"Use only the provided context. If the context is insufficient, say so honestly."
|
|
189
|
+
)
|
|
190
|
+
async for piece in chosen_adapter.stream(
|
|
191
|
+
system=system,
|
|
192
|
+
user=user_message,
|
|
193
|
+
context=minimal.compact_text,
|
|
194
|
+
model=model,
|
|
195
|
+
):
|
|
196
|
+
chunks.append(piece)
|
|
197
|
+
# dual shape: hybrid token + classic chunk for existing clients
|
|
198
|
+
yield _sse({"type": "token", "text": piece, "chunk": piece, "model": model})
|
|
199
|
+
answer = "".join(chunks)
|
|
200
|
+
result = CloudTurnResult(
|
|
201
|
+
user_message=user_message,
|
|
202
|
+
answer_text=answer,
|
|
203
|
+
sent_node_ids=list(minimal.node_ids),
|
|
204
|
+
provider=getattr(chosen_adapter, "provider_name", "cloud"),
|
|
205
|
+
model=model or getattr(chosen_adapter, "default_model", ""),
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
result = await bridge.run_turn(
|
|
209
|
+
user_message=user_message,
|
|
210
|
+
minimal=minimal,
|
|
211
|
+
mode=mode,
|
|
212
|
+
model=model,
|
|
213
|
+
)
|
|
214
|
+
yield _sse(
|
|
215
|
+
{
|
|
216
|
+
"type": "token",
|
|
217
|
+
"text": result.answer_text,
|
|
218
|
+
"chunk": result.answer_text,
|
|
219
|
+
"model": model,
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
plan = plan_kg_expansion_rich(result)
|
|
224
|
+
ingest_status = CloudResponseIngestor(store=knowledge_graph).ingest(plan)
|
|
225
|
+
used = minimal.token_estimate + max(1, len(result.answer_text) // 4)
|
|
226
|
+
budget.record(used)
|
|
227
|
+
|
|
228
|
+
if chat_service is not None:
|
|
229
|
+
try:
|
|
230
|
+
await chat_service.persist_entry(
|
|
231
|
+
"assistant",
|
|
232
|
+
result.answer_text,
|
|
233
|
+
history_meta=history_meta or {},
|
|
234
|
+
history_user=history_user or {},
|
|
235
|
+
)
|
|
236
|
+
if notify is not None:
|
|
237
|
+
notify("assistant", result.answer_text, source)
|
|
238
|
+
except Exception as exc: # noqa: BLE001
|
|
239
|
+
logger.warning("hybrid chat persistence failed: %s", exc)
|
|
240
|
+
|
|
241
|
+
yield _sse(
|
|
242
|
+
{
|
|
243
|
+
"type": "hybrid_done",
|
|
244
|
+
"chunk": "",
|
|
245
|
+
"answer": result.answer_text,
|
|
246
|
+
"sent_node_ids": result.sent_node_ids,
|
|
247
|
+
"provider": result.provider,
|
|
248
|
+
"model": result.model,
|
|
249
|
+
"kg_expansion": ingest_status,
|
|
250
|
+
"token_estimate": minimal.token_estimate,
|
|
251
|
+
"token_budget": budget.snapshot(),
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
yield "data: [DONE]\n\n"
|
|
255
|
+
except Exception as exc: # noqa: BLE001 — surface honest error to client
|
|
256
|
+
logger.warning("hybrid cloud turn failed: %s", exc)
|
|
257
|
+
yield _sse({"type": "error", "detail": str(exc), "error": str(exc)})
|
|
258
|
+
yield "data: [DONE]\n\n"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
__all__ = [
|
|
262
|
+
"run_hybrid_cloud_turn",
|
|
263
|
+
"stream_hybrid_cloud_turn",
|
|
264
|
+
"MinimalContext",
|
|
265
|
+
]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Minimal context extraction for hybrid cloud LLM turns.
|
|
2
|
+
|
|
3
|
+
Given a user message and a local Knowledge Graph store, select only the
|
|
4
|
+
smallest useful set of related nodes, assemble a compact text payload, and
|
|
5
|
+
estimate token cost. This is the only knowledge that is ever allowed to leave
|
|
6
|
+
the machine when NetworkBoundaryMode is CLOUD_ALLOWED.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
|
14
|
+
|
|
15
|
+
from latticeai.core.network_boundary import (
|
|
16
|
+
NetworkBoundaryMode,
|
|
17
|
+
is_node_blocked_for_cloud,
|
|
18
|
+
normalize_network_mode,
|
|
19
|
+
)
|
|
20
|
+
from latticeai.core.security import redact_secret_text
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class MinimalContext:
|
|
27
|
+
"""What may be sent to a cloud LLM."""
|
|
28
|
+
|
|
29
|
+
query: str
|
|
30
|
+
keywords: List[str] = field(default_factory=list)
|
|
31
|
+
node_ids: List[str] = field(default_factory=list)
|
|
32
|
+
compact_text: str = ""
|
|
33
|
+
nodes: List[Dict[str, Any]] = field(default_factory=list)
|
|
34
|
+
token_estimate: int = 0
|
|
35
|
+
quality: Dict[str, Any] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
38
|
+
return {
|
|
39
|
+
"query": self.query,
|
|
40
|
+
"keywords": list(self.keywords),
|
|
41
|
+
"node_ids": list(self.node_ids),
|
|
42
|
+
"compact_text": self.compact_text,
|
|
43
|
+
"nodes": list(self.nodes),
|
|
44
|
+
"token_estimate": self.token_estimate,
|
|
45
|
+
"quality": dict(self.quality),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SupportsHybridSearch(Protocol):
|
|
50
|
+
"""Minimal surface required from a KnowledgeGraph store."""
|
|
51
|
+
|
|
52
|
+
def hybrid_search(
|
|
53
|
+
self,
|
|
54
|
+
query: str,
|
|
55
|
+
*,
|
|
56
|
+
top_k: int = 20,
|
|
57
|
+
allowed_workspaces: Any = None,
|
|
58
|
+
include_legacy_global: bool = False,
|
|
59
|
+
) -> Dict[str, Any]:
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
def context_for_query_with_meta(
|
|
63
|
+
self,
|
|
64
|
+
query: str,
|
|
65
|
+
limit: int = 6,
|
|
66
|
+
*,
|
|
67
|
+
allowed_workspaces: Any = None,
|
|
68
|
+
include_legacy_global: bool = False,
|
|
69
|
+
use_hybrid: bool = True,
|
|
70
|
+
) -> Dict[str, Any]:
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _rough_token_estimate(text: str) -> int:
|
|
75
|
+
"""Cheap heuristic: ~4 characters per token for mixed EN/KO."""
|
|
76
|
+
if not text:
|
|
77
|
+
return 0
|
|
78
|
+
return max(1, (len(text) + 3) // 4)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _extract_keywords(message: str, limit: int = 12) -> List[str]:
|
|
82
|
+
"""Very lightweight keyword / entity candidates.
|
|
83
|
+
|
|
84
|
+
Real extraction can later be replaced by a small local model or the
|
|
85
|
+
existing topic candidate helpers inside lattice_brain. For scaffolding we
|
|
86
|
+
keep this deterministic and dependency-free.
|
|
87
|
+
"""
|
|
88
|
+
import re
|
|
89
|
+
|
|
90
|
+
text = str(message or "").strip()
|
|
91
|
+
if not text:
|
|
92
|
+
return []
|
|
93
|
+
# Split on whitespace and common punctuation; keep tokens of length >= 2.
|
|
94
|
+
tokens = re.findall(r"[\w가-힣]{2,}", text, flags=re.UNICODE)
|
|
95
|
+
seen: set[str] = set()
|
|
96
|
+
out: List[str] = []
|
|
97
|
+
for tok in tokens:
|
|
98
|
+
key = tok.lower()
|
|
99
|
+
if key in seen:
|
|
100
|
+
continue
|
|
101
|
+
seen.add(key)
|
|
102
|
+
out.append(tok)
|
|
103
|
+
if len(out) >= limit:
|
|
104
|
+
break
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_minimal_context(
|
|
109
|
+
message: str,
|
|
110
|
+
*,
|
|
111
|
+
store: Optional[SupportsHybridSearch] = None,
|
|
112
|
+
mode: NetworkBoundaryMode | str = NetworkBoundaryMode.LOCAL_ONLY,
|
|
113
|
+
top_k: int = 6,
|
|
114
|
+
allowed_workspaces: Any = None,
|
|
115
|
+
include_legacy_global: bool = False,
|
|
116
|
+
preferred_types: Optional[Sequence[str]] = None,
|
|
117
|
+
) -> MinimalContext:
|
|
118
|
+
"""Select the smallest useful set of local nodes for a cloud turn.
|
|
119
|
+
|
|
120
|
+
When mode is LOCAL_ONLY this still returns a MinimalContext (for local
|
|
121
|
+
RAG) but callers must not transmit it.
|
|
122
|
+
"""
|
|
123
|
+
mode = normalize_network_mode(mode)
|
|
124
|
+
query = str(message or "").strip()
|
|
125
|
+
keywords = _extract_keywords(query)
|
|
126
|
+
|
|
127
|
+
empty = MinimalContext(
|
|
128
|
+
query=query,
|
|
129
|
+
keywords=keywords,
|
|
130
|
+
quality={"mode": "none", "nodes": 0, "limited": True, "reason": "no store or empty query"},
|
|
131
|
+
)
|
|
132
|
+
if not query or store is None:
|
|
133
|
+
return empty
|
|
134
|
+
|
|
135
|
+
preferred = set(
|
|
136
|
+
preferred_types
|
|
137
|
+
or (
|
|
138
|
+
"Decision",
|
|
139
|
+
"Concept",
|
|
140
|
+
"Task",
|
|
141
|
+
"Document",
|
|
142
|
+
"File",
|
|
143
|
+
"CodeFile",
|
|
144
|
+
"Person",
|
|
145
|
+
"Feature",
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# One retrieval, not two. This used to call
|
|
150
|
+
# ``context_for_query_with_meta`` first — a full retrieval — and throw away
|
|
151
|
+
# everything but its ``quality`` dict, then run ``hybrid_search`` for the
|
|
152
|
+
# matches it actually needed. Every cloud turn paid for retrieval twice.
|
|
153
|
+
# ``context_quality_signal`` derives the same signal from the match list we
|
|
154
|
+
# already have.
|
|
155
|
+
matches: List[Dict[str, Any]] = []
|
|
156
|
+
quality: Dict[str, Any] = {}
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
hybrid = store.hybrid_search(
|
|
160
|
+
query,
|
|
161
|
+
top_k=max(top_k * 2, top_k),
|
|
162
|
+
allowed_workspaces=allowed_workspaces,
|
|
163
|
+
include_legacy_global=include_legacy_global,
|
|
164
|
+
)
|
|
165
|
+
matches = list(hybrid.get("matches") or [])
|
|
166
|
+
from lattice_brain.graph.retrieval import context_quality_signal
|
|
167
|
+
|
|
168
|
+
quality = context_quality_signal(
|
|
169
|
+
str(hybrid.get("mode") or "hybrid"),
|
|
170
|
+
len(matches),
|
|
171
|
+
)
|
|
172
|
+
except Exception: # noqa: BLE001
|
|
173
|
+
# Fail closed: an unusable retrieval sends nothing rather than
|
|
174
|
+
# falling back to a broader, unfiltered context.
|
|
175
|
+
logger.warning("hybrid context retrieval failed; sending no context", exc_info=True)
|
|
176
|
+
matches = []
|
|
177
|
+
|
|
178
|
+
# Filter blocked + prefer useful types, then cut to top_k.
|
|
179
|
+
selected: List[Dict[str, Any]] = []
|
|
180
|
+
for match in matches:
|
|
181
|
+
if is_node_blocked_for_cloud(match):
|
|
182
|
+
continue
|
|
183
|
+
# Soft preference: keep preferred types first by stable partition later.
|
|
184
|
+
selected.append(match)
|
|
185
|
+
|
|
186
|
+
selected.sort(
|
|
187
|
+
key=lambda m: (
|
|
188
|
+
0 if str(m.get("type") or "") in preferred else 1,
|
|
189
|
+
-float(m.get("score") or 0.0),
|
|
190
|
+
str(m.get("node_id") or m.get("id") or ""),
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
selected = selected[: max(1, min(int(top_k), 12))]
|
|
194
|
+
|
|
195
|
+
lines: List[str] = []
|
|
196
|
+
node_ids: List[str] = []
|
|
197
|
+
for match in selected:
|
|
198
|
+
nid = str(match.get("node_id") or match.get("id") or "")
|
|
199
|
+
if not nid:
|
|
200
|
+
continue
|
|
201
|
+
node_ids.append(nid)
|
|
202
|
+
title = str(match.get("title") or nid)
|
|
203
|
+
summary = str(match.get("summary") or "")[:400]
|
|
204
|
+
ntype = str(match.get("type") or "Node")
|
|
205
|
+
lines.append(f"- [{ntype}] {title}: {summary}".rstrip(": "))
|
|
206
|
+
|
|
207
|
+
# Last gate before the text can leave the machine. `redact_secret_text` was
|
|
208
|
+
# already applied to logs, audit records, and API previews — everywhere
|
|
209
|
+
# except the one path where bytes actually go to a third party. A token
|
|
210
|
+
# pasted into a note is not marked sensitive by anyone, so pattern
|
|
211
|
+
# redaction is the only thing standing between it and the provider.
|
|
212
|
+
compact = redact_secret_text("\n".join(lines))
|
|
213
|
+
return MinimalContext(
|
|
214
|
+
query=query,
|
|
215
|
+
keywords=keywords,
|
|
216
|
+
node_ids=node_ids,
|
|
217
|
+
compact_text=compact,
|
|
218
|
+
nodes=selected,
|
|
219
|
+
token_estimate=_rough_token_estimate(compact),
|
|
220
|
+
quality=quality or {"mode": "none", "nodes": len(selected), "limited": len(selected) <= 1},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
__all__ = [
|
|
225
|
+
"MinimalContext",
|
|
226
|
+
"SupportsHybridSearch",
|
|
227
|
+
"build_minimal_context",
|
|
228
|
+
]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""User-configurable hybrid policy (Phase 3).
|
|
2
|
+
|
|
3
|
+
Controls:
|
|
4
|
+
|
|
5
|
+
* which node types / metadata flags are blocked from leaving the machine
|
|
6
|
+
* whether cloud-derived KG expansion auto-commits or stages for review
|
|
7
|
+
* whether multimodal (video) cloud calls are allowed when cloud mode is on
|
|
8
|
+
|
|
9
|
+
Persisted under the data dir, scoped like NetworkBoundaryService
|
|
10
|
+
(workspace → user → default).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import threading
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Callable, Dict, List, Optional, Set
|
|
19
|
+
|
|
20
|
+
from latticeai.core.io_utils import atomic_write_json
|
|
21
|
+
from latticeai.core.network_boundary import (
|
|
22
|
+
HARD_BLOCK_METADATA_FLAGS,
|
|
23
|
+
HARD_BLOCK_NODE_TYPES,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
DEFAULT_BLOCKED_TYPES: List[str] = []
|
|
27
|
+
DEFAULT_BLOCKED_FLAGS: List[str] = sorted(HARD_BLOCK_METADATA_FLAGS)
|
|
28
|
+
DEFAULT_AUTO_COMMIT = False
|
|
29
|
+
DEFAULT_ALLOW_MULTIMODAL = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _default_policy() -> Dict[str, Any]:
|
|
33
|
+
return {
|
|
34
|
+
"blocked_node_types": list(DEFAULT_BLOCKED_TYPES),
|
|
35
|
+
"blocked_metadata_flags": list(DEFAULT_BLOCKED_FLAGS),
|
|
36
|
+
"auto_commit": DEFAULT_AUTO_COMMIT,
|
|
37
|
+
"allow_multimodal": DEFAULT_ALLOW_MULTIMODAL,
|
|
38
|
+
"min_extraction_confidence": 0.55,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class HybridPolicyService:
|
|
43
|
+
"""Load / save hybrid cloud policy."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
data_dir: Path,
|
|
49
|
+
audit: Optional[Callable[..., None]] = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._path = Path(data_dir) / "hybrid_policy.json"
|
|
52
|
+
self._audit = audit or (lambda *a, **kw: None)
|
|
53
|
+
self._lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
def rebind_data_dir(self, data_dir: Path) -> None:
|
|
56
|
+
with self._lock:
|
|
57
|
+
self._path = Path(data_dir) / "hybrid_policy.json"
|
|
58
|
+
|
|
59
|
+
def rebind_audit(self, audit: Callable[..., None]) -> None:
|
|
60
|
+
with self._lock:
|
|
61
|
+
self._audit = audit
|
|
62
|
+
|
|
63
|
+
def _read(self) -> Dict[str, Any]:
|
|
64
|
+
base = {
|
|
65
|
+
"default": _default_policy(),
|
|
66
|
+
"users": {},
|
|
67
|
+
"workspaces": {},
|
|
68
|
+
}
|
|
69
|
+
if not self._path.exists():
|
|
70
|
+
return base
|
|
71
|
+
try:
|
|
72
|
+
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
73
|
+
except Exception:
|
|
74
|
+
return base
|
|
75
|
+
if not isinstance(data, dict):
|
|
76
|
+
return base
|
|
77
|
+
data.setdefault("default", _default_policy())
|
|
78
|
+
data.setdefault("users", {})
|
|
79
|
+
data.setdefault("workspaces", {})
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
def _write(self, data: Dict[str, Any]) -> None:
|
|
83
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
atomic_write_json(self._path, data)
|
|
85
|
+
|
|
86
|
+
def _resolve_raw(
|
|
87
|
+
self,
|
|
88
|
+
data: Dict[str, Any],
|
|
89
|
+
*,
|
|
90
|
+
user_email: Optional[str],
|
|
91
|
+
workspace_id: Optional[str],
|
|
92
|
+
) -> Dict[str, Any]:
|
|
93
|
+
policy = dict(data.get("default") or _default_policy())
|
|
94
|
+
if user_email:
|
|
95
|
+
user = (data.get("users") or {}).get(str(user_email).lower())
|
|
96
|
+
if isinstance(user, dict):
|
|
97
|
+
policy.update(user)
|
|
98
|
+
if workspace_id:
|
|
99
|
+
ws = (data.get("workspaces") or {}).get(str(workspace_id))
|
|
100
|
+
if isinstance(ws, dict):
|
|
101
|
+
policy.update(ws)
|
|
102
|
+
# Always union hard-coded circuit breakers.
|
|
103
|
+
blocked_types: Set[str] = set(policy.get("blocked_node_types") or [])
|
|
104
|
+
blocked_types |= set(HARD_BLOCK_NODE_TYPES)
|
|
105
|
+
blocked_flags: Set[str] = set(policy.get("blocked_metadata_flags") or [])
|
|
106
|
+
blocked_flags |= set(HARD_BLOCK_METADATA_FLAGS)
|
|
107
|
+
policy["blocked_node_types"] = sorted(blocked_types)
|
|
108
|
+
policy["blocked_metadata_flags"] = sorted(blocked_flags)
|
|
109
|
+
policy["auto_commit"] = bool(policy.get("auto_commit", False))
|
|
110
|
+
policy["allow_multimodal"] = bool(policy.get("allow_multimodal", False))
|
|
111
|
+
try:
|
|
112
|
+
policy["min_extraction_confidence"] = float(
|
|
113
|
+
policy.get("min_extraction_confidence", 0.55)
|
|
114
|
+
)
|
|
115
|
+
except (TypeError, ValueError):
|
|
116
|
+
policy["min_extraction_confidence"] = 0.55
|
|
117
|
+
return policy
|
|
118
|
+
|
|
119
|
+
def resolve(
|
|
120
|
+
self,
|
|
121
|
+
*,
|
|
122
|
+
user_email: Optional[str] = None,
|
|
123
|
+
workspace_id: Optional[str] = None,
|
|
124
|
+
) -> Dict[str, Any]:
|
|
125
|
+
with self._lock:
|
|
126
|
+
data = self._read()
|
|
127
|
+
return self._resolve_raw(data, user_email=user_email, workspace_id=workspace_id)
|
|
128
|
+
|
|
129
|
+
def set_policy(
|
|
130
|
+
self,
|
|
131
|
+
patch: Dict[str, Any],
|
|
132
|
+
*,
|
|
133
|
+
user_email: Optional[str] = None,
|
|
134
|
+
workspace_id: Optional[str] = None,
|
|
135
|
+
source: str = "api",
|
|
136
|
+
) -> Dict[str, Any]:
|
|
137
|
+
allowed = {
|
|
138
|
+
"blocked_node_types",
|
|
139
|
+
"blocked_metadata_flags",
|
|
140
|
+
"auto_commit",
|
|
141
|
+
"allow_multimodal",
|
|
142
|
+
"min_extraction_confidence",
|
|
143
|
+
}
|
|
144
|
+
clean = {k: v for k, v in (patch or {}).items() if k in allowed}
|
|
145
|
+
with self._lock:
|
|
146
|
+
data = self._read()
|
|
147
|
+
previous = self._resolve_raw(
|
|
148
|
+
data, user_email=user_email, workspace_id=workspace_id
|
|
149
|
+
)
|
|
150
|
+
if workspace_id:
|
|
151
|
+
bucket = data.setdefault("workspaces", {})
|
|
152
|
+
current = dict(bucket.get(str(workspace_id)) or {})
|
|
153
|
+
current.update(clean)
|
|
154
|
+
bucket[str(workspace_id)] = current
|
|
155
|
+
elif user_email:
|
|
156
|
+
bucket = data.setdefault("users", {})
|
|
157
|
+
key = str(user_email).lower()
|
|
158
|
+
current = dict(bucket.get(key) or {})
|
|
159
|
+
current.update(clean)
|
|
160
|
+
bucket[key] = current
|
|
161
|
+
else:
|
|
162
|
+
current = dict(data.get("default") or _default_policy())
|
|
163
|
+
current.update(clean)
|
|
164
|
+
data["default"] = current
|
|
165
|
+
self._write(data)
|
|
166
|
+
resolved = self.resolve(user_email=user_email, workspace_id=workspace_id)
|
|
167
|
+
self._audit(
|
|
168
|
+
"hybrid_policy_changed",
|
|
169
|
+
user_email=user_email,
|
|
170
|
+
workspace_id=workspace_id,
|
|
171
|
+
previous=previous,
|
|
172
|
+
policy=resolved,
|
|
173
|
+
source=source,
|
|
174
|
+
)
|
|
175
|
+
return resolved
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
__all__ = ["HybridPolicyService", "DEFAULT_AUTO_COMMIT", "DEFAULT_ALLOW_MULTIMODAL"]
|
|
@@ -26,8 +26,8 @@ import logging
|
|
|
26
26
|
from pathlib import Path
|
|
27
27
|
from typing import Any, Dict, List, Optional
|
|
28
28
|
|
|
29
|
-
from latticeai.core.workspace_os_utils import _file_size
|
|
30
29
|
from latticeai.core.timeutil import now_iso as _now
|
|
30
|
+
from latticeai.core.workspace_os_utils import _file_size
|
|
31
31
|
|
|
32
32
|
# Personal workspace memory kinds (from WorkspaceOS.MEMORY_KINDS).
|
|
33
33
|
WORKSPACE_KINDS = (
|