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.
- package/README.md +88 -46
- package/auto_setup.py +7 -853
- package/desktop/electron/README.md +9 -0
- package/desktop/electron/main.cjs +5 -3
- package/docs/CHANGELOG.md +178 -2
- package/docs/COMMUNITY_AND_PLUGINS.md +4 -2
- package/docs/DEVELOPMENT.md +53 -14
- package/docs/LEGACY_COMPATIBILITY.md +4 -4
- package/docs/ONBOARDING.md +10 -2
- package/docs/OPERATIONS.md +8 -4
- package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
- package/docs/TRUST_MODEL.md +5 -2
- package/docs/WHY_LATTICE.md +15 -10
- package/docs/WORKFLOW_DESIGNER.md +22 -0
- package/docs/kg-schema.md +13 -2
- package/docs/mcp-tools.md +17 -6
- package/docs/privacy.md +19 -3
- package/docs/public-deploy.md +32 -3
- package/docs/security-model.md +46 -11
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +1 -6
- package/lattice_brain/context.py +66 -9
- package/lattice_brain/graph/_kg_fsutil.py +1 -5
- package/lattice_brain/graph/discovery_index.py +174 -20
- package/lattice_brain/graph/documents.py +44 -9
- package/lattice_brain/graph/ingest.py +47 -20
- package/lattice_brain/graph/provenance.py +13 -6
- package/lattice_brain/graph/retrieval.py +140 -39
- package/lattice_brain/graph/retrieval_docgen.py +37 -4
- package/lattice_brain/ingestion.py +4 -7
- package/lattice_brain/portability.py +28 -14
- package/lattice_brain/runtime/agent_runtime.py +5 -9
- package/lattice_brain/runtime/hooks.py +30 -13
- package/lattice_brain/runtime/multi_agent.py +27 -8
- package/lattice_brain/runtime/statuses.py +10 -0
- package/lattice_brain/utils.py +20 -2
- package/lattice_brain/workflow.py +1 -5
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agent_registry.py +10 -8
- package/latticeai/api/agents.py +22 -3
- package/latticeai/api/auth.py +78 -16
- package/latticeai/api/browser.py +303 -34
- package/latticeai/api/chat.py +320 -850
- package/latticeai/api/chat_agent_http.py +356 -0
- package/latticeai/api/chat_contracts.py +54 -0
- package/latticeai/api/chat_documents.py +256 -0
- package/latticeai/api/chat_helpers.py +28 -1
- package/latticeai/api/chat_history.py +99 -0
- package/latticeai/api/chat_intents.py +316 -0
- package/latticeai/api/chat_stream.py +115 -0
- package/latticeai/api/computer_use.py +62 -9
- package/latticeai/api/health.py +9 -3
- package/latticeai/api/hooks.py +17 -6
- package/latticeai/api/knowledge_graph.py +78 -14
- package/latticeai/api/local_files.py +7 -2
- package/latticeai/api/mcp.py +102 -23
- package/latticeai/api/models.py +72 -33
- package/latticeai/api/network.py +5 -5
- package/latticeai/api/permissions.py +67 -26
- package/latticeai/api/plugins.py +21 -7
- package/latticeai/api/portability.py +5 -5
- package/latticeai/api/realtime.py +33 -5
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +123 -24
- package/latticeai/api/tools.py +96 -14
- package/latticeai/api/workflow_designer.py +37 -0
- package/latticeai/api/workspace.py +2 -1
- package/latticeai/app_factory.py +110 -52
- package/latticeai/core/agent.py +50 -13
- package/latticeai/core/agent_prompts.py +5 -0
- package/latticeai/core/agent_registry.py +1 -5
- package/latticeai/core/config.py +23 -3
- package/latticeai/core/context_builder.py +21 -3
- package/latticeai/core/file_generation.py +451 -0
- package/latticeai/core/invitations.py +1 -4
- package/latticeai/core/io_utils.py +9 -1
- package/latticeai/core/legacy_compatibility.py +24 -3
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/plugins.py +15 -0
- package/latticeai/core/policy.py +1 -1
- package/latticeai/core/realtime.py +38 -15
- package/latticeai/core/sessions.py +7 -2
- package/latticeai/core/timeutil.py +10 -0
- package/latticeai/core/tool_registry.py +90 -18
- package/latticeai/core/users.py +1 -5
- package/latticeai/core/workspace_graph_trace.py +31 -5
- package/latticeai/core/workspace_memory.py +3 -1
- package/latticeai/core/workspace_os.py +18 -7
- package/latticeai/core/workspace_os_utils.py +0 -5
- package/latticeai/core/workspace_permissions.py +2 -1
- package/latticeai/core/workspace_plugins.py +1 -1
- package/latticeai/core/workspace_runs.py +14 -5
- package/latticeai/core/workspace_skills.py +1 -1
- package/latticeai/core/workspace_snapshots.py +2 -1
- package/latticeai/core/workspace_timeline.py +2 -1
- package/latticeai/integrations/telegram_bot.py +94 -43
- package/latticeai/models/router.py +130 -36
- package/latticeai/runtime/access_runtime.py +62 -4
- package/latticeai/runtime/automation_runtime.py +13 -7
- package/latticeai/runtime/brain_runtime.py +19 -7
- package/latticeai/runtime/chat_wiring.py +2 -0
- package/latticeai/runtime/config_runtime.py +58 -26
- package/latticeai/runtime/context_runtime.py +16 -4
- package/latticeai/runtime/hooks_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +33 -5
- package/latticeai/runtime/namespace_runtime.py +62 -72
- package/latticeai/runtime/platform_runtime_wiring.py +4 -3
- package/latticeai/runtime/review_wiring.py +1 -1
- package/latticeai/runtime/router_registration.py +34 -1
- package/latticeai/runtime/security_runtime.py +110 -13
- package/latticeai/runtime/stages.py +27 -0
- package/latticeai/server_app.py +5 -1
- package/latticeai/services/architecture_readiness.py +74 -5
- package/latticeai/services/brain_automation.py +3 -26
- package/latticeai/services/chat_service.py +205 -15
- package/latticeai/services/local_knowledge.py +423 -0
- package/latticeai/services/memory_service.py +55 -21
- package/latticeai/services/model_engines.py +48 -33
- package/latticeai/services/model_errors.py +17 -0
- package/latticeai/services/model_loading.py +28 -25
- package/latticeai/services/model_runtime.py +228 -162
- package/latticeai/services/p_reinforce.py +22 -3
- package/latticeai/services/platform_runtime.py +83 -23
- package/latticeai/services/product_readiness.py +19 -17
- package/latticeai/services/review_queue.py +12 -0
- package/latticeai/services/router_context.py +1 -0
- package/latticeai/services/run_executor.py +4 -9
- package/latticeai/services/search_service.py +203 -28
- package/latticeai/services/tool_dispatch.py +28 -5
- package/latticeai/services/triggers.py +53 -4
- package/latticeai/services/upload_service.py +13 -2
- package/latticeai/setup/__init__.py +25 -0
- package/latticeai/setup/auto_setup.py +857 -0
- package/latticeai/setup/wizard.py +1264 -0
- package/latticeai/tools/__init__.py +278 -0
- package/{tools → latticeai/tools}/commands.py +67 -7
- package/{tools → latticeai/tools}/computer.py +1 -1
- package/{tools → latticeai/tools}/documents.py +1 -1
- package/{tools → latticeai/tools}/filesystem.py +3 -3
- package/latticeai/tools/knowledge.py +178 -0
- package/{tools → latticeai/tools}/local_files.py +1 -1
- package/{tools → latticeai/tools}/network.py +0 -1
- package/local_knowledge_api.py +4 -342
- package/package.json +22 -2
- package/scripts/brain_quality_eval.py +3 -1
- package/scripts/bump_version.py +3 -0
- package/scripts/capture_release_evidence.mjs +180 -0
- package/scripts/check_current_release_docs.mjs +142 -0
- package/scripts/check_i18n_literals.mjs +107 -31
- package/scripts/check_openapi_drift.mjs +56 -0
- package/scripts/export_openapi.py +67 -7
- package/scripts/i18n_literal_allowlist.json +22 -24
- package/scripts/run_integration_tests.mjs +72 -10
- package/scripts/validate_release_artifacts.py +49 -7
- package/scripts/wheel_smoke.py +5 -0
- package/setup_wizard.py +3 -1260
- 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 +11 -11
- package/static/app/assets/Act-C3dBrWE-.js +1 -0
- package/static/app/assets/Brain-DBYgdcjt.js +321 -0
- package/static/app/assets/Capture-Cf3hqRtN.js +1 -0
- package/static/app/assets/Library-CFfkNn3s.js +1 -0
- package/static/app/assets/System-BOurbT-v.js +1 -0
- package/static/app/assets/index-A3M9sElj.js +17 -0
- package/static/app/assets/index-Bmx9rzTc.css +2 -0
- package/static/app/assets/primitives-DcUUmhdC.js +1 -0
- package/static/app/assets/textarea-BklR6zN4.js +1 -0
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/tools/__init__.py +21 -269
- package/docs/CODE_REVIEW_2026-07-06.md +0 -764
- package/latticeai/runtime/app_context_runtime.py +0 -13
- package/latticeai/runtime/tail_wiring.py +0 -21
- package/scripts/com.pts.claudecode.discord.plist +0 -31
- package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
- package/scripts/start-pts-claudecode-discord.sh +0 -51
- package/static/app/assets/Act-21lIXx2E.js +0 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
- package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
- package/static/app/assets/Library-bFMtyni3.js +0 -1
- package/static/app/assets/System-K6krGCqn.js +0 -1
- package/static/app/assets/index-C4R3ws30.js +0 -17
- package/static/app/assets/index-ChSeOB02.css +0 -2
- package/static/app/assets/primitives-sQU3it5I.js +0 -1
- package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
- 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
|
-
|
|
27
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
421
|
-
conv_id =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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(
|
|
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).
|
|
118
|
-
|
|
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
|
|
122
|
-
allowed =
|
|
123
|
-
scopes = self.workspaces_of([item.get(id_key) for item in
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if
|
|
128
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
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=
|
|
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
|
|
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"))
|