ltcai 9.0.0 → 9.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.
Files changed (189) hide show
  1. package/README.md +88 -46
  2. package/auto_setup.py +7 -853
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +178 -2
  6. package/docs/COMMUNITY_AND_PLUGINS.md +4 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/TRUST_MODEL.md +5 -2
  13. package/docs/WHY_LATTICE.md +15 -10
  14. package/docs/WORKFLOW_DESIGNER.md +22 -0
  15. package/docs/kg-schema.md +13 -2
  16. package/docs/mcp-tools.md +17 -6
  17. package/docs/privacy.md +19 -3
  18. package/docs/public-deploy.md +32 -3
  19. package/docs/security-model.md +46 -11
  20. package/lattice_brain/__init__.py +1 -1
  21. package/lattice_brain/archive.py +1 -6
  22. package/lattice_brain/context.py +66 -9
  23. package/lattice_brain/graph/_kg_fsutil.py +1 -5
  24. package/lattice_brain/graph/discovery_index.py +174 -20
  25. package/lattice_brain/graph/documents.py +44 -9
  26. package/lattice_brain/graph/ingest.py +47 -20
  27. package/lattice_brain/graph/provenance.py +13 -6
  28. package/lattice_brain/graph/retrieval.py +140 -39
  29. package/lattice_brain/graph/retrieval_docgen.py +37 -4
  30. package/lattice_brain/ingestion.py +4 -7
  31. package/lattice_brain/portability.py +28 -14
  32. package/lattice_brain/runtime/agent_runtime.py +5 -9
  33. package/lattice_brain/runtime/hooks.py +30 -13
  34. package/lattice_brain/runtime/multi_agent.py +27 -8
  35. package/lattice_brain/runtime/statuses.py +10 -0
  36. package/lattice_brain/utils.py +20 -2
  37. package/lattice_brain/workflow.py +1 -5
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/admin.py +1 -1
  40. package/latticeai/api/agent_registry.py +10 -8
  41. package/latticeai/api/agents.py +22 -3
  42. package/latticeai/api/auth.py +78 -16
  43. package/latticeai/api/browser.py +303 -34
  44. package/latticeai/api/chat.py +320 -850
  45. package/latticeai/api/chat_agent_http.py +356 -0
  46. package/latticeai/api/chat_contracts.py +54 -0
  47. package/latticeai/api/chat_documents.py +256 -0
  48. package/latticeai/api/chat_helpers.py +28 -1
  49. package/latticeai/api/chat_history.py +99 -0
  50. package/latticeai/api/chat_intents.py +316 -0
  51. package/latticeai/api/chat_stream.py +115 -0
  52. package/latticeai/api/computer_use.py +62 -9
  53. package/latticeai/api/health.py +9 -3
  54. package/latticeai/api/hooks.py +17 -6
  55. package/latticeai/api/knowledge_graph.py +78 -14
  56. package/latticeai/api/local_files.py +7 -2
  57. package/latticeai/api/mcp.py +102 -23
  58. package/latticeai/api/models.py +72 -33
  59. package/latticeai/api/network.py +5 -5
  60. package/latticeai/api/permissions.py +67 -26
  61. package/latticeai/api/plugins.py +21 -7
  62. package/latticeai/api/portability.py +5 -5
  63. package/latticeai/api/realtime.py +33 -5
  64. package/latticeai/api/setup.py +2 -2
  65. package/latticeai/api/static_routes.py +123 -24
  66. package/latticeai/api/tools.py +96 -14
  67. package/latticeai/api/workflow_designer.py +37 -0
  68. package/latticeai/api/workspace.py +2 -1
  69. package/latticeai/app_factory.py +110 -52
  70. package/latticeai/core/agent.py +50 -13
  71. package/latticeai/core/agent_prompts.py +5 -0
  72. package/latticeai/core/agent_registry.py +1 -5
  73. package/latticeai/core/config.py +23 -3
  74. package/latticeai/core/context_builder.py +21 -3
  75. package/latticeai/core/file_generation.py +451 -0
  76. package/latticeai/core/invitations.py +1 -4
  77. package/latticeai/core/io_utils.py +9 -1
  78. package/latticeai/core/legacy_compatibility.py +24 -3
  79. package/latticeai/core/marketplace.py +1 -1
  80. package/latticeai/core/plugins.py +15 -0
  81. package/latticeai/core/policy.py +1 -1
  82. package/latticeai/core/realtime.py +38 -15
  83. package/latticeai/core/sessions.py +7 -2
  84. package/latticeai/core/timeutil.py +10 -0
  85. package/latticeai/core/tool_registry.py +90 -18
  86. package/latticeai/core/users.py +1 -5
  87. package/latticeai/core/workspace_graph_trace.py +31 -5
  88. package/latticeai/core/workspace_memory.py +3 -1
  89. package/latticeai/core/workspace_os.py +18 -7
  90. package/latticeai/core/workspace_os_utils.py +0 -5
  91. package/latticeai/core/workspace_permissions.py +2 -1
  92. package/latticeai/core/workspace_plugins.py +1 -1
  93. package/latticeai/core/workspace_runs.py +14 -5
  94. package/latticeai/core/workspace_skills.py +1 -1
  95. package/latticeai/core/workspace_snapshots.py +2 -1
  96. package/latticeai/core/workspace_timeline.py +2 -1
  97. package/latticeai/integrations/telegram_bot.py +94 -43
  98. package/latticeai/models/router.py +130 -36
  99. package/latticeai/runtime/access_runtime.py +62 -4
  100. package/latticeai/runtime/automation_runtime.py +13 -7
  101. package/latticeai/runtime/brain_runtime.py +19 -7
  102. package/latticeai/runtime/chat_wiring.py +2 -0
  103. package/latticeai/runtime/config_runtime.py +58 -26
  104. package/latticeai/runtime/context_runtime.py +16 -4
  105. package/latticeai/runtime/hooks_runtime.py +1 -1
  106. package/latticeai/runtime/model_wiring.py +33 -5
  107. package/latticeai/runtime/namespace_runtime.py +62 -72
  108. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  109. package/latticeai/runtime/review_wiring.py +1 -1
  110. package/latticeai/runtime/router_registration.py +34 -1
  111. package/latticeai/runtime/security_runtime.py +110 -13
  112. package/latticeai/runtime/stages.py +27 -0
  113. package/latticeai/server_app.py +5 -1
  114. package/latticeai/services/architecture_readiness.py +74 -5
  115. package/latticeai/services/brain_automation.py +3 -26
  116. package/latticeai/services/chat_service.py +205 -15
  117. package/latticeai/services/local_knowledge.py +423 -0
  118. package/latticeai/services/memory_service.py +55 -21
  119. package/latticeai/services/model_engines.py +48 -33
  120. package/latticeai/services/model_errors.py +17 -0
  121. package/latticeai/services/model_loading.py +28 -25
  122. package/latticeai/services/model_runtime.py +228 -162
  123. package/latticeai/services/p_reinforce.py +22 -3
  124. package/latticeai/services/platform_runtime.py +83 -23
  125. package/latticeai/services/product_readiness.py +19 -17
  126. package/latticeai/services/review_queue.py +12 -0
  127. package/latticeai/services/router_context.py +1 -0
  128. package/latticeai/services/run_executor.py +4 -9
  129. package/latticeai/services/search_service.py +203 -28
  130. package/latticeai/services/tool_dispatch.py +28 -5
  131. package/latticeai/services/triggers.py +53 -4
  132. package/latticeai/services/upload_service.py +13 -2
  133. package/latticeai/setup/__init__.py +25 -0
  134. package/latticeai/setup/auto_setup.py +857 -0
  135. package/latticeai/setup/wizard.py +1264 -0
  136. package/latticeai/tools/__init__.py +278 -0
  137. package/{tools → latticeai/tools}/commands.py +67 -7
  138. package/{tools → latticeai/tools}/computer.py +1 -1
  139. package/{tools → latticeai/tools}/documents.py +1 -1
  140. package/{tools → latticeai/tools}/filesystem.py +3 -3
  141. package/latticeai/tools/knowledge.py +178 -0
  142. package/{tools → latticeai/tools}/local_files.py +1 -1
  143. package/{tools → latticeai/tools}/network.py +0 -1
  144. package/local_knowledge_api.py +4 -342
  145. package/package.json +22 -2
  146. package/scripts/brain_quality_eval.py +3 -1
  147. package/scripts/bump_version.py +3 -0
  148. package/scripts/capture_release_evidence.mjs +180 -0
  149. package/scripts/check_current_release_docs.mjs +142 -0
  150. package/scripts/check_i18n_literals.mjs +107 -31
  151. package/scripts/check_openapi_drift.mjs +56 -0
  152. package/scripts/export_openapi.py +67 -7
  153. package/scripts/i18n_literal_allowlist.json +22 -24
  154. package/scripts/run_integration_tests.mjs +72 -10
  155. package/scripts/validate_release_artifacts.py +49 -7
  156. package/scripts/wheel_smoke.py +5 -0
  157. package/setup_wizard.py +3 -1260
  158. package/src-tauri/Cargo.lock +1 -1
  159. package/src-tauri/Cargo.toml +1 -1
  160. package/src-tauri/tauri.conf.json +1 -1
  161. package/static/app/asset-manifest.json +11 -11
  162. package/static/app/assets/Act-C3dBrWE-.js +1 -0
  163. package/static/app/assets/Brain-DBYgdcjt.js +321 -0
  164. package/static/app/assets/Capture-Cf3hqRtN.js +1 -0
  165. package/static/app/assets/Library-CFfkNn3s.js +1 -0
  166. package/static/app/assets/System-BOurbT-v.js +1 -0
  167. package/static/app/assets/index-A3M9sElj.js +17 -0
  168. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  169. package/static/app/assets/primitives-DcUUmhdC.js +1 -0
  170. package/static/app/assets/textarea-BklR6zN4.js +1 -0
  171. package/static/app/index.html +2 -2
  172. package/static/sw.js +1 -1
  173. package/tools/__init__.py +21 -269
  174. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  175. package/latticeai/runtime/app_context_runtime.py +0 -13
  176. package/latticeai/runtime/tail_wiring.py +0 -21
  177. package/scripts/com.pts.claudecode.discord.plist +0 -31
  178. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  179. package/scripts/start-pts-claudecode-discord.sh +0 -51
  180. package/static/app/assets/Act-21lIXx2E.js +0 -1
  181. package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
  182. package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
  183. package/static/app/assets/Library-bFMtyni3.js +0 -1
  184. package/static/app/assets/System-K6krGCqn.js +0 -1
  185. package/static/app/assets/index-C4R3ws30.js +0 -17
  186. package/static/app/assets/index-ChSeOB02.css +0 -2
  187. package/static/app/assets/primitives-sQU3it5I.js +0 -1
  188. package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
  189. package/tools/knowledge.py +0 -95
