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,15 @@ from __future__ import annotations
5
5
  from ._kg_common import * # noqa: F403,F401
6
6
 
7
7
 
8
+ def _local_scoped_slug(prefix: str, value: str, workspace_id: Optional[str]) -> str:
9
+ """Preserve legacy IDs while isolating newly workspace-scoped nodes."""
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
+
8
17
  class KnowledgeGraphLocalIndexMixin:
9
18
  """Local file → graph indexing (text extraction, node/index upserts,
10
19
  graph-node deletion, orphan cleanup, and the index_local_folder driver),
@@ -136,27 +145,56 @@ class KnowledgeGraphLocalIndexMixin:
136
145
  file_path: Path,
137
146
  os_type: str,
138
147
  drive_id: str,
148
+ user_email: Optional[str] = None,
149
+ workspace_id: Optional[str] = None,
139
150
  ) -> str:
140
151
  computer_label = platform.node() or "내 컴퓨터"
141
- computer_id = f"computer:{_slug(computer_label)}"
142
- drive_node_id = f"drive:{_sha256_text(f'{os_type}:{drive_id}')[:24]}"
152
+ computer_id = _local_scoped_slug("computer", computer_label, workspace_id)
153
+ drive_identity = f"{workspace_id}|{os_type}:{drive_id}" if workspace_id else f"{os_type}:{drive_id}"
154
+ drive_node_id = f"drive:{_sha256_text(drive_identity)[:24]}"
143
155
  root_folder_id = f"folder:{_sha256_text(f'{source_id}:root')[:24]}"
144
156
  self._upsert_node(
145
- conn, computer_id, "Computer", computer_label, metadata={"os_type": os_type}
157
+ conn,
158
+ computer_id,
159
+ "Computer",
160
+ computer_label,
161
+ metadata={"os_type": os_type, "workspace_id": workspace_id},
162
+ owner=user_email,
163
+ workspace_id=workspace_id,
146
164
  )
147
165
  self._upsert_node(
148
166
  conn,
149
167
  drive_node_id,
150
168
  "Drive",
151
169
  drive_id,
152
- metadata={"os_type": os_type, "drive_id": drive_id},
170
+ metadata={"os_type": os_type, "drive_id": drive_id, "workspace_id": workspace_id},
171
+ owner=user_email,
172
+ workspace_id=workspace_id,
153
173
  )
174
+ stale_parents = conn.execute(
175
+ """
176
+ SELECT e.from_node
177
+ FROM edges e
178
+ JOIN nodes n ON n.id=e.from_node
179
+ WHERE e.to_node=? AND n.type='Drive' AND e.from_node<>?
180
+ """,
181
+ (root_folder_id, drive_node_id),
182
+ ).fetchall()
183
+ for row in stale_parents:
184
+ conn.execute(
185
+ "DELETE FROM edges WHERE from_node=? AND to_node=?",
186
+ (row["from_node"], root_folder_id),
187
+ )
188
+ conn.execute(
189
+ "DELETE FROM edges_v2 WHERE source=? AND target=?",
190
+ (row["from_node"], root_folder_id),
191
+ )
154
192
  self._upsert_edge(
155
193
  conn,
156
194
  computer_id,
157
195
  drive_node_id,
158
196
  "포함함",
159
- metadata={"source": "local_scan"},
197
+ metadata={"source": "local_scan", "workspace_id": workspace_id},
160
198
  )
161
199
  self._upsert_node(
162
200
  conn,
@@ -164,14 +202,16 @@ class KnowledgeGraphLocalIndexMixin:
164
202
  "Folder",
165
203
  root.name or str(root),
166
204
  summary=str(root),
167
- metadata={"source_id": source_id, "path": str(root), "root": True},
205
+ metadata={"source_id": source_id, "path": str(root), "root": True, "workspace_id": workspace_id},
206
+ owner=user_email,
207
+ workspace_id=workspace_id,
168
208
  )
169
209
  self._upsert_edge(
170
210
  conn,
171
211
  drive_node_id,
172
212
  root_folder_id,
173
213
  "포함함",
174
- metadata={"source": "local_scan"},
214
+ metadata={"source": "local_scan", "workspace_id": workspace_id},
175
215
  )
176
216
 
177
217
  try:
@@ -195,10 +235,17 @@ class KnowledgeGraphLocalIndexMixin:
195
235
  "source_id": source_id,
196
236
  "path": str(current_path),
197
237
  "root": False,
238
+ "workspace_id": workspace_id,
198
239
  },
240
+ owner=user_email,
241
+ workspace_id=workspace_id,
199
242
  )
200
243
  self._upsert_edge(
201
- conn, parent_id, folder_id, "포함함", metadata={"source": "local_scan"}
244
+ conn,
245
+ parent_id,
246
+ folder_id,
247
+ "포함함",
248
+ metadata={"source": "local_scan", "workspace_id": workspace_id},
202
249
  )
203
250
  parent_id = folder_id
204
251
  return parent_id
@@ -296,6 +343,8 @@ class KnowledgeGraphLocalIndexMixin:
296
343
  parser_type: str,
297
344
  text: str,
298
345
  parser_meta: Dict[str, Any],
346
+ user_email: Optional[str] = None,
347
+ workspace_id: Optional[str] = None,
299
348
  ) -> str:
300
349
  text = _clean_text(text)
301
350
  if not text:
@@ -312,17 +361,26 @@ class KnowledgeGraphLocalIndexMixin:
312
361
  file_path=file_path,
313
362
  os_type=os_type,
314
363
  drive_id=drive_id,
364
+ user_email=user_email,
365
+ workspace_id=workspace_id,
315
366
  )
316
- child_rows = conn.execute(
367
+ linked_rows = conn.execute(
317
368
  """
