ltcai 8.9.0 → 9.1.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 +81 -58
- package/auto_setup.py +7 -904
- package/desktop/electron/README.md +9 -0
- package/desktop/electron/main.cjs +5 -3
- package/docs/CHANGELOG.md +221 -238
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -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/ROADMAP_RECOMMENDATIONS.md +61 -36
- 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 +4 -14
- package/lattice_brain/context.py +66 -9
- package/lattice_brain/embeddings.py +38 -2
- package/lattice_brain/graph/_kg_common.py +27 -462
- package/lattice_brain/graph/_kg_constants.py +243 -0
- package/lattice_brain/graph/_kg_fsutil.py +293 -0
- package/lattice_brain/graph/discovery.py +0 -948
- package/lattice_brain/graph/discovery_index.py +1126 -0
- 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 +141 -610
- package/lattice_brain/graph/retrieval_docgen.py +243 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -0
- package/lattice_brain/graph/store.py +6 -0
- package/lattice_brain/ingestion.py +69 -4
- package/lattice_brain/portability.py +29 -23
- package/lattice_brain/quality.py +98 -4
- package/lattice_brain/runtime/agent_runtime.py +169 -7
- 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 +46 -0
- 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 +355 -952
- 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 +250 -0
- package/latticeai/api/chat_history.py +99 -0
- package/latticeai/api/chat_intents.py +302 -0
- package/latticeai/api/chat_stream.py +115 -0
- package/latticeai/api/computer_use.py +211 -40
- 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/marketplace.py +11 -0
- 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 +70 -29
- 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 +97 -14
- package/latticeai/api/workflow_designer.py +37 -0
- package/latticeai/api/workspace.py +2 -1
- package/latticeai/app_factory.py +185 -405
- package/latticeai/core/agent.py +19 -9
- 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/invitations.py +1 -4
- package/latticeai/core/io_utils.py +45 -0
- package/latticeai/core/legacy_compatibility.py +24 -3
- package/latticeai/core/local_embeddings.py +2 -4
- package/latticeai/core/marketplace.py +33 -2
- package/latticeai/core/mcp_catalog.py +450 -0
- package/latticeai/core/mcp_registry.py +2 -441
- 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 +5 -14
- 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 +2 -21
- 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 +96 -40
- package/latticeai/models/model_providers.py +111 -0
- package/latticeai/models/router.py +189 -173
- package/latticeai/runtime/access_runtime.py +62 -4
- package/latticeai/runtime/audit_runtime.py +27 -16
- package/latticeai/runtime/automation_runtime.py +22 -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/history_runtime.py +163 -0
- package/latticeai/runtime/hooks_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +33 -5
- package/latticeai/runtime/namespace_runtime.py +163 -0
- package/latticeai/runtime/network_config_runtime.py +56 -0
- 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/sso_config_runtime.py +128 -0
- package/latticeai/runtime/stages.py +27 -0
- package/latticeai/runtime/user_key_runtime.py +106 -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 +268 -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 +92 -24
- package/latticeai/services/product_readiness.py +19 -17
- package/latticeai/services/review_queue.py +76 -11
- package/latticeai/services/router_context.py +1 -0
- package/latticeai/services/run_executor.py +25 -9
- package/latticeai/services/search_service.py +203 -28
- package/latticeai/services/setup_detection.py +80 -0
- 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 +7 -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 -1304
- 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-Bzz0bUyW.js +1 -0
- package/static/app/assets/Brain-Dj2J20YA.js +321 -0
- package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
- package/static/app/assets/Library-B03FP1Yx.js +1 -0
- package/static/app/assets/System-80lHW0Ux.js +1 -0
- package/static/app/assets/index-BiMofBTM.js +17 -0
- package/static/app/assets/index-Bmx9rzTc.css +2 -0
- package/static/app/assets/primitives-Q1A96_7v.js +1 -0
- package/static/app/assets/textarea-D13RtnTo.js +1 -0
- package/static/app/index.html +2 -2
- package/static/css/tokens.css +4 -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/sso_runtime.py +0 -52
- 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-fZokUnC0.js +0 -1
- package/static/app/assets/Brain-DtyuWubr.js +0 -321
- package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
- package/static/app/assets/Library-C9kyFkSt.js +0 -1
- package/static/app/assets/System-VbChmX7r.js +0 -1
- package/static/app/assets/index-DCh5AoXt.css +0 -2
- package/static/app/assets/index-DPdcPoF0.js +0 -17
- package/static/app/assets/primitives-DFeanEV6.js +0 -1
- package/static/app/assets/textarea-CD8UNKIy.js +0 -1
- package/tools/knowledge.py +0 -95
|
@@ -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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
149
|
+
|
|
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,383 +765,15 @@ 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}
|
|
675
776
|
|
|
676
|
-
def _iter_vector_source_items(
|
|
677
|
-
self,
|
|
678
|
-
conn: sqlite3.Connection,
|
|
679
|
-
*,
|
|
680
|
-
include_nodes: bool = True,
|
|
681
|
-
include_chunks: bool = True,
|
|
682
|
-
) -> List[Dict[str, Any]]:
|
|
683
|
-
items: List[Dict[str, Any]] = []
|
|
684
|
-
if include_nodes:
|
|
685
|
-
for row in conn.execute(
|
|
686
|
-
"""
|
|
687
|
-
SELECT id, type, title, summary, metadata_json
|
|
688
|
-
FROM nodes
|
|
689
|
-
WHERE type <> 'Chunk'
|
|
690
|
-
ORDER BY updated_at DESC, id ASC
|
|
691
|
-
"""
|
|
692
|
-
).fetchall():
|
|
693
|
-
metadata = _safe_loads(row["metadata_json"])
|
|
694
|
-
text = self._vector_text_for_node(
|
|
695
|
-
title=row["title"],
|
|
696
|
-
summary=row["summary"] or "",
|
|
697
|
-
metadata=metadata,
|
|
698
|
-
)
|
|
699
|
-
if text:
|
|
700
|
-
items.append(
|
|
701
|
-
{
|
|
702
|
-
"item_id": row["id"],
|
|
703
|
-
"item_type": "node",
|
|
704
|
-
"source_node": row["id"],
|
|
705
|
-
"text": text,
|
|
706
|
-
"metadata": {"node_type": row["type"], **metadata},
|
|
707
|
-
}
|
|
708
|
-
)
|
|
709
|
-
if include_chunks:
|
|
710
|
-
for row in conn.execute(
|
|
711
|
-
"""
|
|
712
|
-
SELECT c.id, c.source_node AS parent_source_node, c.text, c.metadata_json
|
|
713
|
-
FROM chunks c
|
|
714
|
-
JOIN nodes n ON n.id=c.id
|
|
715
|
-
ORDER BY c.created_at DESC, c.id ASC
|
|
716
|
-
"""
|
|
717
|
-
).fetchall():
|
|
718
|
-
metadata = _safe_loads(row["metadata_json"])
|
|
719
|
-
text = _clean_text(row["text"] or "")
|
|
720
|
-
if text:
|
|
721
|
-
items.append(
|
|
722
|
-
{
|
|
723
|
-
"item_id": row["id"],
|
|
724
|
-
"item_type": "chunk",
|
|
725
|
-
"source_node": row["id"],
|
|
726
|
-
"text": text,
|
|
727
|
-
"metadata": {
|
|
728
|
-
**metadata,
|
|
729
|
-
"parent_source_node": row["parent_source_node"],
|
|
730
|
-
},
|
|
731
|
-
}
|
|
732
|
-
)
|
|
733
|
-
return items
|
|
734
|
-
|
|
735
|
-
def rebuild_vector_index(
|
|
736
|
-
self,
|
|
737
|
-
*,
|
|
738
|
-
full: bool = False,
|
|
739
|
-
include_nodes: bool = True,
|
|
740
|
-
include_chunks: bool = True,
|
|
741
|
-
) -> Dict[str, Any]:
|
|
742
|
-
"""Rebuild the derived vector index without mutating graph content."""
|
|
743
|
-
op_id = f"vector-op:{_sha256_text(f'{time.time()}:{os.getpid()}')[:24]}"
|
|
744
|
-
requested_at = _now()
|
|
745
|
-
started = time.perf_counter()
|
|
746
|
-
try:
|
|
747
|
-
with self._connect() as conn:
|
|
748
|
-
conn.execute(
|
|
749
|
-
"""
|
|
750
|
-
INSERT INTO vector_index_operations(
|
|
751
|
-
id, operation, status, requested_at, started_at, metadata_json
|
|
752
|
-
)
|
|
753
|
-
VALUES (?, ?, 'running', ?, ?, ?)
|
|
754
|
-
""",
|
|
755
|
-
(
|
|
756
|
-
op_id,
|
|
757
|
-
"rebuild_full" if full else "rebuild_incremental",
|
|
758
|
-
requested_at,
|
|
759
|
-
requested_at,
|
|
760
|
-
_json(
|
|
761
|
-
{
|
|
762
|
-
"include_nodes": include_nodes,
|
|
763
|
-
"include_chunks": include_chunks,
|
|
764
|
-
}
|
|
765
|
-
),
|
|
766
|
-
),
|
|
767
|
-
)
|
|
768
|
-
if full:
|
|
769
|
-
filters = []
|
|
770
|
-
if include_nodes:
|
|
771
|
-
filters.append("'node'")
|
|
772
|
-
if include_chunks:
|
|
773
|
-
filters.append("'chunk'")
|
|
774
|
-
if filters:
|
|
775
|
-
conn.execute(
|
|
776
|
-
f"DELETE FROM vector_embeddings WHERE item_type IN ({','.join(filters)})"
|
|
777
|
-
)
|
|
778
|
-
items = self._iter_vector_source_items(
|
|
779
|
-
conn,
|
|
780
|
-
include_nodes=include_nodes,
|
|
781
|
-
include_chunks=include_chunks,
|
|
782
|
-
)
|
|
783
|
-
indexed = skipped = 0
|
|
784
|
-
for item in items:
|
|
785
|
-
changed = self._upsert_vector_item(conn, **item)
|
|
786
|
-
if changed:
|
|
787
|
-
indexed += 1
|
|
788
|
-
else:
|
|
789
|
-
skipped += 1
|
|
790
|
-
duration_ms = round((time.perf_counter() - started) * 1000, 2)
|
|
791
|
-
conn.execute(
|
|
792
|
-
"""
|
|
793
|
-
UPDATE vector_index_operations
|
|
794
|
-
SET status='completed', completed_at=?, items_total=?,
|
|
795
|
-
items_indexed=?, items_skipped=?, metadata_json=?
|
|
796
|
-
WHERE id=?
|
|
797
|
-
""",
|
|
798
|
-
(
|
|
799
|
-
_now(),
|
|
800
|
-
len(items),
|
|
801
|
-
indexed,
|
|
802
|
-
skipped,
|
|
803
|
-
_json(
|
|
804
|
-
{
|
|
805
|
-
"include_nodes": include_nodes,
|
|
806
|
-
"include_chunks": include_chunks,
|
|
807
|
-
"duration_ms": duration_ms,
|
|
808
|
-
"embedding_model": self._embedding_model.model_id,
|
|
809
|
-
"embedding_dim": self._embedding_model.dim,
|
|
810
|
-
}
|
|
811
|
-
),
|
|
812
|
-
op_id,
|
|
813
|
-
),
|
|
814
|
-
)
|
|
815
|
-
return {
|
|
816
|
-
"status": "completed",
|
|
817
|
-
"operation_id": op_id,
|
|
818
|
-
"full": bool(full),
|
|
819
|
-
"items_total": len(items),
|
|
820
|
-
"items_indexed": indexed,
|
|
821
|
-
"items_skipped": skipped,
|
|
822
|
-
"duration_ms": duration_ms,
|
|
823
|
-
"embedding_model": self._embedding_model.model_id,
|
|
824
|
-
"embedding_dim": self._embedding_model.dim,
|
|
825
|
-
}
|
|
826
|
-
except Exception as exc:
|
|
827
|
-
duration_ms = round((time.perf_counter() - started) * 1000, 2)
|
|
828
|
-
with self._connect() as conn:
|
|
829
|
-
conn.execute(
|
|
830
|
-
"""
|
|
831
|
-
INSERT INTO vector_index_operations(
|
|
832
|
-
id, operation, status, requested_at, started_at, completed_at,
|
|
833
|
-
error_message, metadata_json
|
|
834
|
-
)
|
|
835
|
-
VALUES (?, ?, 'failed', ?, ?, ?, ?, ?)
|
|
836
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
837
|
-
status='failed',
|
|
838
|
-
completed_at=excluded.completed_at,
|
|
839
|
-
error_message=excluded.error_message,
|
|
840
|
-
metadata_json=excluded.metadata_json
|
|
841
|
-
""",
|
|
842
|
-
(
|
|
843
|
-
op_id,
|
|
844
|
-
"rebuild_full" if full else "rebuild_incremental",
|
|
845
|
-
requested_at,
|
|
846
|
-
requested_at,
|
|
847
|
-
_now(),
|
|
848
|
-
str(exc),
|
|
849
|
-
_json({"duration_ms": duration_ms}),
|
|
850
|
-
),
|
|
851
|
-
)
|
|
852
|
-
raise
|
|
853
|
-
|
|
854
|
-
def index_status(self) -> Dict[str, Any]:
|
|
855
|
-
storage_capabilities = None
|
|
856
|
-
try:
|
|
857
|
-
storage_capabilities = self.storage_engine.capabilities().as_dict()
|
|
858
|
-
except Exception as exc:
|
|
859
|
-
storage_capabilities = {
|
|
860
|
-
"engine": "sqlite",
|
|
861
|
-
"available": False,
|
|
862
|
-
"reason": str(exc),
|
|
863
|
-
}
|
|
864
|
-
with self._connect() as conn:
|
|
865
|
-
vector_counts = {
|
|
866
|
-
row["item_type"]: row["count"]
|
|
867
|
-
for row in conn.execute(
|
|
868
|
-
"SELECT item_type, COUNT(*) AS count FROM vector_embeddings GROUP BY item_type"
|
|
869
|
-
)
|
|
870
|
-
}
|
|
871
|
-
source_items = self._iter_vector_source_items(conn)
|
|
872
|
-
vector_rows = {
|
|
873
|
-
row["item_id"]: row
|
|
874
|
-
for row in conn.execute(
|
|
875
|
-
"""
|
|
876
|
-
SELECT item_id, text_hash, embedding_dim, embedding_model, indexed_at
|
|
877
|
-
FROM vector_embeddings
|
|
878
|
-
"""
|
|
879
|
-
).fetchall()
|
|
880
|
-
}
|
|
881
|
-
latest_rows = conn.execute(
|
|
882
|
-
"""
|
|
883
|
-
SELECT id, operation, status, requested_at, started_at, completed_at,
|
|
884
|
-
items_total, items_indexed, items_skipped, error_message, metadata_json
|
|
885
|
-
FROM vector_index_operations
|
|
886
|
-
ORDER BY requested_at DESC, id DESC
|
|
887
|
-
LIMIT 5
|
|
888
|
-
"""
|
|
889
|
-
).fetchall()
|
|
890
|
-
missing = stale = ready = 0
|
|
891
|
-
for item in source_items:
|
|
892
|
-
vector_row = vector_rows.get(item["item_id"])
|
|
893
|
-
expected_hash = _sha256_text(_clean_text(item["text"]))
|
|
894
|
-
if not vector_row:
|
|
895
|
-
missing += 1
|
|
896
|
-
elif (
|
|
897
|
-
vector_row["text_hash"] != expected_hash
|
|
898
|
-
or vector_row["embedding_dim"] != self._embedding_model.dim
|
|
899
|
-
or vector_row["embedding_model"] != self._embedding_model.model_id
|
|
900
|
-
):
|
|
901
|
-
stale += 1
|
|
902
|
-
else:
|
|
903
|
-
ready += 1
|
|
904
|
-
pending = missing + stale
|
|
905
|
-
return {
|
|
906
|
-
"status": "ready" if pending == 0 else "needs_reindex",
|
|
907
|
-
"storage": {
|
|
908
|
-
"db_path": str(self.db_path),
|
|
909
|
-
"backend": "sqlite",
|
|
910
|
-
"embedding_model": self._embedding_model.model_id,
|
|
911
|
-
"embedding_dim": self._embedding_model.dim,
|
|
912
|
-
# Honest capability report: trigram FTS5 keyword index, or
|
|
913
|
-
# LIKE-scan fallback when this SQLite build lacks it.
|
|
914
|
-
"fts_enabled": bool(getattr(self, "_fts_enabled", False)),
|
|
915
|
-
"engine": storage_capabilities,
|
|
916
|
-
"vector_search_backend": (
|
|
917
|
-
storage_capabilities.get("vector_backend")
|
|
918
|
-
if isinstance(storage_capabilities, dict)
|
|
919
|
-
else "bruteforce-cosine"
|
|
920
|
-
),
|
|
921
|
-
"vector_search_mode": (
|
|
922
|
-
(storage_capabilities.get("metadata") or {}).get("vector_mode")
|
|
923
|
-
if isinstance(storage_capabilities, dict)
|
|
924
|
-
else "fallback"
|
|
925
|
-
),
|
|
926
|
-
"sqlite_vec_ann_available": (
|
|
927
|
-
bool((storage_capabilities.get("metadata") or {}).get("sqlite_vec_ann_available"))
|
|
928
|
-
if isinstance(storage_capabilities, dict)
|
|
929
|
-
else False
|
|
930
|
-
),
|
|
931
|
-
},
|
|
932
|
-
"source_items": len(source_items),
|
|
933
|
-
"indexed_items": sum(vector_counts.values()),
|
|
934
|
-
"ready_items": ready,
|
|
935
|
-
"missing_items": missing,
|
|
936
|
-
"stale_items": stale,
|
|
937
|
-
"pending_items": pending,
|
|
938
|
-
"by_item_type": vector_counts,
|
|
939
|
-
"operations": [
|
|
940
|
-
{
|
|
941
|
-
"id": row["id"],
|
|
942
|
-
"operation": row["operation"],
|
|
943
|
-
"status": row["status"],
|
|
944
|
-
"requested_at": row["requested_at"],
|
|
945
|
-
"started_at": row["started_at"],
|
|
946
|
-
"completed_at": row["completed_at"],
|
|
947
|
-
"items_total": row["items_total"],
|
|
948
|
-
"items_indexed": row["items_indexed"],
|
|
949
|
-
"items_skipped": row["items_skipped"],
|
|
950
|
-
"error_message": row["error_message"],
|
|
951
|
-
"metadata": _safe_loads(row["metadata_json"]),
|
|
952
|
-
}
|
|
953
|
-
for row in latest_rows
|
|
954
|
-
],
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
def vector_search(
|
|
958
|
-
self,
|
|
959
|
-
query: str,
|
|
960
|
-
*,
|
|
961
|
-
limit: int = 30,
|
|
962
|
-
min_score: float = 0.0,
|
|
963
|
-
max_candidates: int = 10_000,
|
|
964
|
-
) -> Dict[str, Any]:
|
|
965
|
-
query = str(query or "").strip()
|
|
966
|
-
limit = max(1, min(int(limit or 30), 100))
|
|
967
|
-
min_score = float(min_score or 0.0)
|
|
968
|
-
if not query:
|
|
969
|
-
return {"query": query, "matches": []}
|
|
970
|
-
query_vector = self._embedding_model.embed(query)
|
|
971
|
-
max_candidates = max(limit, min(int(max_candidates or 10_000), 50_000))
|
|
972
|
-
with self._connect() as conn:
|
|
973
|
-
rows = conn.execute(
|
|
974
|
-
"""
|
|
975
|
-
SELECT
|
|
976
|
-
ve.item_id, ve.item_type, ve.source_node, ve.embedding,
|
|
977
|
-
ve.embedding_dim, ve.embedding_model, ve.metadata_json AS vector_metadata,
|
|
978
|
-
n.type AS node_type, n.title AS node_title, n.summary AS node_summary,
|
|
979
|
-
n.metadata_json AS node_metadata, n.updated_at AS node_updated_at,
|
|
980
|
-
c.text AS chunk_text, c.source_node AS parent_node_id,
|
|
981
|
-
pn.type AS parent_type, pn.title AS parent_title,
|
|
982
|
-
pn.summary AS parent_summary, pn.metadata_json AS parent_metadata,
|
|
983
|
-
pn.updated_at AS parent_updated_at
|
|
984
|
-
FROM vector_embeddings ve
|
|
985
|
-
LEFT JOIN nodes n ON n.id=ve.source_node
|
|
986
|
-
LEFT JOIN chunks c ON c.id=ve.item_id
|
|
987
|
-
LEFT JOIN nodes pn ON pn.id=c.source_node
|
|
988
|
-
WHERE ve.embedding_model=? AND ve.embedding_dim=?
|
|
989
|
-
ORDER BY ve.indexed_at DESC
|
|
990
|
-
LIMIT ?
|
|
991
|
-
""",
|
|
992
|
-
(
|
|
993
|
-
self._embedding_model.model_id,
|
|
994
|
-
self._embedding_model.dim,
|
|
995
|
-
max_candidates,
|
|
996
|
-
),
|
|
997
|
-
).fetchall()
|
|
998
|
-
scored = []
|
|
999
|
-
for row in rows:
|
|
1000
|
-
vector = self._embedding_model.decode(
|
|
1001
|
-
row["embedding"], row["embedding_dim"]
|
|
1002
|
-
)
|
|
1003
|
-
score = self._embedding_model.similarity(query_vector, vector)
|
|
1004
|
-
if score < min_score:
|
|
1005
|
-
continue
|
|
1006
|
-
is_chunk = row["item_type"] == "chunk"
|
|
1007
|
-
summary = (
|
|
1008
|
-
row["chunk_text"]
|
|
1009
|
-
if is_chunk and row["chunk_text"]
|
|
1010
|
-
else row["node_summary"]
|
|
1011
|
-
)
|
|
1012
|
-
parent_metadata = _safe_loads(row["parent_metadata"])
|
|
1013
|
-
node_metadata = _safe_loads(row["node_metadata"])
|
|
1014
|
-
scored.append(
|
|
1015
|
-
{
|
|
1016
|
-
"id": row["item_id"],
|
|
1017
|
-
"node_id": row["parent_node_id"]
|
|
1018
|
-
if is_chunk and row["parent_node_id"]
|
|
1019
|
-
else row["source_node"],
|
|
1020
|
-
"item_type": row["item_type"],
|
|
1021
|
-
"type": "Chunk" if is_chunk else row["node_type"],
|
|
1022
|
-
"title": row["parent_title"]
|
|
1023
|
-
if is_chunk and row["parent_title"]
|
|
1024
|
-
else row["node_title"],
|
|
1025
|
-
"summary": _clean_text(summary or "")[:1000],
|
|
1026
|
-
"score": round(float(score), 6),
|
|
1027
|
-
"metadata": {
|
|
1028
|
-
**(parent_metadata if is_chunk else node_metadata),
|
|
1029
|
-
"vector": _safe_loads(row["vector_metadata"]),
|
|
1030
|
-
"parent_node_id": row["parent_node_id"],
|
|
1031
|
-
"parent_type": row["parent_type"],
|
|
1032
|
-
},
|
|
1033
|
-
"updated_at": row["parent_updated_at"]
|
|
1034
|
-
if is_chunk and row["parent_updated_at"]
|
|
1035
|
-
else row["node_updated_at"],
|
|
1036
|
-
}
|
|
1037
|
-
)
|
|
1038
|
-
scored.sort(
|
|
1039
|
-
key=lambda item: (item["score"], item.get("updated_at") or ""), reverse=True
|
|
1040
|
-
)
|
|
1041
|
-
return {
|
|
1042
|
-
"query": query,
|
|
1043
|
-
"embedding_model": self._embedding_model.model_id,
|
|
1044
|
-
"embedding_dim": self._embedding_model.dim,
|
|
1045
|
-
"matches": scored[:limit],
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
777
|
def delete_conversation(self, conversation_id: str) -> Dict[str, Any]:
|
|
1049
778
|
conversation_id = str(conversation_id or "").strip()
|
|
1050
779
|
if not conversation_id:
|
|
@@ -1180,201 +909,3 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
1180
909
|
"local_file_status": local_file_status,
|
|
1181
910
|
"v2": v2,
|
|
1182
911
|
}
|
|
1183
|
-
|
|
1184
|
-
def search_for_document_generation(
|
|
1185
|
-
self, query: str, limit: int = 10
|
|
1186
|
-
) -> List[Dict[str, Any]]:
|
|
1187
|
-
"""Hybrid retrieval optimized for document generation.
|
|
1188
|
-
|
|
1189
|
-
Scoring: 0.5*text_relevance + 0.3*graph_relationship + 0.2*recency
|
|
1190
|
-
Returns nodes with rich context for document generation prompts.
|
|
1191
|
-
"""
|
|
1192
|
-
query = str(query or "").strip()
|
|
1193
|
-
if not query:
|
|
1194
|
-
return []
|
|
1195
|
-
limit = max(1, min(int(limit or 10), 50))
|
|
1196
|
-
terms = _topic_candidates(query, limit=12)
|
|
1197
|
-
now = datetime.now()
|
|
1198
|
-
nt, et = self._read_tables()
|
|
1199
|
-
|
|
1200
|
-
with self._connect() as conn:
|
|
1201
|
-
candidate_rows = []
|
|
1202
|
-
seen_ids = set()
|
|
1203
|
-
|
|
1204
|
-
if query:
|
|
1205
|
-
q = f"%{query}%"
|
|
1206
|
-
rows = conn.execute(
|
|
1207
|
-
f"""
|
|
1208
|
-
SELECT id, type, title, summary, metadata_json, updated_at
|
|
1209
|
-
FROM {nt}
|
|
1210
|
-
WHERE (title LIKE ? OR summary LIKE ? OR metadata_json LIKE ?)
|
|
1211
|
-
AND type IN ('Document', 'File', 'CodeFile', 'SlideDeck',
|
|
1212
|
-
'Spreadsheet', 'Image', 'ImageText', 'Chat',
|
|
1213
|
-
'Decision', 'Task', 'Concept', 'Feature',
|
|
1214
|
-
'Page', 'Slide')
|
|
1215
|
-
ORDER BY updated_at DESC, id ASC
|
|
1216
|
-
LIMIT ?
|
|
1217
|
-
""",
|
|
1218
|
-
(q, q, q, limit * 5),
|
|
1219
|
-
).fetchall()
|
|
1220
|
-
for row in rows:
|
|
1221
|
-
if row["id"] not in seen_ids:
|
|
1222
|
-
seen_ids.add(row["id"])
|
|
1223
|
-
candidate_rows.append(row)
|
|
1224
|
-
|
|
1225
|
-
for term in terms:
|
|
1226
|
-
t = f"%{term}%"
|
|
1227
|
-
rows = conn.execute(
|
|
1228
|
-
f"""
|
|
1229
|
-
SELECT id, type, title, summary, metadata_json, updated_at
|
|
1230
|
-
FROM {nt}
|
|
1231
|
-
WHERE (title LIKE ? OR summary LIKE ? OR metadata_json LIKE ?)
|
|
1232
|
-
AND type IN ('Document', 'File', 'CodeFile', 'SlideDeck',
|
|
1233
|
-
'Spreadsheet', 'Image', 'ImageText', 'Chat',
|
|
1234
|
-
'Decision', 'Task', 'Concept', 'Feature',
|
|
1235
|
-
'Page', 'Slide')
|
|
1236
|
-
ORDER BY updated_at DESC, id ASC
|
|
1237
|
-
LIMIT ?
|
|
1238
|
-
""",
|
|
1239
|
-
(t, t, t, limit * 3),
|
|
1240
|
-
).fetchall()
|
|
1241
|
-
for row in rows:
|
|
1242
|
-
if row["id"] not in seen_ids:
|
|
1243
|
-
seen_ids.add(row["id"])
|
|
1244
|
-
candidate_rows.append(row)
|
|
1245
|
-
|
|
1246
|
-
scored_results = []
|
|
1247
|
-
for row in candidate_rows:
|
|
1248
|
-
haystack = (
|
|
1249
|
-
f"{row['title']} {row['summary']} {row['metadata_json']}".lower()
|
|
1250
|
-
)
|
|
1251
|
-
|
|
1252
|
-
text_hits = sum(1 for term in terms if term.lower() in haystack)
|
|
1253
|
-
text_score = min(1.0, text_hits / max(len(terms), 1))
|
|
1254
|
-
|
|
1255
|
-
edge_count = conn.execute(
|
|
1256
|
-
f"SELECT COUNT(*) AS c FROM {et} WHERE from_node=? OR to_node=?",
|
|
1257
|
-
(row["id"], row["id"]),
|
|
1258
|
-
).fetchone()["c"]
|
|
1259
|
-
graph_score = min(1.0, math.log1p(edge_count) / 4.0)
|
|
1260
|
-
|
|
1261
|
-
recency = _recency_score(
|
|
1262
|
-
row["updated_at"], now=now, half_life_days=14.0
|
|
1263
|
-
)
|
|
1264
|
-
|
|
1265
|
-
doc_type_boost = (
|
|
1266
|
-
1.2
|
|
1267
|
-
if row["type"]
|
|
1268
|
-
in (
|
|
1269
|
-
"Document",
|
|
1270
|
-
"File",
|
|
1271
|
-
"SlideDeck",
|
|
1272
|
-
"Decision",
|
|
1273
|
-
)
|
|
1274
|
-
else 1.0
|
|
1275
|
-
)
|
|
1276
|
-
|
|
1277
|
-
hybrid_score = (
|
|
1278
|
-
0.5 * text_score + 0.3 * graph_score + 0.2 * recency
|
|
1279
|
-
) * doc_type_boost
|
|
1280
|
-
|
|
1281
|
-
meta = _safe_loads(row["metadata_json"])
|
|
1282
|
-
neighbor_concepts = []
|
|
1283
|
-
neighbor_rows = conn.execute(
|
|
1284
|
-
f"""
|
|
1285
|
-
SELECT n.title, n.type FROM {et} e
|
|
1286
|
-
JOIN {nt} n ON n.id = CASE WHEN e.from_node = ? THEN e.to_node ELSE e.from_node END
|
|
1287
|
-
WHERE (e.from_node = ? OR e.to_node = ?)
|
|
1288
|
-
AND n.type IN ('Concept', 'Feature', 'Decision', 'Task')
|
|
1289
|
-
LIMIT 8
|
|
1290
|
-
""",
|
|
1291
|
-
(row["id"], row["id"], row["id"]),
|
|
1292
|
-
).fetchall()
|
|
1293
|
-
for nr in neighbor_rows:
|
|
1294
|
-
neighbor_concepts.append({"title": nr["title"], "type": nr["type"]})
|
|
1295
|
-
|
|
1296
|
-
scored_results.append(
|
|
1297
|
-
{
|
|
1298
|
-
"id": row["id"],
|
|
1299
|
-
"type": row["type"],
|
|
1300
|
-
"title": row["title"],
|
|
1301
|
-
"summary": row["summary"],
|
|
1302
|
-
"metadata": meta,
|
|
1303
|
-
"updated_at": row["updated_at"],
|
|
1304
|
-
"hybrid_score": round(hybrid_score, 4),
|
|
1305
|
-
"scores": {
|
|
1306
|
-
"text": round(text_score, 4),
|
|
1307
|
-
"graph": round(graph_score, 4),
|
|
1308
|
-
"recency": round(recency, 4),
|
|
1309
|
-
},
|
|
1310
|
-
"related_concepts": neighbor_concepts,
|
|
1311
|
-
}
|
|
1312
|
-
)
|
|
1313
|
-
|
|
1314
|
-
scored_results.sort(key=lambda x: x["hybrid_score"], reverse=True)
|
|
1315
|
-
return scored_results[:limit]
|
|
1316
|
-
|
|
1317
|
-
def multi_hop_context(
|
|
1318
|
-
self, node_ids: List[str], max_hops: int = 2
|
|
1319
|
-
) -> Dict[str, Any]:
|
|
1320
|
-
"""Multi-hop graph traversal from seed nodes for richer context."""
|
|
1321
|
-
visited_nodes = set()
|
|
1322
|
-
visited_edges = set()
|
|
1323
|
-
all_nodes = []
|
|
1324
|
-
all_edges = []
|
|
1325
|
-
frontier = set(node_ids)
|
|
1326
|
-
nt, et = self._read_tables()
|
|
1327
|
-
|
|
1328
|
-
with self._connect() as conn:
|
|
1329
|
-
for hop in range(max_hops):
|
|
1330
|
-
if not frontier:
|
|
1331
|
-
break
|
|
1332
|
-
next_frontier = set()
|
|
1333
|
-
for nid in frontier:
|
|
1334
|
-
if nid in visited_nodes:
|
|
1335
|
-
continue
|
|
1336
|
-
visited_nodes.add(nid)
|
|
1337
|
-
row = conn.execute(
|
|
1338
|
-
f"SELECT id, type, title, summary, metadata_json, updated_at FROM {nt} WHERE id=?",
|
|
1339
|
-
(nid,),
|
|
1340
|
-
).fetchone()
|
|
1341
|
-
if row:
|
|
1342
|
-
all_nodes.append(
|
|
1343
|
-
{
|
|
1344
|
-
"id": row["id"],
|
|
1345
|
-
"type": row["type"],
|
|
1346
|
-
"title": row["title"],
|
|
1347
|
-
"summary": row["summary"],
|
|
1348
|
-
"metadata": _safe_loads(row["metadata_json"]),
|
|
1349
|
-
"hop": hop,
|
|
1350
|
-
}
|
|
1351
|
-
)
|
|
1352
|
-
edge_rows = conn.execute(
|
|
1353
|
-
f"""
|
|
1354
|
-
SELECT id, from_node, to_node, type, weight
|
|
1355
|
-
FROM {et} WHERE from_node=? OR to_node=?
|
|
1356
|
-
ORDER BY id ASC
|
|
1357
|
-
""",
|
|
1358
|
-
(nid, nid),
|
|
1359
|
-
).fetchall()
|
|
1360
|
-
for er in edge_rows:
|
|
1361
|
-
if er["id"] not in visited_edges:
|
|
1362
|
-
visited_edges.add(er["id"])
|
|
1363
|
-
all_edges.append(
|
|
1364
|
-
{
|
|
1365
|
-
"from": er["from_node"],
|
|
1366
|
-
"to": er["to_node"],
|
|
1367
|
-
"type": er["type"],
|
|
1368
|
-
"weight": er["weight"],
|
|
1369
|
-
}
|
|
1370
|
-
)
|
|
1371
|
-
other = (
|
|
1372
|
-
er["to_node"]
|
|
1373
|
-
if er["from_node"] == nid
|
|
1374
|
-
else er["from_node"]
|
|
1375
|
-
)
|
|
1376
|
-
if other not in visited_nodes:
|
|
1377
|
-
next_frontier.add(other)
|
|
1378
|
-
frontier = next_frontier
|
|
1379
|
-
|
|
1380
|
-
return {"nodes": all_nodes, "edges": all_edges}
|