@@ -5,6 +5,20 @@ from __future__ import annotations
5
5
  from ._kg_common import * # noqa: F403,F401
6
6
 
7
7
 
8
+ def _scoped_slug_id(prefix: str, value: str, workspace_id: Optional[str]) -> str:
9
+ """Keep legacy IDs unchanged while isolating every newly scoped identity."""
10
+ slug = _slug(value)
11
+ if not workspace_id:
12
+ return f"{prefix}:{slug}"
13
+ scope = _sha256_text(str(workspace_id))[:12]
14
+ return f"{prefix}:{scope}:{slug}"
15
+
16
+
17
+ def _scoped_hash_id(prefix: str, value: str, workspace_id: Optional[str]) -> str:
18
+ identity = f"{workspace_id}|{value}" if workspace_id else value
19
+ return f"{prefix}:{_sha256_text(identity)[:24]}"
20
+
21
+
8
22
  class KnowledgeGraphIngestMixin:
9
23
  def ingest_message(
10
24
  self,
@@ -19,12 +33,10 @@ class KnowledgeGraphIngestMixin:
19
33
  raw: Optional[Dict[str, Any]] = None,
20
34
  ) -> Dict[str, Any]:
21
35
  content = str(content or "")
22
- digest = _sha256_text(
23
- "|".join([role or "", content, conversation_id or "", user_email or ""])
24
- )[:24]
25
36
  node_type = "AIResponse" if role == "assistant" else "Message"