318
- SELECT e.to_node AS id
369
+ SELECT e.to_node AS id, n.type, n.metadata_json
319
370
  FROM edges e
320
371
  JOIN nodes n ON n.id=e.to_node
321
- WHERE e.from_node=? AND n.type IN ('Chunk', 'ImageText', 'Section')
372
+ WHERE e.from_node=?
322
373
  """,
323
374
  (file_node_id,),
324
375
  ).fetchall()
325
- child_ids = [row["id"] for row in child_rows]
376
+ child_ids = []
377
+ auto_candidate_ids = set()
378
+ for row in linked_rows:
379
+ linked_metadata = _safe_loads(row["metadata_json"])
380
+ if row["type"] in {"Chunk", "ImageText", "Section"} or linked_metadata.get("source_node") == file_node_id:
381
+ child_ids.append(row["id"])
382
+ elif linked_metadata.get("auto_extracted") and linked_metadata.get("source") == "local_folder":
383
+ auto_candidate_ids.add(row["id"])
326
384
  conn.execute("DELETE FROM chunks WHERE source_node=?", (file_node_id,))
327
385
  if child_ids:
328
386
  placeholders = ",".join("?" * len(child_ids))
@@ -330,6 +388,27 @@ class KnowledgeGraphLocalIndexMixin:
330
388
  self._v2_delete_nodes(conn, child_ids)
331
389
  conn.execute("DELETE FROM edges WHERE from_node=?", (file_node_id,))
332
390
  self._v2_delete_edges_from(conn, file_node_id)
391
+ removable_auto_ids = set()
392
+ for node_id in auto_candidate_ids:
393
+ remaining_edges = conn.execute(
394
+ "SELECT from_node, to_node FROM edges WHERE from_node=? OR to_node=?",
395
+ (node_id, node_id),
396
+ ).fetchall()
397
+ if all(
398
+ row["from_node"] in auto_candidate_ids
399
+ and row["to_node"] in auto_candidate_ids
400
+ for row in remaining_edges
401
+ ):
402
+ removable_auto_ids.add(node_id)
403
+ if removable_auto_ids:
404
+ placeholders = ",".join("?" * len(removable_auto_ids))
405
+ params = list(removable_auto_ids)
406
+ conn.execute(
407
+ f"DELETE FROM edges WHERE from_node IN ({placeholders}) OR to_node IN ({placeholders})",
408
+ params * 2,
409
+ )
410
+ conn.execute(f"DELETE FROM nodes WHERE id IN ({placeholders})", params)
411
+ self._v2_delete_nodes(conn, params)
333
412
 
334
413
  metadata = {
335
414
  "source": "local_folder",
@@ -345,6 +424,7 @@ class KnowledgeGraphLocalIndexMixin:
345
424
  "modified_at": _safe_iso_from_stat_mtime(stat.st_mtime),
346
425
  "sha256": sha256,
347
426
  "parser": parser_meta,
427
+ "workspace_id": workspace_id,
348
428
  }
349
429
  self._upsert_node(
350
430
  conn,
@@ -354,6 +434,8 @@ class KnowledgeGraphLocalIndexMixin:
354
434
  summary=text[:700],
355
435
  metadata=metadata,
356
436
  raw=metadata,
437
+ owner=user_email,
438
+ workspace_id=workspace_id,
357
439
  )
358
440
  self._upsert_edge(
359
441
  conn,
@@ -361,8 +443,9 @@ class KnowledgeGraphLocalIndexMixin:
361
443
  file_node_id,
362
444
  "포함함",
363
445
  weight=1.0,
364
- metadata={"source": "local_scan"},
446
+ metadata={"source": "local_scan", "workspace_id": workspace_id},
365
447
  )
448
+ self._cleanup_local_graph_orphans(conn, source_id)
366
449
 
367
450
  target_for_concepts = text
368
451
  if category == "image" and text:
@@ -377,7 +460,10 @@ class KnowledgeGraphLocalIndexMixin:
377
460
  "source_node": file_node_id,
378
461
  "source_id": source_id,
379
462
  "chars": len(text),
463
+ "workspace_id": workspace_id,
380
464
  },
465
+ owner=user_email,
466
+ workspace_id=workspace_id,
381
467
  )
382
468
  self._upsert_edge(
383
469
  conn,
@@ -385,7 +471,7 @@ class KnowledgeGraphLocalIndexMixin:
385
471
  image_text_id,
386
472
  "포함함",
387
473
  weight=0.8,
388
- metadata={"source": "ocr"},
474
+ metadata={"source": "ocr", "workspace_id": workspace_id},
389
475
  )
390
476
 
391
477
  for index, chunk in enumerate(_chunks(text)):
@@ -400,7 +486,10 @@ class KnowledgeGraphLocalIndexMixin:
400
486
  "index": index,
401
487
  "source_node": file_node_id,
402
488
  "source_id": source_id,
489
+ "workspace_id": workspace_id,
403
490
  },
491
+ owner=user_email,
492
+ workspace_id=workspace_id,
404
493
  )
405
494
  self._upsert_chunk(
406
495
  conn,
@@ -411,6 +500,7 @@ class KnowledgeGraphLocalIndexMixin:
411
500
  "index": index,
412
501
  "source_node": file_node_id,
413
502
  "source_id": source_id,
503
+ "workspace_id": workspace_id,
414
504
  },
415
505
  )
416
506
  self._upsert_edge(
@@ -419,14 +509,14 @@ class KnowledgeGraphLocalIndexMixin:
419
509
  chunk_id,
420
510
  "포함함",
421
511
  weight=0.7,
422
- metadata={"source": "local_scan"},
512
+ metadata={"source": "local_scan", "workspace_id": workspace_id},
423
513
  )
424
514
 
425
515
  concepts = _extract_concepts(target_for_concepts, limit=18)
426
516
  concept_ids: Dict[str, str] = {}
427
517
  for concept in concepts:
428
518
  node_t = _classify_node_type(concept, target_for_concepts)
429
- concept_id = f"{node_t.lower()}:{_slug(concept)}"
519
+ concept_id = _local_scoped_slug(node_t.lower(), concept, workspace_id)
430
520
  concept_ids[concept.lower()] = concept_id
431
521
  self._upsert_node(
432
522
  conn,
@@ -437,7 +527,10 @@ class KnowledgeGraphLocalIndexMixin:
437
527
  "auto_extracted": True,
438
528
  "source": "local_folder",
439
529
  "source_id": source_id,
530
+ "workspace_id": workspace_id,
440
531
  },
532
+ owner=user_email,
533
+ workspace_id=workspace_id,
441
534
  )
442
535
  self._upsert_edge(
443
536
  conn,
@@ -445,7 +538,7 @@ class KnowledgeGraphLocalIndexMixin:
445
538
  concept_id,
446
539
  "언급함",
447
540
  weight=0.75,
448
- metadata={"source": "local_scan"},
541
+ metadata={"source": "local_scan", "workspace_id": workspace_id},
449
542
  )
450
543
 
451
544
  for triple in _extract_triples(target_for_concepts, concepts, limit=20):
@@ -461,6 +554,7 @@ class KnowledgeGraphLocalIndexMixin:
461
554
  metadata={
462
555
  "context": triple.get("context", "")[:240],
463
556
  "source_id": source_id,
557
+ "workspace_id": workspace_id,
464
558
  },
465
559
  )
466
560
 
@@ -478,10 +572,20 @@ class KnowledgeGraphLocalIndexMixin:
478
572
  "auto_extracted": True,
479
573
  "source_node": file_node_id,
480
574
  "filename": file_path.name,
575
+ "workspace_id": workspace_id,
481
576
  },
482
577
  raw=item,
578
+ owner=user_email,
579
+ workspace_id=workspace_id,
580
+ )
581
+ self._upsert_edge(
582
+ conn,
583
+ file_node_id,
584
+ sem_id,
585
+ "포함함",
586
+ weight=0.9,
587
+ metadata={"source": "local_scan", "workspace_id": workspace_id},
483
588
  )
484
- self._upsert_edge(conn, file_node_id, sem_id, "포함함", weight=0.9)
485
589
 
486
590
  return file_node_id
487
591
 
@@ -627,6 +731,21 @@ class KnowledgeGraphLocalIndexMixin:
627
731
  except (TypeError, ValueError):
628
732
  return False
629
733
 
734
+ @staticmethod
735
+ def _node_matches_workspace(
736
+ conn: sqlite3.Connection,
737
+ node_id: Optional[str],
738
+ workspace_id: Optional[str],
739
+ ) -> bool:
740
+ """Return true only when the projected node has the expected scope."""
741
+ if not node_id:
742
+ return False
743
+ row = conn.execute(
744
+ "SELECT workspace_id FROM nodes_v2 WHERE id=?",
745
+ (node_id,),
746
+ ).fetchone()
747
+ return bool(row is not None and row["workspace_id"] == workspace_id)
748
+
630
749
  def index_local_folder(
631
750
  self,
632
751
  path: Path,
@@ -634,8 +753,10 @@ class KnowledgeGraphLocalIndexMixin:
634
753
  include_ocr: bool = False,
635
754
  watch_enabled: bool = False,
636
755
  user_email: Optional[str] = None,
756
+ workspace_id: Optional[str] = None,
637
757
  consent: Optional[Dict[str, Any]] = None,
638
758
  max_files: int = 5_000,
759
+ source_id_override: Optional[str] = None,
639
760
  ) -> Dict[str, Any]:
640
761
  """Read approved files from a local folder and connect them to Graph RAG."""
641
762
  root = Path(path).expanduser().resolve()
@@ -646,17 +767,25 @@ class KnowledgeGraphLocalIndexMixin:
646
767
 
647
768
  os_type = _current_os_type()
648
769
  drive_id = _drive_id_for_path(root)
649
- source_id = f"source:{_path_fingerprint(root)}"
770
+ path_fingerprint = _path_fingerprint(root)
771
+ source_id = str(source_id_override or "").strip()
772
+ if not source_id:
773
+ source_id = (
774
+ f"source:{_sha256_text(f'{workspace_id}|{path_fingerprint}')[:24]}"
775
+ if workspace_id
776
+ else f"source:{path_fingerprint}"
777
+ )
650
778
  now = _now()
651
779
  max_files = max(1, min(int(max_files or 5_000), 50_000))
652
780
  consent_payload = {
653
781
  "approved_at": now,
654
- "approved_by": user_email,
655
782
  "knowledge_source": True,
656
783
  "include_ocr": bool(include_ocr),
657
784
  "watch_enabled": bool(watch_enabled),
658
785
  "sensitive_files_default_excluded": True,
659
786
  **(consent or {}),
787
+ "approved_by": user_email or (consent or {}).get("approved_by"),
788
+ "workspace_id": workspace_id or (consent or {}).get("workspace_id"),
660
789
  }
661
790
  counts: Counter = Counter()
662
791
  seen_relative_paths: set = set()
@@ -665,6 +794,23 @@ class KnowledgeGraphLocalIndexMixin:
665
794
  limit_reached = False
666
795
 
667
796
  with self._connect() as conn:
797
+ existing_source = conn.execute(
798
+ "SELECT id, consent_json FROM knowledge_sources WHERE root_path=?",
799
+ (str(root),),
800
+ ).fetchone()
801
+ if existing_source is not None:
802
+ existing_consent = _safe_loads(existing_source["consent_json"])
803
+ existing_scope = existing_consent.get("workspace_id") or "personal"
804
+ requested_scope = workspace_id or consent_payload.get("workspace_id") or "personal"
805
+ if existing_scope != requested_scope:
806
+ raise ValueError(
807
+ "This folder is already connected to another workspace. "
808
+ "Disconnect it there before assigning it to a different Brain."
809
+ )
810
+ if existing_source["id"] != source_id:
811
+ # Reuse the legacy source identity so a personal source is
812
+ # reprojected in place instead of duplicated during upgrade.
813
+ source_id = existing_source["id"]
668
814
  conn.execute(
669
815
  """
