ltcai 10.9.0 → 11.0.1
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 +46 -64
- package/docs/CHANGELOG.md +72 -237
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/PERFORMANCE.md +78 -7
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/docs/v11.1.0_PRODUCT_INTELLIGENCE_PLAN.md +313 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_contract.py +11 -0
- package/lattice_brain/graph/curator.py +1 -1
- package/lattice_brain/graph/fusion.py +184 -3
- package/lattice_brain/graph/proactive.py +138 -1
- package/lattice_brain/graph/projection.py +10 -2
- package/lattice_brain/graph/retrieval.py +137 -7
- package/lattice_brain/graph/retrieval_docgen.py +20 -20
- package/lattice_brain/graph/retrieval_policy.py +6 -0
- package/lattice_brain/graph/retrieval_reads.py +188 -1
- package/lattice_brain/graph/retrieval_vector.py +474 -110
- package/lattice_brain/graph/schema.py +125 -2
- package/lattice_brain/graph/vector_index/__init__.py +85 -0
- package/lattice_brain/graph/vector_index/base.py +170 -0
- package/lattice_brain/graph/vector_index/brute_force.py +114 -0
- package/lattice_brain/graph/vector_index/hnsw.py +293 -0
- package/lattice_brain/graph/vector_index/jobs.py +287 -0
- package/lattice_brain/graph/vector_index/quantized.py +151 -0
- package/lattice_brain/graph/vector_index/selector.py +131 -0
- package/lattice_brain/ingestion.py +50 -3
- package/lattice_brain/portability.py +654 -2
- package/lattice_brain/runtime/agent_runtime.py +1 -1
- package/lattice_brain/runtime/contracts.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/self_model.py +620 -0
- package/lattice_brain/synthesis.py +801 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +83 -1
- package/latticeai/api/chat_stream.py +4 -1
- package/latticeai/api/local_files.py +62 -0
- package/latticeai/api/models.py +1 -1
- package/latticeai/api/portability.py +130 -1
- package/latticeai/api/security_dashboard.py +48 -13
- package/latticeai/api/voice_capture.py +4 -1
- package/latticeai/api/workspace.py +11 -5
- package/latticeai/core/embedding_providers.py +20 -1
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +14 -0
- package/latticeai/core/model_compat.py +2 -2
- package/latticeai/core/tool_registry.py +0 -7
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_os_utils.py +4 -50
- package/latticeai/core/workspace_review_items.py +12 -1
- package/latticeai/integrations/telegram_bot.py +28 -10
- package/latticeai/models/router.py +1 -1
- package/latticeai/runtime/access_runtime.py +1 -1
- package/latticeai/runtime/network_boundary_wiring.py +9 -5
- package/latticeai/runtime/permission_mode_wiring.py +9 -6
- package/latticeai/runtime/router_registration.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +253 -0
- package/latticeai/services/memory_service.py +1 -1
- package/latticeai/services/model_catalog.py +4 -3
- package/latticeai/services/model_engines.py +28 -14
- package/latticeai/services/obsidian_bridge.py +618 -0
- package/latticeai/services/product_readiness.py +5 -3
- package/latticeai/tools/filesystem.py +4 -1
- package/package.json +1 -1
- package/scripts/bench_vector_index.py +295 -0
- package/scripts/check_current_release_docs.mjs +4 -2
- package/scripts/release_screen_claims.json +31 -0
- 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-CS9IeqUX.js → Act-D4zSxFR-.js} +1 -1
- package/static/app/assets/{AdminConsole-3UkIEWGA.js → AdminConsole-w5jBfPt2.js} +1 -1
- package/static/app/assets/{Brain-B22EmNqS.js → Brain-C2EqQg74.js} +2 -2
- package/static/app/assets/BrainHome-CvXS6XiQ.js +2 -0
- package/static/app/assets/BrainSignals-DOE_KhOU.js +1 -0
- package/static/app/assets/Capture-DPqpGK8d.js +1 -0
- package/static/app/assets/{CommandPalette-86m4FCcN.js → CommandPalette-CNf7h5fp.js} +1 -1
- package/static/app/assets/Library-BN0HYOfc.js +1 -0
- package/static/app/assets/LivingBrain-Dfq_wEDI.js +1 -0
- package/static/app/assets/ProductFlow-B-3O0rNV.js +1 -0
- package/static/app/assets/{ReviewCard-BepjSDpN.js → ReviewCard-gZ-tdqFM.js} +1 -1
- package/static/app/assets/System-BElUcSSw.js +1 -0
- package/static/app/assets/arrow-left-CFNIMjhv.js +1 -0
- package/static/app/assets/{bot-DQj0-LkM.js → bot--qYHMtkP.js} +1 -1
- package/static/app/assets/brain-DDCLjRqO.js +1 -0
- package/static/app/assets/{button-CmTknyAP.js → button-51Z3rsuv.js} +1 -1
- package/static/app/assets/{circle-pause-yTCWRziJ.js → circle-pause-CMIiMaQl.js} +1 -1
- package/static/app/assets/{circle-play-Ccrva84R.js → circle-play-DZoO_cfG.js} +1 -1
- package/static/app/assets/{cpu-CbJqWTlS.js → cpu-Bs6uc9W9.js} +1 -1
- package/static/app/assets/{download-CkSzbzU-.js → download-G-2olkWz.js} +1 -1
- package/static/app/assets/{folder-open-CKyjQ4PU.js → folder-open-CTOspnmb.js} +1 -1
- package/static/app/assets/{hard-drive-DAzk9um0.js → hard-drive-CewHWJhn.js} +1 -1
- package/static/app/assets/index-CkzokZAj.css +2 -0
- package/static/app/assets/{index-CxOcwsHV.js → index-D7Rr-J2Y.js} +3 -3
- package/static/app/assets/{input-DcMETmZ7.js → input-D4w_BZWl.js} +1 -1
- package/static/app/assets/{permissionCopy-BVf13_25.js → permissionCopy-CosBEXAZ.js} +1 -1
- package/static/app/assets/primitives-d0g9pvzS.js +1 -0
- package/static/app/assets/search-BLCYt75v.js +1 -0
- package/static/app/assets/{share-2-COWCHNZm.js → share-2-NmD7e_oV.js} +1 -1
- package/static/app/assets/{shield-alert-BDrvilyK.js → shield-alert-CcQeMuju.js} +1 -1
- package/static/app/assets/{textarea-rUmsc8cP.js → textarea-BPAJDc-0.js} +1 -1
- package/static/app/assets/{useFocusTrap-Bi5UY_8v.js → useFocusTrap-C7YLdTBC.js} +1 -1
- package/static/app/assets/{useQuery-C-AicB-3.js → useQuery-DRyD9opW.js} +1 -1
- package/static/app/assets/{utils-BMwWg78e.js → utils-DG1_ExrP.js} +3 -3
- package/static/app/assets/{workspace-Y93tls8P.js → workspace-CWVf3gsI.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/BrainHome-CMDqgJF4.js +0 -2
- package/static/app/assets/BrainSignals-BeE8RJo3.js +0 -1
- package/static/app/assets/Capture-ZX9bQh68.js +0 -1
- package/static/app/assets/Library-Bhz5LUca.js +0 -1
- package/static/app/assets/LivingBrain-BSa0wpFG.js +0 -1
- package/static/app/assets/ProductFlow-IP4Q-5aQ.js +0 -1
- package/static/app/assets/System-Bwx1h_jT.js +0 -1
- package/static/app/assets/arrow-left-ig7AZU8B.js +0 -1
- package/static/app/assets/brain-D8OEwmVj.js +0 -1
- package/static/app/assets/index-BfD-jhA9.css +0 -2
- package/static/app/assets/primitives-CQV9Q2YM.js +0 -1
- package/static/app/assets/search-CXGASMMH.js +0 -1
|
@@ -16,6 +16,8 @@ any cloud service. Two complementary mechanisms, both fully local:
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import base64
|
|
20
|
+
import hashlib
|
|
19
21
|
import json
|
|
20
22
|
import logging
|
|
21
23
|
import os
|
|
@@ -25,9 +27,17 @@ import tempfile
|
|
|
25
27
|
import zipfile
|
|
26
28
|
from contextlib import closing
|
|
27
29
|
from pathlib import Path, PurePosixPath
|
|
28
|
-
from typing import Any, Dict, Optional
|
|
30
|
+
from typing import Any, Dict, Iterable, List, Optional, Set
|
|
29
31
|
|
|
30
|
-
from .
|
|
32
|
+
from cryptography.exceptions import InvalidTag
|
|
33
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
34
|
+
|
|
35
|
+
from .archive import (
|
|
36
|
+
KDF_ITERATIONS,
|
|
37
|
+
BrainArchivePaths,
|
|
38
|
+
EncryptedBrainArchive,
|
|
39
|
+
_derive_key,
|
|
40
|
+
)
|
|
31
41
|
from .quiet import quiet
|
|
32
42
|
from .storage import (
|
|
33
43
|
DockerPostgresWizard,
|
|
@@ -41,6 +51,53 @@ FORMAT = "latticeai.kg.export"
|
|
|
41
51
|
FORMAT_VERSION = 1
|
|
42
52
|
BACKUP_FORMAT = "latticeai.kg.backup"
|
|
43
53
|
|
|
54
|
+
# ── selective subgraph share (v11.1.0 prototype, off by default) ─────────────
|
|
55
|
+
#: Logical format of a *partial* bundle: a chosen slice of the graph plus its
|
|
56
|
+
#: provenance, signed by this device. Deliberately distinct from ``FORMAT`` so
|
|
57
|
+
#: the whole-graph importer can never be handed a subgraph by accident.
|
|
58
|
+
SUBGRAPH_FORMAT = "latticeai.kg.subgraph"
|
|
59
|
+
SUBGRAPH_FORMAT_VERSION = 1
|
|
60
|
+
#: Encrypted on-disk envelope for the same bundle, reusing the ``.latticebrain``
|
|
61
|
+
#: passphrase mechanism (PBKDF2-SHA256 + AES-256-GCM) from ``archive.py``.
|
|
62
|
+
SUBGRAPH_ARCHIVE_FORMAT = "latticebrain.subgraph"
|
|
63
|
+
#: Opt-in gate. Off unless the operator sets it: sharing knowledge with another
|
|
64
|
+
#: Brain is a network act, and local-first means those are never on by default.
|
|
65
|
+
BRAIN_NETWORK_ENV = "LATTICEAI_BRAIN_NETWORK"
|
|
66
|
+
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
|
67
|
+
#: A received bundle becomes review items, never a merge. The cap keeps one
|
|
68
|
+
#: peer from flooding the inbox; what it dropped is reported, not hidden.
|
|
69
|
+
SUBGRAPH_PROPOSAL_CAP = 200
|
|
70
|
+
#: Review-queue vocabulary. ``kg_change_digest`` is the existing source for
|
|
71
|
+
#: "the graph would change"; the kind distinguishes shared-subgraph items.
|
|
72
|
+
SUBGRAPH_REVIEW_SOURCE = "kg_change_digest"
|
|
73
|
+
SUBGRAPH_REVIEW_KIND = "shared_subgraph_node"
|
|
74
|
+
#: Mirrors ``services.review_queue.OPEN_STATUSES``. Duplicated as a literal
|
|
75
|
+
#: rather than imported because ``lattice_brain`` must not depend on the
|
|
76
|
+
#: ``latticeai`` service layer; the review sink is injected, not imported.
|
|
77
|
+
_OPEN_REVIEW_STATUSES = frozenset({"pending", "snoozed"})
|
|
78
|
+
|
|
79
|
+
BRAIN_NETWORK_DISABLED_DETAIL = (
|
|
80
|
+
"Brain Network sharing is off. It is opt-in by design: set "
|
|
81
|
+
f"{BRAIN_NETWORK_ENV}=1 to enable selective subgraph export and receipt."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class BrainNetworkDisabled(PermissionError):
|
|
86
|
+
"""Raised when a share surface is used while the opt-in flag is off."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, detail: str = BRAIN_NETWORK_DISABLED_DETAIL) -> None:
|
|
89
|
+
super().__init__(detail)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def brain_network_enabled() -> bool:
|
|
93
|
+
"""True only when the operator explicitly opted in (default: off)."""
|
|
94
|
+
return os.getenv(BRAIN_NETWORK_ENV, "").strip().lower() in _TRUTHY
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def require_brain_network() -> None:
|
|
98
|
+
if not brain_network_enabled():
|
|
99
|
+
raise BrainNetworkDisabled()
|
|
100
|
+
|
|
44
101
|
|
|
45
102
|
def _stamp() -> str:
|
|
46
103
|
return utc_now_iso().replace(":", "").replace("-", "").replace(".", "")[:15]
|
|
@@ -163,6 +220,140 @@ def _replace_tree_with_backup(src: Optional[Path], dest: Path, backup_dir: Path)
|
|
|
163
220
|
raise
|
|
164
221
|
|
|
165
222
|
|
|
223
|
+
#: Sender-identifying fields stripped from a shared bundle by default. The
|
|
224
|
+
#: knowledge travels; "which account, on which machine, at which path" does not.
|
|
225
|
+
_REDACTED_FIELDS = frozenset({"owner", "user_email", "source_uri", "permissions"})
|
|
226
|
+
#: The signed payload's key set. One tuple so export and verify can never
|
|
227
|
+
#: disagree about what the digest covers.
|
|
228
|
+
_PAYLOAD_KEYS = (
|
|
229
|
+
"nodes", "chunk_nodes", "edges", "chunks", "knowledge_sources", "provenance",
|
|
230
|
+
)
|
|
231
|
+
#: Node types a one-hop expansion never admits — they describe the sender
|
|
232
|
+
#: (identity, local paths), not the knowledge being shared.
|
|
233
|
+
_NEIGHBOR_EXCLUDED_TYPES = frozenset({"Person", "Source"})
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _canonical_digest(payload: Dict[str, Any]) -> str:
|
|
237
|
+
"""sha256 over the canonical JSON of a bundle payload.
|
|
238
|
+
|
|
239
|
+
Pinned in the signed header so the Ed25519 signature covers the contents,
|
|
240
|
+
not just the manifest describing them.
|
|
241
|
+
"""
|
|
242
|
+
canonical = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
|
243
|
+
return hashlib.sha256(canonical).hexdigest()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _node_source_type(node: Dict[str, Any]) -> str:
|
|
247
|
+
try:
|
|
248
|
+
metadata = json.loads(node.get("metadata_json") or "{}")
|
|
249
|
+
except (TypeError, ValueError):
|
|
250
|
+
return ""
|
|
251
|
+
if not isinstance(metadata, dict):
|
|
252
|
+
return ""
|
|
253
|
+
return str(metadata.get("source_type") or "").strip().lower()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _select_node_ids(nodes: List[Dict[str, Any]], selection: Dict[str, Any]) -> Set[str]:
|
|
257
|
+
wanted_ids = set(selection["node_ids"])
|
|
258
|
+
wanted_types = set(selection["node_types"])
|
|
259
|
+
wanted_sources = set(selection["source_types"])
|
|
260
|
+
keep: Set[str] = set()
|
|
261
|
+
for node in nodes:
|
|
262
|
+
node_id = str(node.get("id"))
|
|
263
|
+
if node_id in wanted_ids:
|
|
264
|
+
keep.add(node_id)
|
|
265
|
+
continue
|
|
266
|
+
if wanted_types and str(node.get("type") or "").strip().lower() in wanted_types:
|
|
267
|
+
keep.add(node_id)
|
|
268
|
+
continue
|
|
269
|
+
if wanted_sources and _node_source_type(node) in wanted_sources:
|
|
270
|
+
keep.add(node_id)
|
|
271
|
+
return keep
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _expand_neighbors(
|
|
275
|
+
keep: Set[str], edges: List[Dict[str, Any]], known: Dict[str, str]
|
|
276
|
+
) -> Set[str]:
|
|
277
|
+
"""One hop out from the selection, in both directions, within the scope.
|
|
278
|
+
|
|
279
|
+
``Person`` and ``Source`` neighbours are never pulled in implicitly: a
|
|
280
|
+
Person node's label *is* an email address and a Source node's summary *is*
|
|
281
|
+
a local file path, so "share this decision and what it touches" would
|
|
282
|
+
quietly ship the sender's identity and directory layout. Naming one of
|
|
283
|
+
those node ids explicitly still exports it — that is a decision, not a
|
|
284
|
+
side effect.
|
|
285
|
+
"""
|
|
286
|
+
expanded = set(keep)
|
|
287
|
+
|
|
288
|
+
def _admit(candidate: str) -> None:
|
|
289
|
+
if candidate in known and known[candidate] not in _NEIGHBOR_EXCLUDED_TYPES:
|
|
290
|
+
expanded.add(candidate)
|
|
291
|
+
|
|
292
|
+
for edge in edges:
|
|
293
|
+
source = str(edge.get("from_node"))
|
|
294
|
+
target = str(edge.get("to_node"))
|
|
295
|
+
if source in keep:
|
|
296
|
+
_admit(target)
|
|
297
|
+
if target in keep:
|
|
298
|
+
_admit(source)
|
|
299
|
+
return expanded
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _strip_fields(raw_json: Optional[str]) -> Optional[str]:
|
|
303
|
+
if not raw_json:
|
|
304
|
+
return raw_json
|
|
305
|
+
try:
|
|
306
|
+
parsed = json.loads(raw_json)
|
|
307
|
+
except (TypeError, ValueError):
|
|
308
|
+
return raw_json
|
|
309
|
+
if not isinstance(parsed, dict):
|
|
310
|
+
return raw_json
|
|
311
|
+
cleaned = {k: v for k, v in parsed.items() if k not in _REDACTED_FIELDS}
|
|
312
|
+
return json.dumps(cleaned, ensure_ascii=False, sort_keys=True)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _redact_node(node: Dict[str, Any]) -> Dict[str, Any]:
|
|
316
|
+
redacted = dict(node)
|
|
317
|
+
redacted["metadata_json"] = _strip_fields(node.get("metadata_json"))
|
|
318
|
+
redacted["raw_json"] = _strip_fields(node.get("raw_json"))
|
|
319
|
+
return redacted
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _redact_provenance_row(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
323
|
+
redacted = dict(row)
|
|
324
|
+
redacted["owner"] = None
|
|
325
|
+
redacted["source_uri"] = None
|
|
326
|
+
redacted["permissions_json"] = "{}"
|
|
327
|
+
redacted["metadata_json"] = _strip_fields(row.get("metadata_json"))
|
|
328
|
+
return redacted
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _scope_node(node: Dict[str, Any], workspace_id: str) -> Dict[str, Any]:
|
|
332
|
+
"""Stamp the accepting workspace onto an incoming node.
|
|
333
|
+
|
|
334
|
+
Received knowledge belongs to the workspace that accepted it. Without this
|
|
335
|
+
the projection would file it as legacy-global — machine-shared — which is a
|
|
336
|
+
wider scope than the reviewer chose.
|
|
337
|
+
"""
|
|
338
|
+
scoped = dict(node)
|
|
339
|
+
try:
|
|
340
|
+
metadata = json.loads(scoped.get("metadata_json") or "{}")
|
|
341
|
+
except (TypeError, ValueError):
|
|
342
|
+
metadata = {}
|
|
343
|
+
if not isinstance(metadata, dict):
|
|
344
|
+
metadata = {}
|
|
345
|
+
metadata["workspace_id"] = workspace_id
|
|
346
|
+
scoped["metadata_json"] = json.dumps(metadata, ensure_ascii=False, sort_keys=True)
|
|
347
|
+
return scoped
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _shared_node_summary(node: Dict[str, Any], verdict: Dict[str, Any]) -> str:
|
|
351
|
+
body = str(node.get("summary") or "").strip()
|
|
352
|
+
origin = verdict.get("origin") or "unknown device"
|
|
353
|
+
prefix = f"[{origin}] "
|
|
354
|
+
return (prefix + body)[:500] if body else prefix.strip()
|
|
355
|
+
|
|
356
|
+
|
|
166
357
|
class KGPortabilityService:
|
|
167
358
|
def __init__(self, *, knowledge_graph: Any, data_dir, enable_graph: bool = True, device_identity: Any = None) -> None:
|
|
168
359
|
self._kg = knowledge_graph
|
|
@@ -260,6 +451,467 @@ class KGPortabilityService:
|
|
|
260
451
|
artifact = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
261
452
|
return self.import_data(artifact, mode=mode, dry_run=dry_run)
|
|
262
453
|
|
|
454
|
+
# ── selective subgraph share (opt-in prototype, off by default) ──────────
|
|
455
|
+
def share_status(self) -> Dict[str, Any]:
|
|
456
|
+
"""Honest read of the share surface. Safe to call while it is off.
|
|
457
|
+
|
|
458
|
+
A status route that refuses while disabled cannot tell a UI *why* the
|
|
459
|
+
feature is missing, so this one always answers — with ``enabled:
|
|
460
|
+
false`` and the flag that turns it on.
|
|
461
|
+
"""
|
|
462
|
+
enabled = brain_network_enabled()
|
|
463
|
+
return {
|
|
464
|
+
"enabled": enabled,
|
|
465
|
+
"flag": BRAIN_NETWORK_ENV,
|
|
466
|
+
"format": SUBGRAPH_FORMAT,
|
|
467
|
+
"format_version": SUBGRAPH_FORMAT_VERSION,
|
|
468
|
+
"graph_available": self.available(),
|
|
469
|
+
"signing": self._identity is not None,
|
|
470
|
+
"device": self._share_device(),
|
|
471
|
+
"proposal_cap": SUBGRAPH_PROPOSAL_CAP,
|
|
472
|
+
# Stated rather than implied: the bundle is signed by this device
|
|
473
|
+
# and encrypted with a shared passphrase. Encrypting *to a
|
|
474
|
+
# recipient's public key* is not implemented in this release.
|
|
475
|
+
"encryption": "passphrase",
|
|
476
|
+
"recipient_public_key_encryption": False,
|
|
477
|
+
"detail": None if enabled else BRAIN_NETWORK_DISABLED_DETAIL,
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
def _share_device(self) -> Dict[str, Any]:
|
|
481
|
+
"""Signer identity for a bundle — key material only, no storage hints."""
|
|
482
|
+
if self._identity is None:
|
|
483
|
+
return {}
|
|
484
|
+
described = self._identity.describe()
|
|
485
|
+
return {
|
|
486
|
+
"fingerprint": described.get("fingerprint"),
|
|
487
|
+
"public_key": described.get("public_key"),
|
|
488
|
+
"algorithm": described.get("algorithm"),
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
def export_subgraph(
|
|
492
|
+
self,
|
|
493
|
+
*,
|
|
494
|
+
node_ids: Optional[Iterable[str]] = None,
|
|
495
|
+
node_types: Optional[Iterable[str]] = None,
|
|
496
|
+
source_types: Optional[Iterable[str]] = None,
|
|
497
|
+
workspace_id: Optional[str] = None,
|
|
498
|
+
include_legacy_global: bool = False,
|
|
499
|
+
include_neighbors: bool = False,
|
|
500
|
+
redact_provenance: bool = True,
|
|
501
|
+
) -> Dict[str, Any]:
|
|
502
|
+
"""A chosen slice of the graph, with provenance, signed by this device.
|
|
503
|
+
|
|
504
|
+
At least one selector is required. "Share everything" already exists as
|
|
505
|
+
:meth:`export`; a *selective* door that silently defaults to the whole
|
|
506
|
+
Brain is how an accident becomes a leak.
|
|
507
|
+
|
|
508
|
+
``include_neighbors`` widens the selection by one hop (both directions)
|
|
509
|
+
so "share this decision" can carry the decision's immediate relations.
|
|
510
|
+
``redact_provenance`` (default on) strips the sender's local identity —
|
|
511
|
+
owner, source URI, permissions — from both node metadata and the
|
|
512
|
+
provenance rows, and records that it did so in the header.
|
|
513
|
+
|
|
514
|
+
Knowledge-source rows (connected folder registrations, with their local
|
|
515
|
+
paths) are never included; they describe the sender's machine, not the
|
|
516
|
+
knowledge.
|
|
517
|
+
"""
|
|
518
|
+
require_brain_network()
|
|
519
|
+
self._require()
|
|
520
|
+
if self._identity is None:
|
|
521
|
+
raise RuntimeError(
|
|
522
|
+
"A device identity is required to share a subgraph; "
|
|
523
|
+
"an unsigned bundle cannot be attributed and would be refused."
|
|
524
|
+
)
|
|
525
|
+
wanted_ids = sorted({str(v) for v in (node_ids or []) if str(v).strip()})
|
|
526
|
+
wanted_types = sorted({str(v).strip().lower() for v in (node_types or []) if str(v).strip()})
|
|
527
|
+
wanted_sources = sorted({str(v).strip().lower() for v in (source_types or []) if str(v).strip()})
|
|
528
|
+
selection: Dict[str, Any] = {
|
|
529
|
+
"node_ids": wanted_ids,
|
|
530
|
+
"node_types": wanted_types,
|
|
531
|
+
"source_types": wanted_sources,
|
|
532
|
+
"include_neighbors": bool(include_neighbors),
|
|
533
|
+
"workspace_id": workspace_id,
|
|
534
|
+
}
|
|
535
|
+
if not (wanted_ids or wanted_types or wanted_sources):
|
|
536
|
+
raise ValueError(
|
|
537
|
+
"A subgraph share must name what to share "
|
|
538
|
+
"(node_ids, node_types, or source_types)."
|
|
539
|
+
)
|
|
540
|
+
data = self._kg.export_graph_data(
|
|
541
|
+
workspace_id=workspace_id, include_legacy_global=include_legacy_global,
|
|
542
|
+
)
|
|
543
|
+
all_nodes = data.get("nodes") or []
|
|
544
|
+
# Chunks are derived retrieval units, not shareable subjects: they are
|
|
545
|
+
# never selectable, but they travel with the node they belong to so the
|
|
546
|
+
# receiver can actually search the shared content rather than a summary.
|
|
547
|
+
nodes = [n for n in all_nodes if str(n.get("type")) != "Chunk"]
|
|
548
|
+
chunk_nodes_by_id = {
|
|
549
|
+
str(n.get("id")): n for n in all_nodes if str(n.get("type")) == "Chunk"
|
|
550
|
+
}
|
|
551
|
+
edges = list(data.get("edges") or [])
|
|
552
|
+
keep = _select_node_ids(nodes, selection)
|
|
553
|
+
present = {str(n.get("id")): str(n.get("type") or "") for n in nodes}
|
|
554
|
+
missing = [nid for nid in wanted_ids if nid not in present]
|
|
555
|
+
if include_neighbors:
|
|
556
|
+
keep = _expand_neighbors(keep, edges, present)
|
|
557
|
+
if not keep:
|
|
558
|
+
raise ValueError("The selection matched no nodes; nothing was exported.")
|
|
559
|
+
kept_nodes = [n for n in nodes if str(n.get("id")) in keep]
|
|
560
|
+
kept_chunks = [c for c in data.get("chunks") or [] if str(c.get("source_node")) in keep]
|
|
561
|
+
kept_chunk_nodes = [
|
|
562
|
+
chunk_nodes_by_id[str(c.get("id"))]
|
|
563
|
+
for c in kept_chunks
|
|
564
|
+
if str(c.get("id")) in chunk_nodes_by_id
|
|
565
|
+
]
|
|
566
|
+
carried = keep | {str(n.get("id")) for n in kept_chunk_nodes}
|
|
567
|
+
kept_edges = [
|
|
568
|
+
e for e in edges
|
|
569
|
+
if str(e.get("from_node")) in carried and str(e.get("to_node")) in carried
|
|
570
|
+
]
|
|
571
|
+
kept_prov = [p for p in data.get("provenance") or [] if str(p.get("node_id")) in keep]
|
|
572
|
+
if redact_provenance:
|
|
573
|
+
kept_nodes = [_redact_node(n) for n in kept_nodes]
|
|
574
|
+
kept_chunk_nodes = [_redact_node(n) for n in kept_chunk_nodes]
|
|
575
|
+
kept_prov = [_redact_provenance_row(p) for p in kept_prov]
|
|
576
|
+
payload = {
|
|
577
|
+
"nodes": kept_nodes,
|
|
578
|
+
"chunk_nodes": kept_chunk_nodes,
|
|
579
|
+
"edges": kept_edges,
|
|
580
|
+
"chunks": kept_chunks,
|
|
581
|
+
"knowledge_sources": [],
|
|
582
|
+
"provenance": kept_prov,
|
|
583
|
+
}
|
|
584
|
+
counts = {key: len(value) for key, value in payload.items()}
|
|
585
|
+
header = {
|
|
586
|
+
"format": SUBGRAPH_FORMAT,
|
|
587
|
+
"format_version": SUBGRAPH_FORMAT_VERSION,
|
|
588
|
+
**self._kg.schema_versions(),
|
|
589
|
+
"exported_at": utc_now_iso(),
|
|
590
|
+
"workspace_id": workspace_id,
|
|
591
|
+
"selection": selection,
|
|
592
|
+
"counts": counts,
|
|
593
|
+
"unmatched_node_ids": missing,
|
|
594
|
+
"redacted": sorted(_REDACTED_FIELDS) if redact_provenance else [],
|
|
595
|
+
"includes_knowledge_sources": False,
|
|
596
|
+
"device": self._share_device(),
|
|
597
|
+
# The signature covers the header; the header pins the payload.
|
|
598
|
+
"payload_sha256": _canonical_digest(payload),
|
|
599
|
+
}
|
|
600
|
+
return {
|
|
601
|
+
"header": header,
|
|
602
|
+
**payload,
|
|
603
|
+
"signature": self._identity.sign_manifest(header),
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
def export_subgraph_archive(
|
|
607
|
+
self,
|
|
608
|
+
dest_path=None,
|
|
609
|
+
*,
|
|
610
|
+
passphrase: str,
|
|
611
|
+
**selection: Any,
|
|
612
|
+
) -> Dict[str, Any]:
|
|
613
|
+
"""Write the signed subgraph as an encrypted ``.latticebrain`` file.
|
|
614
|
+
|
|
615
|
+
Same passphrase mechanism as the whole-Brain archive (PBKDF2-SHA256 →
|
|
616
|
+
AES-256-GCM), a different ``format`` so the two can never be confused.
|
|
617
|
+
The header and signature stay outside the ciphertext on purpose: a
|
|
618
|
+
recipient can see *who* sent a bundle, and how big it is, before typing
|
|
619
|
+
a passphrase into it.
|
|
620
|
+
"""
|
|
621
|
+
artifact = self.export_subgraph(**selection)
|
|
622
|
+
self._exports_dir.mkdir(parents=True, exist_ok=True)
|
|
623
|
+
dest = Path(dest_path) if dest_path else self._exports_dir / f"subgraph-{_stamp()}.latticebrain"
|
|
624
|
+
if dest.suffix != ".latticebrain":
|
|
625
|
+
dest = dest.with_suffix(".latticebrain")
|
|
626
|
+
body = {key: value for key, value in artifact.items() if key not in {"header", "signature"}}
|
|
627
|
+
plaintext = json.dumps(body, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
|
628
|
+
salt = os.urandom(16)
|
|
629
|
+
nonce = os.urandom(12)
|
|
630
|
+
ciphertext = AESGCM(_derive_key(passphrase, salt)).encrypt(nonce, plaintext, None)
|
|
631
|
+
envelope = {
|
|
632
|
+
"format": SUBGRAPH_ARCHIVE_FORMAT,
|
|
633
|
+
"format_version": SUBGRAPH_FORMAT_VERSION,
|
|
634
|
+
"created_at": utc_now_iso(),
|
|
635
|
+
"kdf": {
|
|
636
|
+
"name": "PBKDF2HMAC-SHA256",
|
|
637
|
+
"iterations": KDF_ITERATIONS,
|
|
638
|
+
"salt": base64.b64encode(salt).decode("ascii"),
|
|
639
|
+
},
|
|
640
|
+
"cipher": {"name": "AES-256-GCM", "nonce": base64.b64encode(nonce).decode("ascii")},
|
|
641
|
+
"header": artifact["header"],
|
|
642
|
+
"signature": artifact["signature"],
|
|
643
|
+
"payload": base64.b64encode(ciphertext).decode("ascii"),
|
|
644
|
+
}
|
|
645
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
646
|
+
dest.write_text(json.dumps(envelope, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
647
|
+
return {
|
|
648
|
+
"path": str(dest),
|
|
649
|
+
"bytes": dest.stat().st_size,
|
|
650
|
+
"encrypted": True,
|
|
651
|
+
"format": SUBGRAPH_ARCHIVE_FORMAT,
|
|
652
|
+
"header": artifact["header"],
|
|
653
|
+
"counts": artifact["header"]["counts"],
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
def read_subgraph_archive(self, path, *, passphrase: str) -> Dict[str, Any]:
|
|
657
|
+
"""Decrypt an encrypted subgraph bundle back into a signed artifact."""
|
|
658
|
+
require_brain_network()
|
|
659
|
+
source = Path(path)
|
|
660
|
+
if not source.exists():
|
|
661
|
+
raise FileNotFoundError(f"Subgraph bundle not found: {source}")
|
|
662
|
+
try:
|
|
663
|
+
envelope = json.loads(source.read_text(encoding="utf-8"))
|
|
664
|
+
except json.JSONDecodeError as exc:
|
|
665
|
+
raise ValueError("Subgraph bundle is not valid JSON.") from exc
|
|
666
|
+
if envelope.get("format") != SUBGRAPH_ARCHIVE_FORMAT:
|
|
667
|
+
raise ValueError("Not a .latticebrain subgraph bundle.")
|
|
668
|
+
try:
|
|
669
|
+
salt = base64.b64decode(envelope["kdf"]["salt"])
|
|
670
|
+
nonce = base64.b64decode(envelope["cipher"]["nonce"])
|
|
671
|
+
ciphertext = base64.b64decode(envelope["payload"])
|
|
672
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
673
|
+
raise ValueError("Subgraph bundle is missing encryption metadata.") from exc
|
|
674
|
+
try:
|
|
675
|
+
plaintext = AESGCM(_derive_key(passphrase, salt)).decrypt(nonce, ciphertext, None)
|
|
676
|
+
except InvalidTag as exc:
|
|
677
|
+
raise ValueError(
|
|
678
|
+
"Subgraph decryption failed; the passphrase or the bundle is invalid."
|
|
679
|
+
) from exc
|
|
680
|
+
body = json.loads(plaintext.decode("utf-8"))
|
|
681
|
+
return {
|
|
682
|
+
"header": envelope.get("header") or {},
|
|
683
|
+
**body,
|
|
684
|
+
"signature": envelope.get("signature") or {},
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
def verify_subgraph(self, artifact: Dict[str, Any]) -> Dict[str, Any]:
|
|
688
|
+
"""Signature + payload-digest verdict for a received bundle.
|
|
689
|
+
|
|
690
|
+
Fail-closed and specific: the caller learns *which* check failed, and
|
|
691
|
+
an unsigned bundle is a failure here (unlike a local file import, where
|
|
692
|
+
pre-v4 unsigned artifacts stay importable).
|
|
693
|
+
"""
|
|
694
|
+
errors: List[str] = []
|
|
695
|
+
header = artifact.get("header") if isinstance(artifact, dict) else None
|
|
696
|
+
header = header if isinstance(header, dict) else {}
|
|
697
|
+
signature = artifact.get("signature") if isinstance(artifact, dict) else None
|
|
698
|
+
signature = signature if isinstance(signature, dict) else {}
|
|
699
|
+
if header.get("format") != SUBGRAPH_FORMAT:
|
|
700
|
+
errors.append("not a Lattice subgraph bundle")
|
|
701
|
+
incoming_version = int(header.get("format_version") or 0)
|
|
702
|
+
if incoming_version > SUBGRAPH_FORMAT_VERSION:
|
|
703
|
+
errors.append(
|
|
704
|
+
f"bundle format version {incoming_version} is newer than this build "
|
|
705
|
+
f"({SUBGRAPH_FORMAT_VERSION})"
|
|
706
|
+
)
|
|
707
|
+
if not signature.get("signature"):
|
|
708
|
+
errors.append("bundle is unsigned")
|
|
709
|
+
else:
|
|
710
|
+
from .graph.identity import verify_manifest
|
|
711
|
+
|
|
712
|
+
if not verify_manifest(header, signature):
|
|
713
|
+
errors.append("signature does not match the bundle header")
|
|
714
|
+
expected = header.get("payload_sha256")
|
|
715
|
+
if expected:
|
|
716
|
+
payload = {key: artifact.get(key) or [] for key in _PAYLOAD_KEYS}
|
|
717
|
+
if _canonical_digest(payload) != expected:
|
|
718
|
+
errors.append("bundle contents do not match the signed digest")
|
|
719
|
+
else:
|
|
720
|
+
errors.append("bundle header does not pin its payload digest")
|
|
721
|
+
fingerprint = str(signature.get("fingerprint") or "") or None
|
|
722
|
+
return {
|
|
723
|
+
"ok": not errors,
|
|
724
|
+
"errors": errors,
|
|
725
|
+
"fingerprint": fingerprint,
|
|
726
|
+
"origin": f"device:{fingerprint}" if fingerprint and not errors else None,
|
|
727
|
+
"exported_at": header.get("exported_at"),
|
|
728
|
+
"counts": header.get("counts") or {},
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
def import_subgraph_proposals(
|
|
732
|
+
self,
|
|
733
|
+
artifact: Dict[str, Any],
|
|
734
|
+
*,
|
|
735
|
+
review_sink: Any,
|
|
736
|
+
workspace_id: Optional[str] = None,
|
|
737
|
+
user_email: Optional[str] = None,
|
|
738
|
+
dry_run: bool = False,
|
|
739
|
+
cap: int = SUBGRAPH_PROPOSAL_CAP,
|
|
740
|
+
) -> Dict[str, Any]:
|
|
741
|
+
"""Receive a subgraph as *proposals*, never as a merge.
|
|
742
|
+
|
|
743
|
+
Every node becomes one review item carrying its edges, chunks, and
|
|
744
|
+
provenance, stamped with the sending device's fingerprint and the
|
|
745
|
+
signature verdict. Nothing reaches the graph until a human accepts an
|
|
746
|
+
item through :meth:`accept_subgraph_proposal`.
|
|
747
|
+
"""
|
|
748
|
+
require_brain_network()
|
|
749
|
+
self._require()
|
|
750
|
+
verdict = self.verify_subgraph(artifact)
|
|
751
|
+
if not verdict["ok"]:
|
|
752
|
+
raise ValueError(
|
|
753
|
+
"Refusing the bundle: " + "; ".join(verdict["errors"])
|
|
754
|
+
)
|
|
755
|
+
header = dict(artifact.get("header") or {})
|
|
756
|
+
nodes = [n for n in artifact.get("nodes") or [] if n.get("id")]
|
|
757
|
+
edges = list(artifact.get("edges") or [])
|
|
758
|
+
chunks = list(artifact.get("chunks") or [])
|
|
759
|
+
chunk_nodes = {str(n.get("id")): n for n in artifact.get("chunk_nodes") or []}
|
|
760
|
+
provenance = list(artifact.get("provenance") or [])
|
|
761
|
+
cap = max(1, int(cap))
|
|
762
|
+
selected = nodes[:cap]
|
|
763
|
+
summary: Dict[str, Any] = {
|
|
764
|
+
"status": "dry_run" if dry_run else "proposed",
|
|
765
|
+
"origin": verdict["origin"],
|
|
766
|
+
"fingerprint": verdict["fingerprint"],
|
|
767
|
+
"signed": True,
|
|
768
|
+
"signature_verified": True,
|
|
769
|
+
"nodes": len(nodes),
|
|
770
|
+
"edges": len(edges),
|
|
771
|
+
"chunks": len(chunks),
|
|
772
|
+
"proposed": len(selected),
|
|
773
|
+
"skipped": len(nodes) - len(selected),
|
|
774
|
+
"capped": len(nodes) > len(selected),
|
|
775
|
+
"exported_at": header.get("exported_at"),
|
|
776
|
+
"items": [],
|
|
777
|
+
}
|
|
778
|
+
if dry_run:
|
|
779
|
+
return summary
|
|
780
|
+
for node in selected:
|
|
781
|
+
node_id = str(node.get("id"))
|
|
782
|
+
node_chunks = [c for c in chunks if str(c.get("source_node")) == node_id]
|
|
783
|
+
carried = {node_id} | {str(c.get("id")) for c in node_chunks}
|
|
784
|
+
item = review_sink.create(
|
|
785
|
+
title=str(node.get("title") or node_id)[:200],
|
|
786
|
+
summary=_shared_node_summary(node, verdict),
|
|
787
|
+
source=SUBGRAPH_REVIEW_SOURCE,
|
|
788
|
+
kind=SUBGRAPH_REVIEW_KIND,
|
|
789
|
+
payload={
|
|
790
|
+
"kind": SUBGRAPH_REVIEW_KIND,
|
|
791
|
+
"node": node,
|
|
792
|
+
"chunk_nodes": [
|
|
793
|
+
chunk_nodes[str(c.get("id"))]
|
|
794
|
+
for c in node_chunks
|
|
795
|
+
if str(c.get("id")) in chunk_nodes
|
|
796
|
+
],
|
|
797
|
+
"edges": [
|
|
798
|
+
e for e in edges
|
|
799
|
+
if str(e.get("from_node")) in carried or str(e.get("to_node")) in carried
|
|
800
|
+
],
|
|
801
|
+
"chunks": node_chunks,
|
|
802
|
+
"provenance": [p for p in provenance if str(p.get("node_id")) == node_id],
|
|
803
|
+
"graph_schema_version": header.get("graph_schema_version"),
|
|
804
|
+
},
|
|
805
|
+
provenance={
|
|
806
|
+
"origin": verdict["origin"],
|
|
807
|
+
"fingerprint": verdict["fingerprint"],
|
|
808
|
+
"public_key": (artifact.get("signature") or {}).get("public_key"),
|
|
809
|
+
"signature_verified": True,
|
|
810
|
+
"exported_at": header.get("exported_at"),
|
|
811
|
+
"source_detail": "brain_network_subgraph",
|
|
812
|
+
"redacted": header.get("redacted") or [],
|
|
813
|
+
},
|
|
814
|
+
user_email=user_email,
|
|
815
|
+
workspace_id=workspace_id,
|
|
816
|
+
)
|
|
817
|
+
summary["items"].append(item.get("id"))
|
|
818
|
+
try:
|
|
819
|
+
self._kg.record_provenance(
|
|
820
|
+
node_id="subgraph-proposal:" + str(header.get("exported_at") or utc_now_iso()),
|
|
821
|
+
source_type="subgraph_share",
|
|
822
|
+
pipeline="kg-portability",
|
|
823
|
+
owner=user_email,
|
|
824
|
+
workspace_id=workspace_id,
|
|
825
|
+
metadata={
|
|
826
|
+
"origin": verdict["origin"],
|
|
827
|
+
"proposed": len(selected),
|
|
828
|
+
"counts": header.get("counts"),
|
|
829
|
+
},
|
|
830
|
+
)
|
|
831
|
+
except Exception: # noqa: BLE001 — the proposals already landed
|
|
832
|
+
quiet()
|
|
833
|
+
return summary
|
|
834
|
+
|
|
835
|
+
def accept_subgraph_proposal(
|
|
836
|
+
self,
|
|
837
|
+
item_id: str,
|
|
838
|
+
*,
|
|
839
|
+
review_sink: Any,
|
|
840
|
+
workspace_id: Optional[str] = None,
|
|
841
|
+
) -> Dict[str, Any]:
|
|
842
|
+
"""Merge one accepted proposal into this Brain and approve the item.
|
|
843
|
+
|
|
844
|
+
Only the node the reviewer looked at is written. An edge whose other
|
|
845
|
+
endpoint is not (yet) in this Brain is *deferred* and reported, because
|
|
846
|
+
writing a dangling relation would invent a connection the receiver
|
|
847
|
+
cannot see.
|
|
848
|
+
"""
|
|
849
|
+
require_brain_network()
|
|
850
|
+
self._require()
|
|
851
|
+
item = review_sink.get(item_id, workspace_id=workspace_id)
|
|
852
|
+
payload = dict((item or {}).get("payload") or {})
|
|
853
|
+
if payload.get("kind") != SUBGRAPH_REVIEW_KIND:
|
|
854
|
+
raise ValueError("That review item is not a shared-subgraph proposal.")
|
|
855
|
+
node = dict(payload.get("node") or {})
|
|
856
|
+
node_id = str(node.get("id") or "")
|
|
857
|
+
if not node_id:
|
|
858
|
+
raise ValueError("The proposal carries no node id.")
|
|
859
|
+
status = str((item or {}).get("status") or "pending")
|
|
860
|
+
if status not in _OPEN_REVIEW_STATUSES:
|
|
861
|
+
# Import first, approve second would re-write an already-decided
|
|
862
|
+
# item; refuse before touching the graph instead.
|
|
863
|
+
raise ValueError(f"That proposal is already {status}; nothing was imported.")
|
|
864
|
+
chunk_nodes = [dict(n) for n in payload.get("chunk_nodes") or []]
|
|
865
|
+
if workspace_id:
|
|
866
|
+
node = _scope_node(node, workspace_id)
|
|
867
|
+
chunk_nodes = [_scope_node(n, workspace_id) for n in chunk_nodes]
|
|
868
|
+
# The chunk rows land in the same write, so their nodes count as
|
|
869
|
+
# present even though this Brain has not seen them yet.
|
|
870
|
+
writing = {node_id} | {str(n.get("id")) for n in chunk_nodes}
|
|
871
|
+
kept: List[Dict[str, Any]] = []
|
|
872
|
+
deferred: List[Dict[str, Any]] = []
|
|
873
|
+
for edge in payload.get("edges") or []:
|
|
874
|
+
endpoints = {str(edge.get("from_node")), str(edge.get("to_node"))}
|
|
875
|
+
outside = endpoints - writing
|
|
876
|
+
if all(self._node_present(other) for other in outside):
|
|
877
|
+
kept.append(edge)
|
|
878
|
+
else:
|
|
879
|
+
deferred.append({
|
|
880
|
+
"from": edge.get("from_node"),
|
|
881
|
+
"to": edge.get("to_node"),
|
|
882
|
+
"type": edge.get("type"),
|
|
883
|
+
"reason": "the other node is not in this Brain yet",
|
|
884
|
+
})
|
|
885
|
+
result = self._kg.import_graph_data(
|
|
886
|
+
{
|
|
887
|
+
"header": {"graph_schema_version": payload.get("graph_schema_version")},
|
|
888
|
+
"nodes": [node, *chunk_nodes],
|
|
889
|
+
"edges": kept,
|
|
890
|
+
"chunks": list(payload.get("chunks") or []),
|
|
891
|
+
"knowledge_sources": [],
|
|
892
|
+
"provenance": list(payload.get("provenance") or []),
|
|
893
|
+
},
|
|
894
|
+
mode="merge",
|
|
895
|
+
)
|
|
896
|
+
approved = review_sink.approve(item_id, workspace_id=workspace_id)
|
|
897
|
+
return {
|
|
898
|
+
"status": "accepted",
|
|
899
|
+
"item_id": item_id,
|
|
900
|
+
"node_id": node_id,
|
|
901
|
+
"imported": result,
|
|
902
|
+
"edges_written": len(kept),
|
|
903
|
+
"edges_deferred": deferred,
|
|
904
|
+
"origin": (item.get("provenance") or {}).get("origin"),
|
|
905
|
+
"review_status": approved.get("status"),
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
def _node_present(self, node_id: str) -> bool:
|
|
909
|
+
try:
|
|
910
|
+
self._kg.get_node(node_id)
|
|
911
|
+
except Exception: # noqa: BLE001 — "not found" is the answer, not a failure
|
|
912
|
+
return False
|
|
913
|
+
return True
|
|
914
|
+
|
|
263
915
|
# ── binary backup / restore ──────────────────────────────────────────────
|
|
264
916
|
def backup(self, dest_path=None) -> Dict[str, Any]:
|
|
265
917
|
self._require()
|