26
- node_id = f"{node_type.lower()}:{digest}"
27
- conv_id = f"conversation:{_slug(conversation_id or 'default')}"
37
+ message_identity = "|".join([role or "", content, conversation_id or "", user_email or ""])
38
+ node_id = _scoped_hash_id(node_type.lower(), message_identity, workspace_id)
39
+ conv_id = _scoped_slug_id("conversation", conversation_id or "default", workspace_id)
28
40
  metadata = {
29
41
  "role": role,
30
42
  "source": source,
@@ -58,7 +70,7 @@ class KnowledgeGraphIngestMixin:
58
70
  person_id = None
59
71
  if user_email or user_nickname:
60
72
  person_key = user_email or user_nickname or "unknown"
61
- person_id = f"person:{_slug(person_key)}"
73
+ person_id = _scoped_slug_id("person", person_key, workspace_id)
62
74
  self._upsert_node(
63
75
  conn,
64
76
  person_id,
@@ -121,7 +133,7 @@ class KnowledgeGraphIngestMixin:
121
133
  concept_ids: Dict[str, str] = {}
122
134
  for concept in concepts:
123
135
  node_t = _classify_node_type(concept, content)
124
- cid = f"{node_t.lower()}:{_slug(concept)}"
136
+ cid = _scoped_slug_id(node_t.lower(), concept, workspace_id)
125
137
  concept_ids[concept.lower()] = cid
126
138
  self._upsert_node(
127
139
  conn,
@@ -212,7 +224,7 @@ class KnowledgeGraphIngestMixin:
212
224
  text = str(
213
225
  (extracted or {}).get("content") or (extracted or {}).get("preview") or ""
214
226
  )
215
- file_id = f"file:{digest[:24]}"
227
+ file_id = _scoped_hash_id("file", digest, workspace_id)
216
228
  metadata = {
217
229
  "filename": filename,
218
230
  "ext": ext,
@@ -253,7 +265,14 @@ class KnowledgeGraphIngestMixin:
253
265
  owner=owner or uploader,
254
266
  workspace_id=workspace_id,
255
267
  )
256
- self._ingest_structure_nodes(conn, file_id, filename, doc_meta)
268
+ self._ingest_structure_nodes(
269
+ conn,
270
+ file_id,
271
+ filename,
272
+ doc_meta,
273
+ owner=owner or uploader,
274
+ workspace_id=workspace_id,
275
+ )
257
276
 
258
277
  # ── SOURCE 노드 + indexed_from (v3.6.0, source_type 지정 시) ──────
259
278
  if source_type:
@@ -274,7 +293,7 @@ class KnowledgeGraphIngestMixin:
274
293
 
275
294
  # ── Person 노드 + 동사형 엣지 ─────────────────────────────────────
276
295
  if uploader:
277
- person_id = f"person:{_slug(uploader)}"
296
+ person_id = _scoped_slug_id("person", uploader, workspace_id)
278
297
  self._upsert_node(
279
298
  conn,
280
299
  person_id,
@@ -289,7 +308,7 @@ class KnowledgeGraphIngestMixin:
289
308
 
290
309
  # ── Chat 노드와 연결 ──────────────────────────────────────────────
291
310
  if conversation_id:
292
- conv_id = f"conversation:{_slug(conversation_id)}"
311
+ conv_id = _scoped_slug_id("conversation", conversation_id, workspace_id)
293
312
  self._upsert_node(
294
313
  conn,
295
314
  conv_id,
@@ -329,7 +348,7 @@ class KnowledgeGraphIngestMixin:
329
348
  concept_ids: Dict[str, str] = {}
330
349
  for concept in concepts:
331
350
  node_t = _classify_node_type(concept, full_text)
332
- cid = f"{node_t.lower()}:{_slug(concept)}"
351
+ cid = _scoped_slug_id(node_t.lower(), concept, workspace_id)
333
352
  concept_ids[concept.lower()] = cid
334
353
  self._upsert_node(
335
354
  conn,
@@ -403,6 +422,7 @@ class KnowledgeGraphIngestMixin:
403
422
  user_nickname: Optional[str] = None,
404
423
  source: Optional[str] = None,
405
424
  conversation_id: Optional[str] = None,
425
+ workspace_id: Optional[str] = None,
406
426
  metadata: Optional[Dict[str, Any]] = None,
407
427
  ) -> Dict[str, Any]:
408
428
  event_type = str(event_type or "Event")
@@ -414,11 +434,12 @@ class KnowledgeGraphIngestMixin:
414
434
  "user_nickname": user_nickname,
415
435
  "source": source,
416
436
  "conversation_id": conversation_id,
437
+ "workspace_id": workspace_id,
417
438
  "metadata": metadata or {},
418
439
  "timestamp": _now(),
419
440
  }
420
- event_id = f"event:{_sha256_text(_json(payload))[:24]}"
421
- conv_id = f"conversation:{_slug(conversation_id or 'default')}"
441
+ event_id = _scoped_hash_id("event", _json(payload), workspace_id)
442
+ conv_id = _scoped_slug_id("conversation", conversation_id or "default", workspace_id)
422
443
  with self._connect() as conn:
423
444
  self._upsert_node(
424
445
  conn,
@@ -428,26 +449,32 @@ class KnowledgeGraphIngestMixin:
428
449
  summary=title,
429
450
  metadata=payload,
430
451
  raw=payload,
452
+ owner=user_email,
453
+ workspace_id=workspace_id,
431
454
  )
432
455
  self._upsert_node(
433
456
  conn,
434
457
  conv_id,
435
458
  "Conversation",
436
459
  conversation_id or "Default conversation",
437
- metadata={"source": source},
460
+ metadata={"source": source, "workspace_id": workspace_id},
461
+ owner=user_email,
462
+ workspace_id=workspace_id,
438
463
  )
439
464
  self._upsert_edge(
440
465
  conn, conv_id, event_id, "has_event", metadata={"source": source}
441
466
  )
442
467
  if user_email or user_nickname:
443
468
  person_key = user_email or user_nickname or "unknown"
444
- person_id = f"person:{_slug(person_key)}"
469
+ person_id = _scoped_slug_id("person", person_key, workspace_id)
445
470
  self._upsert_node(
446
471
  conn,
447
472
  person_id,
448
473
  "Person",
449
474
  user_nickname or user_email or "Unknown user",
450
- metadata={"email": user_email},
475
+ metadata={"email": user_email, "workspace_id": workspace_id},
476
+ owner=user_email,
477
+ workspace_id=workspace_id,
451
478
  )
452
479
  self._upsert_edge(
453
480
  conn,
@@ -599,7 +626,7 @@ class KnowledgeGraphIngestMixin:
599
626
  )
600
627
  # ── 소유자(Person) + 동사형 엣지 ────────────────────────────────
601
628
  if owner:
602
- person_id = f"person:{_slug(owner)}"
629
+ person_id = _scoped_slug_id("person", owner, workspace_id)
603
630
  self._upsert_node(
604
631
  conn,
605
632
  person_id,
@@ -612,7 +639,7 @@ class KnowledgeGraphIngestMixin:
612
639
  self._upsert_edge(conn, person_id, content_id, "업로드함", weight=1.0)
613
640
  # ── 대화 연결 ───────────────────────────────────────────────────
614
641
  if conversation_id:
615
- conv_id = f"conversation:{_slug(conversation_id)}"
642
+ conv_id = _scoped_slug_id("conversation", conversation_id, workspace_id)
616
643
  self._upsert_node(
617
644
  conn,
618
645
  conv_id,
@@ -649,7 +676,7 @@ class KnowledgeGraphIngestMixin:
649
676
  concept_ids: Dict[str, str] = {}
650
677
  for concept in concepts:
651
678
  node_t = _classify_node_type(concept, full_text)
652
- cid = f"{node_t.lower()}:{_slug(concept)}"
679
+ cid = _scoped_slug_id(node_t.lower(), concept, workspace_id)
653
680
  concept_ids[concept.lower()] = cid
654
681
  self._upsert_node(
655
682
  conn,
@@ -200,7 +200,10 @@ class KnowledgeGraphProvenanceMixin:
200
200
  }
201
201
 
202
202
  def export_graph_data(
203
- self, *, workspace_id: Optional[str] = None
203
+ self,
204
+ *,
205
+ workspace_id: Optional[str] = None,
206
+ include_legacy_global: bool = False,
204
207
  ) -> Dict[str, Any]:
205
208
  """Raw, lossless logical export of the graph (nodes/edges/chunks/sources/
206
209
  provenance). Vector embeddings are intentionally omitted — they are
@@ -208,10 +211,11 @@ class KnowledgeGraphProvenanceMixin:
208
211
  :meth:`backup_database` for a faithful binary copy incl. embeddings.
209
212
 
210
213
  ``workspace_id`` REALLY filters (v4): the artifact contains only nodes
211
- scoped to that workspace plus legacy-global rows (NULL scope, readable
212
- machine-wide by definition), with edges/chunks/provenance restricted to
213
- the surviving nodes. Pre-v4 this parameter was stamped into the header
214
- while the data exported everything a header that lied.
214
+ scoped to that workspace, with edges/chunks/provenance restricted to the
215
+ surviving nodes. Legacy-global rows require the explicit
216
+ ``include_legacy_global=True`` migration/compatibility opt-in. Pre-v4
217
+ this parameter was stamped into the header while the data exported
218
+ everything — a header that lied.
215
219
  """
216
220
  with self._connect() as conn:
217
221
 
@@ -221,10 +225,13 @@ class KnowledgeGraphProvenanceMixin:
221
225
  ]
222
226
 
223
227
  if workspace_id:
228
+ scope_sql = "workspace_id = ?"
229
+ if include_legacy_global:
230
+ scope_sql += " OR workspace_id IS NULL"
224
231
  keep_ids = {
225
232
  row["id"]
226
233
  for row in conn.execute(
227
- "SELECT id FROM nodes_v2 WHERE workspace_id = ? OR workspace_id IS NULL",
234
+ f"SELECT id FROM nodes_v2 WHERE {scope_sql}",
228
235
  (workspace_id,),
229
236
  ).fetchall()
230
237
  }
@@ -93,42 +93,67 @@ class KnowledgeGraphRetrievalMixin:
93
93
  }
94
94
 
95
95
  def workspaces_of(self, node_ids) -> Dict[str, Optional[str]]:
96
- """Map node ids to their workspace scope (None = legacy-global)."""
96
+ """Map known node ids to their workspace scope.
97
+
98
+ ``None`` is returned only for a row that is explicitly present in the
99
+ authoritative v2 projection with a NULL workspace. Missing ids remain
100
+ missing, and projection/query failures propagate so callers can fail
101
+ closed instead of mistaking every candidate for legacy-global data.
102
+ """
97
103
  ids = [str(i) for i in node_ids if i]
98
104
  if not ids:
99
105
  return {}
100
106
  placeholders = ",".join("?" for _ in ids)
101
107
  with self._connect() as conn:
102
- try:
103
- return {
104
- row["id"]: row["workspace_id"]
105
- for row in conn.execute(
106
- f"SELECT id, workspace_id FROM nodes_v2 WHERE id IN ({placeholders})",
107
- ids,
108
- ).fetchall()
109
- }
110
- except Exception:
111
- return {}
108
+ return {
109
+ row["id"]: row["workspace_id"]
110
+ for row in conn.execute(
111
+ f"SELECT id, workspace_id FROM nodes_v2 WHERE id IN ({placeholders})",
112
+ ids,
113
+ ).fetchall()
114
+ }
112
115
 
113
- def filter_scoped_nodes(self, items, allowed_workspaces, *, id_key: str = "id"):
116
+ def filter_scoped_nodes(
117
+ self,
118
+ items,
119
+ allowed_workspaces,
120
+ *,
121
+ id_key: str = "id",
122
+ include_legacy_global: bool = False,
123
+ ):
114
124
  """Drop items scoped to a workspace the caller is not a member of.
115
125
 
116
126
  ``allowed_workspaces=None`` means no scoping (single-user / no-auth
117
- mode). Legacy-global rows (no workspace) stay visible to everyone on
118
- the machine the documented pre-v4 compatibility behavior.
127
+ mode). In scoped/multi-user mode, unknown ids are private and
128
+ legacy-global rows require the explicit ``include_legacy_global=True``
129
+ compatibility opt-in.
119
130
  """
131
+ candidates = list(items)
120
132
  if allowed_workspaces is None:
121
- return list(items)
122
- allowed = set(allowed_workspaces)
123
- scopes = self.workspaces_of([item.get(id_key) for item in items])
124
- return [
125
- item
126
- for item in items
127
- if scopes.get(item.get(id_key)) is None
128
- or scopes.get(item.get(id_key)) in allowed
129
- ]
133
+ return candidates
134
+ allowed = {str(workspace_id) for workspace_id in allowed_workspaces if workspace_id}
135
+ scopes = self.workspaces_of([item.get(id_key) for item in candidates])
136
+ visible = []
137
+ for item in candidates:
138
+ node_id = str(item.get(id_key) or "")
139
+ if not node_id or node_id not in scopes:
140
+ # Unknown/unprojected rows are never treated as public.
141
+ continue
142
+ workspace_id = scopes[node_id]
143
+ if workspace_id is None:
144
+ if include_legacy_global:
145
+ visible.append(item)
146
+ elif str(workspace_id) in allowed:
147
+ visible.append(item)
148
+ return visible
130
149
 
131
- def graph(self, limit: int = 300, *, allowed_workspaces=None) -> Dict[str, Any]:
150
+ def graph(
151
+ self,
152
+ limit: int = 300,
153
+ *,
154
+ allowed_workspaces=None,
155
+ include_legacy_global: bool = False,
156
+ ) -> Dict[str, Any]:
132
157
  limit = max(1, min(int(limit or 300), 2000))
133
158
  visible = ",".join(f"'{t}'" for t in self._GRAPH_VISIBLE_TYPES)
134
159
  nt, et = self._read_tables()
@@ -179,7 +204,11 @@ class KnowledgeGraphRetrievalMixin:
179
204
  ]
180
205
 
181
206
  if allowed_workspaces is not None:
182
- nodes = self.filter_scoped_nodes(nodes, allowed_workspaces)
207
+ nodes = self.filter_scoped_nodes(
208
+ nodes,
209
+ allowed_workspaces,
210
+ include_legacy_global=include_legacy_global,
211
+ )
183
212
  kept_ids = {node["id"] for node in nodes}
184
213
  edges = [e for e in edges if e["from"] in kept_ids and e["to"] in kept_ids]
185
214
 
@@ -264,7 +293,14 @@ class KnowledgeGraphRetrievalMixin:
264
293
  node.pop("_raw_importance", None)
265
294
  return {"nodes": nodes, "edges": edges}
266
295
 
267
- def search(self, query: str, limit: int = 30, *, allowed_workspaces=None) -> Dict[str, Any]:
296
+ def search(
297
+ self,
298
+ query: str,
299
+ limit: int = 30,
300
+ *,
301
+ allowed_workspaces=None,
302
+ include_legacy_global: bool = False,
303
+ ) -> Dict[str, Any]:
268
304
  query = str(query or "").strip()
269
305
  q = f"%{query}%"
270
306
  limit = max(1, min(int(limit or 30), 100))
@@ -368,15 +404,31 @@ class KnowledgeGraphRetrievalMixin:
368
404
  for row in rows
369
405
  ]
370
406
  if allowed_workspaces is not None:
371
- matches = self.filter_scoped_nodes(matches, allowed_workspaces)
407
+ matches = self.filter_scoped_nodes(
408
+ matches,
409
+ allowed_workspaces,
410
+ include_legacy_global=include_legacy_global,
411
+ )
372
412
  return {"query": query, "matches": matches}
373
413
 
374
- def context_for_query(self, query: str, limit: int = 6, *, allowed_workspaces=None) -> str:
414
+ def context_for_query(
415
+ self,
416
+ query: str,
417
+ limit: int = 6,
418
+ *,
419
+ allowed_workspaces=None,
420
+ include_legacy_global: bool = False,
421
+ ) -> str:
375
422
  """Return compact graph-backed RAG context for chat generation."""
376
423
  query = str(query or "").strip()
377
424
  if not query:
378
425
  return ""
379
- matches = self.search(query, limit, allowed_workspaces=allowed_workspaces).get("matches", [])
426
+ matches = self.search(
427
+ query,
428
+ limit,
429
+ allowed_workspaces=allowed_workspaces,
430
+ include_legacy_global=include_legacy_global,
431
+ ).get("matches", [])
380
432
  if not matches:
381
433
  topics = _topic_candidates(query, limit=4)
382
434
  if topics:
@@ -414,7 +466,11 @@ class KnowledgeGraphRetrievalMixin:
414
466
  if len(matches) >= limit:
415
467
  break
416
468
  if allowed_workspaces is not None:
417
- matches = self.filter_scoped_nodes(matches, allowed_workspaces)
469
+ matches = self.filter_scoped_nodes(
470
+ matches,
471
+ allowed_workspaces,
472
+ include_legacy_global=include_legacy_global,
473
+ )
418
474
  lines = []
419
475
  for match in matches[:limit]:
420
476
  meta = match.get("metadata") or {}
@@ -431,9 +487,19 @@ class KnowledgeGraphRetrievalMixin:
431
487
  )
432
488
  return "\n".join(lines)
433
489
 
434
- def neighbors(self, node_id: str, *, allowed_workspaces=None) -> Dict[str, Any]:
490
+ def neighbors(
491
+ self,
492
+ node_id: str,
493
+ *,
494
+ allowed_workspaces=None,
495
+ include_legacy_global: bool = False,
496
+ ) -> Dict[str, Any]:
435
497
  """Return direct neighbors (1-hop) of a node."""
436
- if allowed_workspaces is not None and not self.filter_scoped_nodes([{"id": node_id}], allowed_workspaces):
498
+ if allowed_workspaces is not None and not self.filter_scoped_nodes(
499
+ [{"id": node_id}],
500
+ allowed_workspaces,
501
+ include_legacy_global=include_legacy_global,
502
+ ):
437
503
  raise ValueError(f"graph node not found: {node_id}")
438
504
  nt, et = self._read_tables()
439
505
  with self._connect() as conn:
@@ -472,7 +538,11 @@ class KnowledgeGraphRetrievalMixin:
472
538
  )