670
816
  INSERT INTO knowledge_sources(
@@ -765,6 +911,9 @@ class KnowledgeGraphLocalIndexMixin:
765
911
  and existing["status"] == "indexed"
766
912
  and existing["graph_node_id"]
767
913
  and self._local_file_index_has_extracted_text(existing)
914
+ and self._node_matches_workspace(
915
+ conn, existing["graph_node_id"], workspace_id
916
+ )
768
917
  and existing["size_bytes"] == stat.st_size
769
918
  and existing["modified_at"] == modified_at
770
919
  ):
@@ -817,6 +966,9 @@ class KnowledgeGraphLocalIndexMixin:
817
966
  and existing["sha256"] == digest
818
967
  and existing["graph_node_id"]
819
968
  and self._local_file_index_has_extracted_text(existing)
969
+ and self._node_matches_workspace(
970
+ conn, existing["graph_node_id"], workspace_id
971
+ )
820
972
  ):
821
973
  counts["skipped_unchanged"] += 1
822
974
  self._upsert_local_file_index(
@@ -884,6 +1036,8 @@ class KnowledgeGraphLocalIndexMixin:
884
1036
  parser_type=parser_type,
885
1037
  text=text,
886
1038
  parser_meta=parser_meta,
1039
+ user_email=user_email,
1040
+ workspace_id=workspace_id,
887
1041
  )
888
1042
  self._upsert_local_file_index(
889
1043
  conn,
@@ -12,6 +12,9 @@ class KnowledgeGraphDocumentsMixin:
12
12
  file_id: str,
13
13
  filename: str,
14
14
  structure: Dict[str, Any],
15
+ *,
16
+ owner: Optional[str] = None,
17
+ workspace_id: Optional[str] = None,
15
18
  ) -> None:
16
19
  for slide in structure.get("slides") or []:
17
20
  index = slide.get("index")
@@ -19,18 +22,28 @@ class KnowledgeGraphDocumentsMixin:
19
22
  title = f"{filename} slide {index}"
20
23
  summary = "\n".join(slide.get("texts") or [])[:800]
21
24
  self._upsert_node(
22
- conn, slide_id, "Slide", title, summary=summary, metadata=slide
25
+ conn,
26
+ slide_id,
27
+ "Slide",
28
+ title,
29
+ summary=summary,
30
+ metadata={**slide, "workspace_id": workspace_id},
31
+ owner=owner,
32
+ workspace_id=workspace_id,
23
33
  )
24
34
  self._upsert_edge(conn, file_id, slide_id, "has_slide")
25
35
  for text in slide.get("texts") or []:
26
36
  for topic in _topic_candidates(text, limit=4):