473
539
  ]
474
540
  if allowed_workspaces is not None:
475
- nodes = self.filter_scoped_nodes(nodes, allowed_workspaces)
541
+ nodes = self.filter_scoped_nodes(
542
+ nodes,
543
+ allowed_workspaces,
544
+ include_legacy_global=include_legacy_global,
545
+ )
476
546
  kept = {node.get("id") for node in nodes}
477
547
  edges = [
478
548
  edge for edge in edges
@@ -481,7 +551,13 @@ class KnowledgeGraphRetrievalMixin:
481
551
  ]
482
552
  return {"node_id": node_id, "neighbors": nodes, "edges": edges}
483
553
 
484
- def get_node(self, node_id: str, *, allowed_workspaces=None) -> Dict[str, Any]:
554
+ def get_node(
555
+ self,
556
+ node_id: str,
557
+ *,
558
+ allowed_workspaces=None,
559
+ include_legacy_global: bool = False,
560
+ ) -> Dict[str, Any]:
485
561
  node_id = str(node_id or "").strip()
486
562
  if not node_id:
487
563
  raise ValueError("node_id required")
@@ -510,7 +586,11 @@ class KnowledgeGraphRetrievalMixin:
510
586
  "updated_at": row["updated_at"],
511
587
  "degree": degree,