27
- topic_id = f"topic:{_slug(topic)}"
37
+ topic_key = f"{workspace_id}|{topic}" if workspace_id else topic
38
+ topic_id = f"topic:{_sha256_text(topic_key)[:24]}" if workspace_id else f"topic:{_slug(topic)}"
28
39
  self._upsert_node(
29
40
  conn,
30
41
  topic_id,
31
42
  "Topic",
32
43
  topic,
33
- metadata={"auto_extracted": True},
44
+ metadata={"auto_extracted": True, "workspace_id": workspace_id},
45
+ owner=owner,
46
+ workspace_id=workspace_id,
34
47
  )
35
48
  self._upsert_edge(conn, slide_id, topic_id, "discusses", weight=0.6)
36
49
 
@@ -44,13 +57,22 @@ class KnowledgeGraphDocumentsMixin:
44
57
  "Page",
45
58
  title,
46
59
  summary=page.get("preview") or "",
47
- metadata=page,
60
+ metadata={**page, "workspace_id": workspace_id},
61
+ owner=owner,
62
+ workspace_id=workspace_id,
48
63
  )
49
64
  self._upsert_edge(conn, file_id, page_id, "has_page")
50
65
  for topic in _topic_candidates(page.get("preview") or "", limit=4):
51
- topic_id = f"topic:{_slug(topic)}"
66
+ topic_key = f"{workspace_id}|{topic}" if workspace_id else topic
67
+ topic_id = f"topic:{_sha256_text(topic_key)[:24]}" if workspace_id else f"topic:{_slug(topic)}"
52
68
  self._upsert_node(
53
- conn, topic_id, "Topic", topic, metadata={"auto_extracted": True}
69
+ conn,
70
+ topic_id,
71
+ "Topic",
72
+ topic,
73
+ metadata={"auto_extracted": True, "workspace_id": workspace_id},
74
+ owner=owner,
75
+ workspace_id=workspace_id,
54
76
  )
55
77
  self._upsert_edge(conn, page_id, topic_id, "discusses", weight=0.6)
56
78
 
@@ -58,7 +80,13 @@ class KnowledgeGraphDocumentsMixin:
58
80
  sheet_title = sheet.get("title")
59
81
  sheet_id = f"sheet:{_sha256_text(f'{file_id}:sheet:{sheet_title}')[:24]}"
60
82
  self._upsert_node(
61
- conn, sheet_id, "Sheet", f"{filename} / {sheet_title}", metadata=sheet
83
+ conn,
84
+ sheet_id,
85
+ "Sheet",
86
+ f"{filename} / {sheet_title}",
87
+ metadata={**sheet, "workspace_id": workspace_id},
88
+ owner=owner,
89
+ workspace_id=workspace_id,
62
90
  )
63
91
  self._upsert_edge(conn, file_id, sheet_id, "has_sheet")
64
92
 
@@ -66,14 +94,21 @@ class KnowledgeGraphDocumentsMixin:
66
94
  image_key = image.get("sha256") or _sha256_text(
67
95
  json.dumps(image, ensure_ascii=False, sort_keys=True)
68
96
  )
69
- image_id = f"image:{str(image_key)[:24]}"
97
+ scoped_image_key = f"{workspace_id}|{image_key}" if workspace_id else str(image_key)
98
+ image_id = f"image:{_sha256_text(scoped_image_key)[:24]}" if workspace_id else f"image:{str(image_key)[:24]}"
70
99
  title_parts = [filename, "image"]
71
100
  if image.get("page"):
72
101
  title_parts.append(f"page {image.get('page')}")
73
102
  if image.get("name"):
74
103
  title_parts.append(str(image.get("name")).split("/")[-1])
75
104
  self._upsert_node(
76
- conn, image_id, "Image", " / ".join(title_parts), metadata=image
105
+ conn,
106
+ image_id,
107
+ "Image",
108
+ " / ".join(title_parts),
109
+ metadata={**image, "workspace_id": workspace_id},
110
+ owner=owner,
111
+ workspace_id=workspace_id,
77
112
  )
78
113
  self._upsert_edge(conn, file_id, image_id, "contains_image")
79
114