512
588
  }
513
- if allowed_workspaces is not None and not self.filter_scoped_nodes([node], allowed_workspaces):
589
+ if allowed_workspaces is not None and not self.filter_scoped_nodes(
590
+ [node],
591
+ allowed_workspaces,
592
+ include_legacy_global=include_legacy_global,
593
+ ):
514
594
  raise ValueError(f"graph node not found: {node_id}")
515
595
  return node
516
596
 
@@ -522,6 +602,7 @@ class KnowledgeGraphRetrievalMixin:
522
602
  relationship_type: str = "",
523
603
  limit: int = 30,
524
604
  allowed_workspaces=None,
605
+ include_legacy_global: bool = False,
525
606
  ) -> Dict[str, Any]:
526
607
  query = str(query or "").strip()
527
608
  node_id = str(node_id or "").strip()
@@ -591,7 +672,13 @@ class KnowledgeGraphRetrievalMixin:
591
672
  {"id": (rel.get("source") or {}).get("id")},
592
673
  {"id": (rel.get("target") or {}).get("id")},
593
674
  ]
594
- if len(self.filter_scoped_nodes(endpoints, allowed_workspaces)) == 2:
675
+ if len(
676
+ self.filter_scoped_nodes(
677
+ endpoints,
678
+ allowed_workspaces,
679
+ include_legacy_global=include_legacy_global,
680
+ )
681
+ ) == 2:
595
682
  kept.append(rel)
596
683
  relationships = kept
597
684
  return {
@@ -602,12 +689,22 @@ class KnowledgeGraphRetrievalMixin:
602
689
  }
603
690
 
604
691
  def traverse(
605
- self, node_id: str, *, depth: int = 1, limit: int = 100, allowed_workspaces=None
692
+ self,
693
+ node_id: str,
694
+ *,
695
+ depth: int = 1,
696
+ limit: int = 100,
697
+ allowed_workspaces=None,
698
+ include_legacy_global: bool = False,
606
699
  ) -> Dict[str, Any]:
607
700
  node_id = str(node_id or "").strip()
608
701
  if not node_id:
609
702
  raise ValueError("node_id required")
610
- if allowed_workspaces is not None and not self.filter_scoped_nodes([{"id": node_id}], allowed_workspaces):
703
+ if allowed_workspaces is not None and not self.filter_scoped_nodes(
704
+ [{"id": node_id}],
705
+ allowed_workspaces,
706
+ include_legacy_global=include_legacy_global,
707
+ ):
611
708
  raise ValueError(f"graph node not found: {node_id}")
612
709
  depth = max(0, min(int(depth or 1), 4))
613
710
  limit = max(1, min(int(limit or 100), 500))
@@ -668,7 +765,11 @@ class KnowledgeGraphRetrievalMixin:
668
765
  ]
669
766
  edges = list(edges_by_id.values())
670
767
  if allowed_workspaces is not None:
671
- nodes = self.filter_scoped_nodes(nodes, allowed_workspaces)
768
+ nodes = self.filter_scoped_nodes(
769
+ nodes,
770
+ allowed_workspaces,
771
+ include_legacy_global=include_legacy_global,
772
+ )
672
773
  kept = {node.get("id") for node in nodes}
673
774
  edges = [edge for edge in edges if edge.get("from") in kept and edge.get("to") in kept]
674
775
  return {"root": node_id, "depth": depth, "nodes": nodes, "edges": edges}
@@ -12,7 +12,12 @@ class KnowledgeGraphDocGenMixin:
12
12
  """
13
13
 
14
14
  def search_for_document_generation(
15
- self, query: str, limit: int = 10
15
+ self,
16
+ query: str,
17
+ limit: int = 10,
18
+ *,
19
+ allowed_workspaces=None,
20
+ include_legacy_global: bool = False,
16
21
  ) -> List[Dict[str, Any]]:
17
22
  """Hybrid retrieval optimized for document generation.
18
23
 
@@ -112,7 +117,7 @@ class KnowledgeGraphDocGenMixin:
112
117
  neighbor_concepts = []
113
118
  neighbor_rows = conn.execute(
114
119
  f"""
115
- SELECT n.title, n.type FROM {et} e
120
+ SELECT n.id, n.title, n.type FROM {et} e
116
121
  JOIN {nt} n ON n.id = CASE WHEN e.from_node = ? THEN e.to_node ELSE e.from_node END
117
122
  WHERE (e.from_node = ? OR e.to_node = ?)
118
123
  AND n.type IN ('Concept', 'Feature', 'Decision', 'Task')
@@ -121,7 +126,7 @@ class KnowledgeGraphDocGenMixin:
121
126
  (row["id"], row["id"], row["id"]),
122
127
  ).fetchall()
123
128
  for nr in neighbor_rows:
124
- neighbor_concepts.append({"title": nr["title"], "type": nr["type"]})
129
+ neighbor_concepts.append({"id": nr["id"], "title": nr["title"], "type": nr["type"]})
125
130
 
126
131
  scored_results.append(
127
132
  {
@@ -141,11 +146,28 @@ class KnowledgeGraphDocGenMixin:
141
146
  }
142
147
  )
143
148
 
149
+ if allowed_workspaces is not None:
150
+ scored_results = self.filter_scoped_nodes(
151
+ scored_results,
152
+ allowed_workspaces,
153
+ include_legacy_global=include_legacy_global,
154
+ )
155
+ for item in scored_results:
156
+ item["related_concepts"] = self.filter_scoped_nodes(
157
+ item.get("related_concepts", []),
158
+ allowed_workspaces,
159
+ include_legacy_global=include_legacy_global,
160
+ )
144
161
  scored_results.sort(key=lambda x: x["hybrid_score"], reverse=True)
145
162
  return scored_results[:limit]
146
163
 
147
164
  def multi_hop_context(
148
- self, node_ids: List[str], max_hops: int = 2
165
+ self,
166
+ node_ids: List[str],
167
+ max_hops: int = 2,
168
+ *,
169
+ allowed_workspaces=None,
170
+ include_legacy_global: bool = False,
149
171
  ) -> Dict[str, Any]:
150
172
  """Multi-hop graph traversal from seed nodes for richer context."""
151
173
  visited_nodes = set()
@@ -207,4 +229,15 @@ class KnowledgeGraphDocGenMixin:
207
229
  next_frontier.add(other)
208
230
  frontier = next_frontier
209
231
 
232
+ if allowed_workspaces is not None:
233
+ all_nodes = self.filter_scoped_nodes(
234
+ all_nodes,
235
+ allowed_workspaces,
236
+ include_legacy_global=include_legacy_global,
237
+ )
238
+ visible_ids = {node.get("id") for node in all_nodes}
239
+ all_edges = [
240
+ edge for edge in all_edges
241
+ if edge.get("from") in visible_ids and edge.get("to") in visible_ids
242
+ ]
210
243
  return {"nodes": all_nodes, "edges": all_edges}
@@ -21,11 +21,11 @@ from __future__ import annotations
21
21
 
22
22
  import hashlib
23
23
  from dataclasses import dataclass, field
24
- from datetime import datetime, timezone
25
24
  from pathlib import Path
26
25
  from typing import Any, Dict, List, Optional
27
26
 
28
27
  from .runtime.hooks import dispatch_tool
28
+ from .utils import utc_now_iso
29
29
 
30
30
  # Source types that arrive as a file on disk (read via ingest_document).
31
31
  FILE_SOURCE_TYPES = frozenset({"file", "local_file", "upload", "pdf"})
@@ -46,10 +46,6 @@ _MEMORY_NODE_TYPES = {"decision": "Decision", "experience": "Experience", "works
46
46
  DEFAULT_MAX_TEXT_BYTES = 5 * 1024 * 1024 # 5 MB of extracted text per item
47
47
 
48
48
 
49
- def _now_iso() -> str:
50
- return datetime.now(timezone.utc).isoformat()
51
-
52
-
53
49
  # --- Large candidate 1 slice: incremental / background ingestion support ---
54
50
  @dataclass
55
51
  class BackgroundIngestionJob:
@@ -57,7 +53,7 @@ class BackgroundIngestionJob:
57
53
  job_id: str
58
54
  items: List[IngestionItem]
59
55
  status: str = "pending" # pending | running | done | failed
60
- created_at: str = field(default_factory=_now_iso)
56
+ created_at: str = field(default_factory=utc_now_iso)
61
57
  processed: int = 0
62
58
  total: int = 0
63
59
  errors: List[str] = field(default_factory=list)
@@ -188,7 +184,7 @@ class IngestionPipeline:
188
184
  detail="Knowledge Graph is disabled (LATTICEAI_ENABLE_GRAPH).",
189
185
  )
190
186
 
191
- captured_at = item.captured_at or _now_iso()
187
+ captured_at = item.captured_at or utc_now_iso()
192
188
  owner = item.owner or user_email
193
189
  tool_name = f"kg_ingest.{source_type}"
194
190
  # Only the keys are read by the hook payload, so this dict is safe/cheap.
@@ -366,6 +362,7 @@ class IngestionPipeline:
366
362
  user_email=owner,
367
363
  source=meta.get("source") or source_type,
368
364
  conversation_id=item.conversation_id,
365
+ workspace_id=item.workspace_id,
369
366
  metadata={**meta, "detail": (item.text or "")[:2000]},
370
367
  )
371
368
  result.setdefault("node_id", result.get("node_id") or result.get("id"))