ltcai 3.6.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -31
- package/docs/CHANGELOG.md +64 -0
- package/docs/REALTIME_COLLABORATION.md +3 -3
- package/docs/V3_FRONTEND.md +9 -8
- package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +552 -0
- package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
- package/docs/kg-schema.md +51 -53
- package/docs/spec-vs-impl.md +10 -10
- package/kg_schema.py +2 -520
- package/knowledge_graph.py +37 -4629
- package/knowledge_graph_api.py +11 -127
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +16 -17
- package/latticeai/api/agents.py +20 -7
- package/latticeai/api/auth.py +46 -15
- package/latticeai/api/chat.py +112 -76
- package/latticeai/api/health.py +1 -1
- package/latticeai/api/hooks.py +1 -1
- package/latticeai/api/invitations.py +100 -0
- package/latticeai/api/knowledge_graph.py +139 -0
- package/latticeai/api/local_files.py +1 -1
- package/latticeai/api/mcp.py +23 -11
- package/latticeai/api/memory.py +1 -1
- package/latticeai/api/models.py +1 -1
- package/latticeai/api/network.py +81 -0
- package/latticeai/api/plugins.py +3 -6
- package/latticeai/api/realtime.py +5 -8
- package/latticeai/api/search.py +26 -2
- package/latticeai/api/security_dashboard.py +2 -3
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +11 -16
- package/latticeai/api/tools.py +3 -0
- package/latticeai/api/ui_redirects.py +26 -0
- package/latticeai/api/workflow_designer.py +85 -6
- package/latticeai/api/workspace.py +93 -57
- package/latticeai/app_factory.py +1781 -0
- package/latticeai/brain/__init__.py +18 -0
- package/latticeai/brain/_kg_common.py +1123 -0
- package/latticeai/brain/context.py +213 -0
- package/latticeai/brain/conversations.py +236 -0
- package/latticeai/brain/discovery.py +1455 -0
- package/latticeai/brain/documents.py +218 -0
- package/latticeai/brain/identity.py +175 -0
- package/latticeai/brain/ingest.py +644 -0
- package/latticeai/brain/memory.py +102 -0
- package/latticeai/brain/network.py +205 -0
- package/latticeai/brain/projection.py +561 -0
- package/latticeai/brain/provenance.py +401 -0
- package/latticeai/brain/retrieval.py +1316 -0
- package/latticeai/brain/schema.py +640 -0
- package/latticeai/brain/store.py +216 -0
- package/latticeai/brain/write_master.py +225 -0
- package/latticeai/core/agent.py +31 -7
- package/latticeai/core/audit.py +0 -7
- package/latticeai/core/config.py +1 -1
- package/latticeai/core/context_builder.py +1 -2
- package/latticeai/core/enterprise.py +1 -1
- package/latticeai/core/graph_curator.py +2 -2
- package/latticeai/core/invitations.py +131 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +791 -0
- package/latticeai/core/model_compat.py +1 -1
- package/latticeai/core/model_resolution.py +0 -1
- package/latticeai/core/multi_agent.py +238 -4
- package/latticeai/core/policy.py +54 -0
- package/latticeai/core/realtime.py +65 -44
- package/latticeai/core/security.py +1 -1
- package/latticeai/core/sessions.py +66 -10
- package/latticeai/core/users.py +147 -0
- package/latticeai/core/workflow_engine.py +114 -2
- package/latticeai/core/workspace_os.py +477 -29
- package/latticeai/models/__init__.py +7 -0
- package/latticeai/models/router.py +779 -0
- package/latticeai/server_app.py +29 -1536
- package/latticeai/services/agent_runtime.py +243 -4
- package/latticeai/services/app_context.py +75 -14
- package/latticeai/services/ingestion.py +47 -0
- package/latticeai/services/kg_portability.py +33 -3
- package/latticeai/services/memory_service.py +39 -11
- package/latticeai/services/model_runtime.py +2 -5
- package/latticeai/services/platform_runtime.py +100 -23
- package/latticeai/services/run_executor.py +328 -0
- package/latticeai/services/search_service.py +17 -8
- package/latticeai/services/tool_dispatch.py +12 -2
- package/latticeai/services/triggers.py +241 -0
- package/latticeai/services/upload_service.py +37 -12
- package/latticeai/services/workspace_service.py +55 -16
- package/llm_router.py +29 -772
- package/ltcai_cli.py +1 -2
- package/mcp_registry.py +25 -788
- package/p_reinforce.py +124 -14
- package/package.json +10 -20
- package/scripts/bump_version.py +99 -0
- package/scripts/generate_diagrams.py +0 -1
- package/scripts/lint_v3.mjs +105 -18
- package/scripts/validate_release_artifacts.py +0 -1
- package/scripts/wheel_smoke.py +142 -0
- package/server.py +11 -7
- package/setup_wizard.py +1142 -0
- package/static/sw.js +81 -52
- package/static/v3/asset-manifest.json +33 -25
- package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
- package/static/v3/css/lattice.base.css +1 -1
- package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
- package/static/v3/css/lattice.components.css +1 -1
- package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
- package/static/v3/css/lattice.shell.css +1 -1
- package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
- package/static/v3/css/lattice.tokens.css +3 -0
- package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
- package/static/v3/css/lattice.views.css +2 -2
- package/static/v3/index.html +3 -4
- package/static/v3/js/{app.c541f955.js → app.c5c80c46.js} +1 -1
- package/static/v3/js/core/{api.33d6320e.js → api.ba0fbf14.js} +58 -1
- package/static/v3/js/core/api.js +57 -0
- package/static/v3/js/core/i18n.880e1fec.js +575 -0
- package/static/v3/js/core/i18n.js +575 -0
- package/static/v3/js/core/routes.37522821.js +101 -0
- package/static/v3/js/core/routes.js +71 -63
- package/static/v3/js/core/{shell.8c163e0e.js → shell.e3f6bbfa.js} +68 -39
- package/static/v3/js/core/shell.js +66 -37
- package/static/v3/js/core/{store.34ebd5e6.js → store.7b2aa044.js} +11 -1
- package/static/v3/js/core/store.js +11 -1
- package/static/v3/js/views/account.eff40715.js +143 -0
- package/static/v3/js/views/account.js +143 -0
- package/static/v3/js/views/activity.0d271ef9.js +67 -0
- package/static/v3/js/views/activity.js +67 -0
- package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
- package/static/v3/js/views/admin-users.js +4 -6
- package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
- package/static/v3/js/views/agents.js +35 -12
- package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
- package/static/v3/js/views/chat.js +23 -0
- package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
- package/static/v3/js/views/graph-canvas.js +509 -0
- package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
- package/static/v3/js/views/hybrid-search.js +1 -2
- package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.4d09c537.js} +60 -44
- package/static/v3/js/views/knowledge-graph.js +60 -44
- package/static/v3/js/views/network.52a4f181.js +97 -0
- package/static/v3/js/views/network.js +97 -0
- package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
- package/static/v3/js/views/planning.js +26 -5
- package/static/v3/js/views/runs.b63b2afa.js +144 -0
- package/static/v3/js/views/runs.js +144 -0
- package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
- package/static/v3/js/views/settings.js +7 -8
- package/static/v3/js/views/snapshots.6f5db095.js +135 -0
- package/static/v3/js/views/snapshots.js +135 -0
- package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
- package/static/v3/js/views/workflows.js +87 -2
- package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
- package/static/v3/js/views/workspace-admin.js +156 -0
- package/static/vendor/chart.umd.min.js +20 -0
- package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
- package/static/vendor/fonts/inter.css +44 -0
- package/static/vendor/icons/tabler-icons.min.css +4 -0
- package/static/vendor/icons/tabler-icons.woff2 +0 -0
- package/static/vendor/marked.min.js +69 -0
- package/telegram_bot.py +1 -2
- package/tools/commands.py +4 -2
- package/tools/computer.py +1 -1
- package/tools/documents.py +1 -3
- package/tools/filesystem.py +0 -4
- package/tools/knowledge.py +1 -3
- package/tools/network.py +1 -3
- package/codex_telegram_bot.py +0 -195
- package/docs/assets/v3.4.0/agent-run.png +0 -0
- package/docs/assets/v3.4.0/agents.png +0 -0
- package/docs/assets/v3.4.0/before/chat-before.png +0 -0
- package/docs/assets/v3.4.0/before/files-before.png +0 -0
- package/docs/assets/v3.4.0/chat.png +0 -0
- package/docs/assets/v3.4.0/connect-folder.png +0 -0
- package/docs/assets/v3.4.0/files.png +0 -0
- package/docs/assets/v3.4.0/home.png +0 -0
- package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
- package/docs/assets/v3.4.0/local-agent.png +0 -0
- package/docs/assets/v3.4.0/memory.png +0 -0
- package/docs/assets/v3.4.0/settings.png +0 -0
- package/docs/assets/v3.4.0/vision-input.png +0 -0
- package/docs/assets/v3.4.0/workflows.png +0 -0
- package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
- package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.1/local-agent.png +0 -0
- package/docs/images/admin-dashboard.png +0 -0
- package/docs/images/architecture.png +0 -0
- package/docs/images/enterprise.png +0 -0
- package/docs/images/graph.png +0 -0
- package/docs/images/hero.gif +0 -0
- package/docs/images/knowledge-graph.png +0 -0
- package/docs/images/lattice-ai-demo.gif +0 -0
- package/docs/images/lattice-ai-hero.png +0 -0
- package/docs/images/logo.svg +0 -33
- package/docs/images/mobile-responsive.png +0 -0
- package/docs/images/model-recommendation.png +0 -0
- package/docs/images/onboarding.png +0 -0
- package/docs/images/organization.png +0 -0
- package/docs/images/pipeline.png +0 -0
- package/docs/images/screenshot-admin.png +0 -0
- package/docs/images/screenshot-chat.png +0 -0
- package/docs/images/screenshot-graph.png +0 -0
- package/docs/images/skills.png +0 -0
- package/docs/images/workspace-dark.png +0 -0
- package/docs/images/workspace-light.png +0 -0
- package/docs/images/workspace.png +0 -0
- package/requirements.txt +0 -16
- package/static/account.html +0 -115
- package/static/activity.html +0 -73
- package/static/admin.html +0 -488
- package/static/agents.html +0 -139
- package/static/chat.html +0 -844
- package/static/css/reference/account.css +0 -439
- package/static/css/reference/admin.css +0 -610
- package/static/css/reference/base.css +0 -1661
- package/static/css/reference/chat.css +0 -4623
- package/static/css/reference/graph.css +0 -1016
- package/static/css/responsive.css +0 -861
- package/static/graph.html +0 -124
- package/static/platform.css +0 -104
- package/static/plugins.html +0 -136
- package/static/scripts/account.js +0 -238
- package/static/scripts/admin.js +0 -1614
- package/static/scripts/chat.js +0 -5081
- package/static/scripts/graph.js +0 -1804
- package/static/scripts/platform.js +0 -64
- package/static/scripts/ux.js +0 -167
- package/static/scripts/workspace.js +0 -948
- package/static/v3/js/core/routes.2ce3815a.js +0 -93
- package/static/workflows.html +0 -146
- package/static/workspace.css +0 -1121
- package/static/workspace.html +0 -357
|
@@ -21,6 +21,7 @@ it, the frontend) now depends on this boundary instead of internal paths.
|
|
|
21
21
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
|
+
from datetime import datetime
|
|
24
25
|
from typing import Any, Callable, Dict, List, Optional
|
|
25
26
|
|
|
26
27
|
from latticeai.core.multi_agent import (
|
|
@@ -41,8 +42,12 @@ ROLE_DESCRIPTIONS = {
|
|
|
41
42
|
# Run statuses the orchestrator can emit that mean "still working". The default
|
|
42
43
|
# orchestrator runs synchronously, so persisted runs are always terminal; this
|
|
43
44
|
# set lets the runtime report live work if a future async runner lands.
|
|
44
|
-
_ACTIVE_STATUSES = {"running", "in_progress", "queued", "retrying"}
|
|
45
|
-
_TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled"}
|
|
45
|
+
_ACTIVE_STATUSES = {"running", "in_progress", "queued", "retrying", "cancelling"}
|
|
46
|
+
_TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _now() -> str:
|
|
50
|
+
return datetime.now().isoformat(timespec="seconds")
|
|
46
51
|
|
|
47
52
|
|
|
48
53
|
class AgentRuntime:
|
|
@@ -64,6 +69,13 @@ class AgentRuntime:
|
|
|
64
69
|
# Lifecycle hooks registry (optional). When present, ``start`` fires the
|
|
65
70
|
# ``pre_run`` / ``post_run`` hooks; a blocking ``pre_run`` aborts the run.
|
|
66
71
|
self._hooks = hooks
|
|
72
|
+
self._run_executor: Any = None
|
|
73
|
+
|
|
74
|
+
def attach_executor(self, executor: Any) -> None:
|
|
75
|
+
self._run_executor = executor
|
|
76
|
+
|
|
77
|
+
def _execution_mode(self) -> str:
|
|
78
|
+
return "async" if self._run_executor is not None else "synchronous"
|
|
67
79
|
|
|
68
80
|
# ── configuration ─────────────────────────────────────────────────────
|
|
69
81
|
def config(self) -> Dict[str, Any]:
|
|
@@ -72,7 +84,12 @@ class AgentRuntime:
|
|
|
72
84
|
"roles": list(AGENT_ROLES),
|
|
73
85
|
"default_pipeline": list(CORE_PIPELINE),
|
|
74
86
|
"max_retries_cap": self._max_retries_cap,
|
|
75
|
-
"execution_mode":
|
|
87
|
+
"execution_mode": self._execution_mode(),
|
|
88
|
+
"cancellation": (
|
|
89
|
+
"cooperative; running synchronous model/tool calls finish their current step before a cancelled status is persisted"
|
|
90
|
+
if self._run_executor is not None else
|
|
91
|
+
"not supported for the synchronous runtime"
|
|
92
|
+
),
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
def roles(self) -> List[Dict[str, Any]]:
|
|
@@ -150,7 +167,7 @@ class AgentRuntime:
|
|
|
150
167
|
"runtime": {
|
|
151
168
|
"ready": True,
|
|
152
169
|
"version": MULTI_AGENT_VERSION,
|
|
153
|
-
"execution_mode":
|
|
170
|
+
"execution_mode": self._execution_mode(),
|
|
154
171
|
"default_pipeline": list(CORE_PIPELINE),
|
|
155
172
|
"total_runs": len(runs),
|
|
156
173
|
"active_runs": active,
|
|
@@ -184,6 +201,225 @@ class AgentRuntime:
|
|
|
184
201
|
}
|
|
185
202
|
|
|
186
203
|
# ── execution ─────────────────────────────────────────────────────────
|
|
204
|
+
def _fire_pre_run(
|
|
205
|
+
self,
|
|
206
|
+
*,
|
|
207
|
+
goal: str,
|
|
208
|
+
roles: Optional[List[str]],
|
|
209
|
+
max_retries: int,
|
|
210
|
+
user_email: Optional[str],
|
|
211
|
+
scope: Optional[str],
|
|
212
|
+
) -> Optional[Dict[str, Any]]:
|
|
213
|
+
pre_dispatch: Optional[Dict[str, Any]] = None
|
|
214
|
+
if self._hooks is not None:
|
|
215
|
+
pre_dispatch = self._hooks.fire_hook(
|
|
216
|
+
"pre_run", "agent.run",
|
|
217
|
+
payload={"goal": goal, "roles": roles or None, "max_retries": max_retries},
|
|
218
|
+
user_email=user_email, workspace_id=scope,
|
|
219
|
+
)
|
|
220
|
+
if pre_dispatch.get("blocked"):
|
|
221
|
+
self._append_audit_event(
|
|
222
|
+
"multi_agent_run_blocked",
|
|
223
|
+
user_email=user_email,
|
|
224
|
+
reason=pre_dispatch.get("block_reason"),
|
|
225
|
+
)
|
|
226
|
+
raise PermissionError(pre_dispatch.get("block_reason") or "Agent run blocked by a pre_run hook.")
|
|
227
|
+
return pre_dispatch
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _result_patch(result: Any, goal: str) -> Dict[str, Any]:
|
|
231
|
+
return {
|
|
232
|
+
"agent_id": result.agent_id,
|
|
233
|
+
"status": result.status,
|
|
234
|
+
"input": goal,
|
|
235
|
+
"output_text": result.output,
|
|
236
|
+
"timeline": result.timeline,
|
|
237
|
+
"relationships": [ROLE_AGENT_IDS.get(r, f"agent:{r}") for r in result.roles_run],
|
|
238
|
+
"handoffs": result.handoffs,
|
|
239
|
+
"context_packets": result.context_packets,
|
|
240
|
+
"plan": result.plan,
|
|
241
|
+
"plan_review": result.plan_review,
|
|
242
|
+
"review_history": result.review_history,
|
|
243
|
+
"retry_history": result.retry_history,
|
|
244
|
+
"memory_snapshots": result.memory_snapshots,
|
|
245
|
+
"mode": getattr(result, "mode", "simulation"),
|
|
246
|
+
"current_role": None,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
def _post_run_hooks(
|
|
250
|
+
self,
|
|
251
|
+
*,
|
|
252
|
+
run_id: Optional[str],
|
|
253
|
+
result: Any,
|
|
254
|
+
user_email: Optional[str],
|
|
255
|
+
scope: Optional[str],
|
|
256
|
+
status: Optional[str] = None,
|
|
257
|
+
) -> Optional[Dict[str, Any]]:
|
|
258
|
+
if self._hooks is None:
|
|
259
|
+
return None
|
|
260
|
+
return self._hooks.fire_hook(
|
|
261
|
+
"post_run", "agent.run",
|
|
262
|
+
payload={
|
|
263
|
+
"run_id": run_id,
|
|
264
|
+
"agent_id": result.agent_id,
|
|
265
|
+
"status": status or result.status,
|
|
266
|
+
"retries": result.retries,
|
|
267
|
+
},
|
|
268
|
+
user_email=user_email, workspace_id=scope,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def reserve_run(
|
|
272
|
+
self,
|
|
273
|
+
goal: str,
|
|
274
|
+
*,
|
|
275
|
+
user_email: Optional[str],
|
|
276
|
+
scope: Optional[str],
|
|
277
|
+
roles: Optional[List[str]] = None,
|
|
278
|
+
inputs: Optional[Dict[str, Any]] = None,
|
|
279
|
+
max_retries: int = 2,
|
|
280
|
+
) -> Dict[str, Any]:
|
|
281
|
+
"""Create the durable queued row used by the async executor."""
|
|
282
|
+
if not str(goal or "").strip():
|
|
283
|
+
raise ValueError("goal is required")
|
|
284
|
+
pre_dispatch = self._fire_pre_run(
|
|
285
|
+
goal=goal,
|
|
286
|
+
roles=roles,
|
|
287
|
+
max_retries=max_retries,
|
|
288
|
+
user_email=user_email,
|
|
289
|
+
scope=scope,
|
|
290
|
+
)
|
|
291
|
+
try:
|
|
292
|
+
orchestrator = self._orchestrator_factory(user_email or None, scope)
|
|
293
|
+
mode = getattr(orchestrator, "mode", "simulation")
|
|
294
|
+
except Exception:
|
|
295
|
+
mode = "simulation"
|
|
296
|
+
run = self._store.record_agent_run(
|
|
297
|
+
agent_id=ROLE_AGENT_IDS.get("executor", "agent:executor"),
|
|
298
|
+
status="queued",
|
|
299
|
+
input_text=goal,
|
|
300
|
+
output_text="",
|
|
301
|
+
timeline=[{"event": "agent_started", "status": "queued", "timestamp": _now()}],
|
|
302
|
+
relationships=[],
|
|
303
|
+
handoffs=[],
|
|
304
|
+
context_packets=[],
|
|
305
|
+
plan=[],
|
|
306
|
+
plan_review={},
|
|
307
|
+
review_history=[],
|
|
308
|
+
retry_history=[],
|
|
309
|
+
memory_snapshots=[],
|
|
310
|
+
user_email=user_email or None,
|
|
311
|
+
graph=None,
|
|
312
|
+
workspace_id=scope,
|
|
313
|
+
mode=mode,
|
|
314
|
+
)
|
|
315
|
+
run = self._store.update_agent_run(
|
|
316
|
+
run.get("id"),
|
|
317
|
+
workspace_id=scope,
|
|
318
|
+
execution_mode="async",
|
|
319
|
+
requested_roles=roles or None,
|
|
320
|
+
inputs=inputs or {},
|
|
321
|
+
max_retries=max_retries,
|
|
322
|
+
)
|
|
323
|
+
payload: Dict[str, Any] = {"run": run}
|
|
324
|
+
if pre_dispatch is not None:
|
|
325
|
+
payload["pre_run_hooks"] = pre_dispatch
|
|
326
|
+
return payload
|
|
327
|
+
|
|
328
|
+
def complete_reserved_run(
|
|
329
|
+
self,
|
|
330
|
+
run_id: str,
|
|
331
|
+
goal: str,
|
|
332
|
+
*,
|
|
333
|
+
user_email: Optional[str],
|
|
334
|
+
scope: Optional[str],
|
|
335
|
+
roles: Optional[List[str]] = None,
|
|
336
|
+
inputs: Optional[Dict[str, Any]] = None,
|
|
337
|
+
max_retries: int = 2,
|
|
338
|
+
pre_dispatch: Optional[Dict[str, Any]] = None,
|
|
339
|
+
cancel_requested: Optional[Callable[[], bool]] = None,
|
|
340
|
+
) -> Dict[str, Any]:
|
|
341
|
+
"""Execute orchestration and update an existing durable async row."""
|
|
342
|
+
run = self._store.get_agent_run(run_id, workspace_id=scope)
|
|
343
|
+
base_timeline = list(run.get("timeline") or [])
|
|
344
|
+
self._store.update_agent_run(
|
|
345
|
+
run_id,
|
|
346
|
+
workspace_id=scope,
|
|
347
|
+
status="running",
|
|
348
|
+
current_role=(roles or list(CORE_PIPELINE))[0] if (roles or CORE_PIPELINE) else None,
|
|
349
|
+
started_at=run.get("started_at") or _now(),
|
|
350
|
+
)
|
|
351
|
+
try:
|
|
352
|
+
orchestrator = self._orchestrator_factory(user_email or None, scope)
|
|
353
|
+
result = orchestrator.run(
|
|
354
|
+
goal,
|
|
355
|
+
user_email=user_email or None,
|
|
356
|
+
workspace_id=scope,
|
|
357
|
+
inputs=inputs or {},
|
|
358
|
+
roles=roles or None,
|
|
359
|
+
max_retries=max(0, min(int(max_retries or 0), self._max_retries_cap)),
|
|
360
|
+
)
|
|
361
|
+
except Exception as exc:
|
|
362
|
+
failed = self._store.update_agent_run(
|
|
363
|
+
run_id,
|
|
364
|
+
workspace_id=scope,
|
|
365
|
+
graph=self._workspace_graph(),
|
|
366
|
+
status="failed",
|
|
367
|
+
current_role=None,
|
|
368
|
+
error=str(exc),
|
|
369
|
+
output_text=str(exc),
|
|
370
|
+
timeline=base_timeline + [{
|
|
371
|
+
"event": "execution_failed",
|
|
372
|
+
"status": "failed",
|
|
373
|
+
"detail": str(exc),
|
|
374
|
+
"timestamp": _now(),
|
|
375
|
+
}],
|
|
376
|
+
)
|
|
377
|
+
self._append_audit_event("multi_agent_run", user_email=user_email, agent_id=failed.get("agent_id"), status="failed", retries=0)
|
|
378
|
+
return {"run": failed, "result": {"status": "failed", "error": str(exc)}}
|
|
379
|
+
|
|
380
|
+
patch = self._result_patch(result, goal)
|
|
381
|
+
patch["timeline"] = base_timeline + list(result.timeline or [])
|
|
382
|
+
if cancel_requested is not None and cancel_requested():
|
|
383
|
+
patch["status"] = "cancelled"
|
|
384
|
+
patch["current_role"] = None
|
|
385
|
+
patch["cancel_reason"] = "cancelled after the current synchronous step completed"
|
|
386
|
+
patch["cancelled_at"] = _now()
|
|
387
|
+
patch["timeline"] = patch["timeline"] + [{
|
|
388
|
+
"event": "execution_cancelled",
|
|
389
|
+
"status": "cancelled",
|
|
390
|
+
"reason": patch["cancel_reason"],
|
|
391
|
+
"timestamp": _now(),
|
|
392
|
+
}]
|
|
393
|
+
updated = self._store.update_agent_run(
|
|
394
|
+
run_id,
|
|
395
|
+
workspace_id=scope,
|
|
396
|
+
graph=self._workspace_graph(),
|
|
397
|
+
patch=patch,
|
|
398
|
+
)
|
|
399
|
+
self._append_audit_event(
|
|
400
|
+
"multi_agent_run",
|
|
401
|
+
user_email=user_email,
|
|
402
|
+
agent_id=result.agent_id,
|
|
403
|
+
status=updated.get("status") or result.status,
|
|
404
|
+
retries=result.retries,
|
|
405
|
+
)
|
|
406
|
+
post_dispatch = self._post_run_hooks(
|
|
407
|
+
run_id=run_id,
|
|
408
|
+
result=result,
|
|
409
|
+
user_email=user_email,
|
|
410
|
+
scope=scope,
|
|
411
|
+
status=updated.get("status") or result.status,
|
|
412
|
+
)
|
|
413
|
+
result_payload = result.as_dict()
|
|
414
|
+
if updated.get("status") == "cancelled":
|
|
415
|
+
result_payload = {"status": "cancelled", "reason": updated.get("cancel_reason"), "completed_result": result_payload}
|
|
416
|
+
payload = {"run": updated, "result": result_payload}
|
|
417
|
+
if pre_dispatch is not None:
|
|
418
|
+
payload["pre_run_hooks"] = pre_dispatch
|
|
419
|
+
if post_dispatch is not None:
|
|
420
|
+
payload["post_run_hooks"] = post_dispatch
|
|
421
|
+
return payload
|
|
422
|
+
|
|
187
423
|
def start(
|
|
188
424
|
self,
|
|
189
425
|
goal: str,
|
|
@@ -242,6 +478,7 @@ class AgentRuntime:
|
|
|
242
478
|
user_email=user_email or None,
|
|
243
479
|
graph=self._workspace_graph(),
|
|
244
480
|
workspace_id=scope,
|
|
481
|
+
mode=getattr(result, "mode", "simulation"),
|
|
245
482
|
)
|
|
246
483
|
self._append_audit_event(
|
|
247
484
|
"multi_agent_run",
|
|
@@ -280,6 +517,8 @@ class AgentRuntime:
|
|
|
280
517
|
exists the run has already completed. Report that honestly rather than
|
|
281
518
|
pretending a cancellation occurred.
|
|
282
519
|
"""
|
|
520
|
+
if self._run_executor is not None:
|
|
521
|
+
return self._run_executor.cancel(run_id, kind="agent", scope=scope)
|
|
283
522
|
try:
|
|
284
523
|
run = self._store.get_agent_run(run_id, workspace_id=scope)
|
|
285
524
|
except FileNotFoundError:
|
|
@@ -1,27 +1,88 @@
|
|
|
1
1
|
"""Application dependency context for router assembly.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
``latticeai.app_factory.create_app`` builds one ``AppContext`` per app and
|
|
4
|
+
hands it to router factories, replacing the historical 25-30-kwarg closure
|
|
5
|
+
wiring. Every field defaults to ``None``-ish so tests can construct a context
|
|
6
|
+
carrying only the dependencies a router actually touches.
|
|
7
|
+
|
|
8
|
+
Fields are grouped by the consumer that motivated them; routers must treat the
|
|
9
|
+
context as read-only.
|
|
6
10
|
"""
|
|
7
11
|
|
|
8
12
|
from __future__ import annotations
|
|
9
13
|
|
|
10
14
|
from dataclasses import dataclass
|
|
11
15
|
from pathlib import Path
|
|
12
|
-
from typing import Any, Callable
|
|
16
|
+
from typing import Any, Callable, Optional
|
|
13
17
|
|
|
14
18
|
|
|
15
19
|
@dataclass(frozen=True)
|
|
16
20
|
class AppContext:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
# ── core configuration / paths ────────────────────────────────────────
|
|
22
|
+
config: Any = None
|
|
23
|
+
data_dir: Optional[Path] = None
|
|
24
|
+
static_dir: Optional[Path] = None
|
|
25
|
+
base_dir: Optional[Path] = None
|
|
26
|
+
skills_dir: Optional[Path] = None
|
|
27
|
+
|
|
28
|
+
# ── singletons ────────────────────────────────────────────────────────
|
|
29
|
+
model_router: Any = None
|
|
30
|
+
workspace_store: Any = None
|
|
31
|
+
workspace_service: Any = None
|
|
32
|
+
knowledge_graph: Any = None
|
|
33
|
+
local_kg_watcher: Any = None
|
|
34
|
+
chat_service: Any = None
|
|
35
|
+
context_assembler: Any = None
|
|
36
|
+
brain_memory: Any = None
|
|
37
|
+
gardener: Any = None
|
|
38
|
+
hooks: Any = None
|
|
39
|
+
realtime_bus: Any = None
|
|
40
|
+
capability_registry: Any = None
|
|
41
|
+
|
|
42
|
+
# ── auth / session callables ──────────────────────────────────────────
|
|
43
|
+
require_user: Optional[Callable[..., str]] = None
|
|
44
|
+
require_admin: Optional[Callable[..., tuple]] = None
|
|
45
|
+
get_current_user: Optional[Callable[..., Optional[str]]] = None
|
|
46
|
+
load_users: Optional[Callable[[], dict]] = None
|
|
47
|
+
get_user_role: Optional[Callable[..., str]] = None
|
|
48
|
+
enforce_rate_limit: Optional[Callable[..., None]] = None
|
|
49
|
+
|
|
50
|
+
# ── audit / history callables ─────────────────────────────────────────
|
|
51
|
+
append_audit_event: Optional[Callable[..., None]] = None
|
|
52
|
+
get_audit_log: Optional[Callable[[], list]] = None
|
|
53
|
+
get_history: Optional[Callable[[], list]] = None
|
|
54
|
+
get_history_user: Optional[Callable[..., dict]] = None
|
|
55
|
+
save_to_history: Optional[Callable[..., None]] = None
|
|
56
|
+
clear_history: Optional[Callable[..., dict]] = None
|
|
57
|
+
clear_conversation: Optional[Callable[..., dict]] = None
|
|
58
|
+
group_history_conversations: Optional[Callable[..., list]] = None
|
|
59
|
+
get_conversation_messages: Optional[Callable[..., list]] = None
|
|
60
|
+
conversation_title: Optional[Callable[..., str]] = None
|
|
61
|
+
|
|
62
|
+
# ── knowledge graph access ────────────────────────────────────────────
|
|
63
|
+
enable_graph: bool = False
|
|
64
|
+
require_graph: Optional[Callable[[], None]] = None
|
|
65
|
+
workspace_graph: Optional[Callable[[], Any]] = None
|
|
66
|
+
graph_stats: Optional[Callable[[], dict]] = None
|
|
67
|
+
|
|
68
|
+
# ── workspace payload providers / skills ──────────────────────────────
|
|
69
|
+
workspace_models: Optional[Callable[[], dict]] = None
|
|
70
|
+
workspace_settings: Optional[Callable[[], dict]] = None
|
|
71
|
+
scan_environment: Optional[Callable[[], Any]] = None
|
|
72
|
+
local_sysinfo: Optional[Callable[..., Any]] = None
|
|
73
|
+
get_recommendations: Optional[Callable[..., Any]] = None
|
|
74
|
+
fetch_skills_marketplace: Optional[Callable[..., Any]] = None
|
|
75
|
+
install_skill: Optional[Callable[..., Any]] = None
|
|
76
|
+
remove_skill_directory: Optional[Callable[..., dict]] = None
|
|
77
|
+
redact_secret_text: Optional[Callable[[str], str]] = None
|
|
78
|
+
ui_file_response: Optional[Callable[..., Any]] = None
|
|
79
|
+
|
|
80
|
+
# ── models ────────────────────────────────────────────────────────────
|
|
81
|
+
public_model: str = ""
|
|
82
|
+
local_model: str = ""
|
|
27
83
|
|
|
84
|
+
# ── integrations ──────────────────────────────────────────────────────
|
|
85
|
+
# Fired as on_chat_message(role, text, source) after a chat exchange is
|
|
86
|
+
# persisted; ``None`` means no external chat mirror is registered. The
|
|
87
|
+
# telegram bridge subscribes here only when ENABLE_TELEGRAM is truthy.
|
|
88
|
+
on_chat_message: Optional[Callable[..., None]] = None
|
|
@@ -33,6 +33,15 @@ FILE_SOURCE_TYPES = frozenset({"file", "local_file", "upload", "pdf"})
|
|
|
33
33
|
TEXT_SOURCE_TYPES = frozenset(
|
|
34
34
|
{"web_url", "browser_tab", "text", "markdown", "note", "code", "clipboard"}
|
|
35
35
|
)
|
|
36
|
+
# Conversational exchanges (read via ingest_message — role/content semantics,
|
|
37
|
+
# conversation chaining). v4: chat and MCP messages stop bypassing the
|
|
38
|
+
# pipeline, so they carry provenance and fire the hook lifecycle like every
|
|
39
|
+
# other source.
|
|
40
|
+
CHAT_SOURCE_TYPES = frozenset({"chat_message", "mcp_message"})
|
|
41
|
+
# Typed memory records (read via ingest_event → Decision/Experience/Event
|
|
42
|
+
# nodes). The Memory System writes through the same door as everything else.
|
|
43
|
+
MEMORY_SOURCE_TYPES = frozenset({"decision", "experience", "workspace_event"})
|
|
44
|
+
_MEMORY_NODE_TYPES = {"decision": "Decision", "experience": "Experience", "workspace_event": "Event"}
|
|
36
45
|
|
|
37
46
|
DEFAULT_MAX_TEXT_BYTES = 5 * 1024 * 1024 # 5 MB of extracted text per item
|
|
38
47
|
|
|
@@ -143,6 +152,10 @@ class IngestionPipeline:
|
|
|
143
152
|
}
|
|
144
153
|
|
|
145
154
|
def _run() -> Dict[str, Any]:
|
|
155
|
+
if source_type in CHAT_SOURCE_TYPES:
|
|
156
|
+
return self._ingest_chat(item, source_type=source_type, owner=owner)
|
|
157
|
+
if source_type in MEMORY_SOURCE_TYPES:
|
|
158
|
+
return self._ingest_memory_record(item, source_type=source_type, owner=owner)
|
|
146
159
|
if source_type in FILE_SOURCE_TYPES or (item.path and not item.text):
|
|
147
160
|
return self._ingest_file(item, source_type=source_type, owner=owner, captured_at=captured_at)
|
|
148
161
|
return self._ingest_text(item, source_type=source_type, owner=owner, captured_at=captured_at)
|
|
@@ -243,6 +256,40 @@ class IngestionPipeline:
|
|
|
243
256
|
metadata={"mime_type": item.mime_type, **(item.metadata or {})},
|
|
244
257
|
)
|
|
245
258
|
|
|
259
|
+
def _ingest_chat(self, item, *, source_type, owner) -> Dict[str, Any]:
|
|
260
|
+
text = item.text or ""
|
|
261
|
+
meta = item.metadata or {}
|
|
262
|
+
role = str(meta.get("role") or "user")
|
|
263
|
+
result = self._kg.ingest_message(
|
|
264
|
+
role,
|
|
265
|
+
text,
|
|
266
|
+
user_email=owner,
|
|
267
|
+
user_nickname=meta.get("user_nickname"),
|
|
268
|
+
source=meta.get("source") or source_type,
|
|
269
|
+
conversation_id=item.conversation_id,
|
|
270
|
+
raw=meta.get("raw"),
|
|
271
|
+
)
|
|
272
|
+
# ingest_message reports message/response node ids; normalize the keys
|
|
273
|
+
# the provenance step expects.
|
|
274
|
+
result.setdefault("node_id", result.get("node_id") or result.get("message_node_id") or result.get("id"))
|
|
275
|
+
result.setdefault("title", item.title or text[:80])
|
|
276
|
+
return result
|
|
277
|
+
|
|
278
|
+
def _ingest_memory_record(self, item, *, source_type, owner) -> Dict[str, Any]:
|
|
279
|
+
node_type = _MEMORY_NODE_TYPES[source_type]
|
|
280
|
+
meta = item.metadata or {}
|
|
281
|
+
result = self._kg.ingest_event(
|
|
282
|
+
node_type,
|
|
283
|
+
item.title or (item.text or node_type)[:120],
|
|
284
|
+
user_email=owner,
|
|
285
|
+
source=meta.get("source") or source_type,
|
|
286
|
+
conversation_id=item.conversation_id,
|
|
287
|
+
metadata={**meta, "detail": (item.text or "")[:2000]},
|
|
288
|
+
)
|
|
289
|
+
result.setdefault("node_id", result.get("node_id") or result.get("id"))
|
|
290
|
+
result.setdefault("title", item.title)
|
|
291
|
+
return result
|
|
292
|
+
|
|
246
293
|
def _ingest_file(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
|
|
247
294
|
if not item.path:
|
|
248
295
|
raise ValueError("File ingestion requires a path.")
|
|
@@ -44,11 +44,16 @@ def _sha256_file(path: Path) -> str:
|
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
class KGPortabilityService:
|
|
47
|
-
def __init__(self, *, knowledge_graph: Any, data_dir, enable_graph: bool = True) -> None:
|
|
47
|
+
def __init__(self, *, knowledge_graph: Any, data_dir, enable_graph: bool = True, device_identity: Any = None) -> None:
|
|
48
48
|
self._kg = knowledge_graph
|
|
49
49
|
self._data_dir = Path(data_dir)
|
|
50
50
|
self._enable = bool(enable_graph)
|
|
51
51
|
self._exports_dir = self._data_dir / "workspace_exports"
|
|
52
|
+
# v4 sovereignty: when a DeviceIdentity is wired, exports are signed
|
|
53
|
+
# and imports record origin provenance. Pre-v4 unsigned bundles stay
|
|
54
|
+
# importable locally (origin='unsigned-legacy') — signatures are
|
|
55
|
+
# mandatory only on the Brain Network peer path.
|
|
56
|
+
self._identity = device_identity
|
|
52
57
|
|
|
53
58
|
def available(self) -> bool:
|
|
54
59
|
return self._enable and self._kg is not None
|
|
@@ -60,7 +65,7 @@ class KGPortabilityService:
|
|
|
60
65
|
# ── logical export / import ──────────────────────────────────────────────
|
|
61
66
|
def export(self, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
62
67
|
self._require()
|
|
63
|
-
data = self._kg.export_graph_data()
|
|
68
|
+
data = self._kg.export_graph_data(workspace_id=workspace_id)
|
|
64
69
|
header = {
|
|
65
70
|
"format": FORMAT,
|
|
66
71
|
"format_version": FORMAT_VERSION,
|
|
@@ -69,7 +74,10 @@ class KGPortabilityService:
|
|
|
69
74
|
"workspace_id": workspace_id,
|
|
70
75
|
"counts": data.get("counts"),
|
|
71
76
|
}
|
|
72
|
-
|
|
77
|
+
artifact = {"header": header, **data}
|
|
78
|
+
if self._identity is not None:
|
|
79
|
+
artifact["signature"] = self._identity.sign_manifest(header)
|
|
80
|
+
return artifact
|
|
73
81
|
|
|
74
82
|
def export_to_file(self, path=None, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
75
83
|
artifact = self.export(workspace_id=workspace_id)
|
|
@@ -84,8 +92,30 @@ class KGPortabilityService:
|
|
|
84
92
|
raise ValueError("Invalid Knowledge Graph export artifact.")
|
|
85
93
|
if mode not in ("merge", "replace"):
|
|
86
94
|
raise ValueError("mode must be 'merge' or 'replace'.")
|
|
95
|
+
origin = "unsigned-legacy"
|
|
96
|
+
signature = artifact.get("signature")
|
|
97
|
+
if signature:
|
|
98
|
+
from latticeai.brain.identity import verify_manifest
|
|
99
|
+
|
|
100
|
+
if not verify_manifest(artifact.get("header") or {}, signature):
|
|
101
|
+
raise ValueError("Bundle signature verification failed — refusing to import.")
|
|
102
|
+
origin = f"device:{signature.get('fingerprint') or 'unknown'}"
|
|
87
103
|
result = self._kg.import_graph_data(artifact, mode=mode, dry_run=dry_run)
|
|
88
104
|
result["header"] = artifact.get("header")
|
|
105
|
+
result["origin"] = origin
|
|
106
|
+
result["signed"] = bool(signature)
|
|
107
|
+
if not dry_run:
|
|
108
|
+
try:
|
|
109
|
+
self._kg.record_provenance(
|
|
110
|
+
node_id="import:" + str((artifact.get("header") or {}).get("exported_at") or _now_iso()),
|
|
111
|
+
source_type="bundle_import",
|
|
112
|
+
pipeline="kg-portability",
|
|
113
|
+
owner=None,
|
|
114
|
+
metadata={"origin": origin, "mode": mode,
|
|
115
|
+
"counts": (artifact.get("header") or {}).get("counts")},
|
|
116
|
+
)
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
89
119
|
return result
|
|
90
120
|
|
|
91
121
|
def import_from_file(self, path, *, mode: str = "merge", dry_run: bool = False) -> Dict[str, Any]:
|
|
@@ -24,7 +24,7 @@ from __future__ import annotations
|
|
|
24
24
|
import json
|
|
25
25
|
from datetime import datetime
|
|
26
26
|
from pathlib import Path
|
|
27
|
-
from typing import Any,
|
|
27
|
+
from typing import Any, Dict, List, Optional
|
|
28
28
|
|
|
29
29
|
# Personal workspace memory kinds (from WorkspaceOS.MEMORY_KINDS).
|
|
30
30
|
WORKSPACE_KINDS = (
|
|
@@ -60,12 +60,16 @@ class MemoryService:
|
|
|
60
60
|
knowledge_graph: Any = None,
|
|
61
61
|
enable_graph: bool = True,
|
|
62
62
|
history_file: Optional[Path] = None,
|
|
63
|
+
conversation_store: Any = None,
|
|
63
64
|
):
|
|
64
65
|
self._store = store
|
|
65
66
|
self._kg = knowledge_graph
|
|
66
67
|
self._enable_graph = bool(enable_graph and knowledge_graph is not None)
|
|
67
68
|
self._data_dir = Path(data_dir)
|
|
68
69
|
self._history_file = Path(history_file) if history_file else (self._data_dir / "chat_history.json")
|
|
70
|
+
# v4: the durable SQLite conversation store supersedes the JSON file
|
|
71
|
+
# as the conversation tier's backing store when provided.
|
|
72
|
+
self._conversation_store = conversation_store
|
|
69
73
|
|
|
70
74
|
# ── helpers over the underlying stores ────────────────────────────────
|
|
71
75
|
def _workspace_memories(self, *, user_email: Optional[str], workspace_id: Optional[str]) -> List[Dict[str, Any]]:
|
|
@@ -87,6 +91,14 @@ class MemoryService:
|
|
|
87
91
|
return []
|
|
88
92
|
|
|
89
93
|
def _conversations(self) -> List[Dict[str, Any]]:
|
|
94
|
+
if self._conversation_store is not None:
|
|
95
|
+
try:
|
|
96
|
+
grouped: Dict[str, List[Dict[str, Any]]] = {}
|
|
97
|
+
for item in self._conversation_store.history():
|
|
98
|
+
grouped.setdefault(item.get("conversation_id") or "legacy-previous-history", []).append(item)
|
|
99
|
+
return [{"id": conv_id, "messages": msgs} for conv_id, msgs in grouped.items()]
|
|
100
|
+
except Exception:
|
|
101
|
+
return []
|
|
90
102
|
if not self._history_file.exists():
|
|
91
103
|
return []
|
|
92
104
|
try:
|
|
@@ -130,7 +142,10 @@ class MemoryService:
|
|
|
130
142
|
|
|
131
143
|
ws_bytes = _file_size(self._data_dir / "workspace_os.json")
|
|
132
144
|
kg_bytes = _file_size(self._data_dir / "knowledge_graph.sqlite")
|
|
133
|
-
|
|
145
|
+
if self._conversation_store is not None:
|
|
146
|
+
conv_bytes = int(getattr(self._conversation_store, "size_bytes", lambda: 0)())
|
|
147
|
+
else:
|
|
148
|
+
conv_bytes = _file_size(self._history_file)
|
|
134
149
|
|
|
135
150
|
node_total = sum((kg_stats or {}).get("nodes", {}).values()) if kg_stats else None
|
|
136
151
|
edge_total = sum((kg_stats or {}).get("edges", {}).values()) if kg_stats else None
|
|
@@ -159,7 +174,7 @@ class MemoryService:
|
|
|
159
174
|
{
|
|
160
175
|
"id": "conversation", "type": "conversation", "label": "Conversation Memory",
|
|
161
176
|
"count": len(convs), "size_bytes": conv_bytes,
|
|
162
|
-
"health": "ok" if self._history_file.exists() else "empty",
|
|
177
|
+
"health": "ok" if (self._conversation_store is not None or self._history_file.exists()) else "empty",
|
|
163
178
|
"detail": "Historical interaction memory from chat.",
|
|
164
179
|
},
|
|
165
180
|
{
|
|
@@ -202,6 +217,18 @@ class MemoryService:
|
|
|
202
217
|
limit: int = 20,
|
|
203
218
|
) -> Dict[str, Any]:
|
|
204
219
|
q = str(query or "").strip()
|
|
220
|
+
query_tokens = [tok for tok in q.lower().split() if tok]
|
|
221
|
+
|
|
222
|
+
def _lexical_score(*texts: Any) -> float:
|
|
223
|
+
# Honest, comparable relevance: fraction of query tokens present.
|
|
224
|
+
# Both tiers share this scorer so the cross-tier ranking is real,
|
|
225
|
+
# not an artifact of per-tier constants.
|
|
226
|
+
if not query_tokens:
|
|
227
|
+
return 0.0
|
|
228
|
+
haystack = " ".join(str(t or "") for t in texts).lower()
|
|
229
|
+
hits = sum(1 for tok in query_tokens if tok in haystack)
|
|
230
|
+
return round(hits / len(query_tokens), 4)
|
|
231
|
+
|
|
205
232
|
results: List[Dict[str, Any]] = []
|
|
206
233
|
|
|
207
234
|
try:
|
|
@@ -215,23 +242,24 @@ class MemoryService:
|
|
|
215
242
|
"title": (m.get("kind") or "memory"),
|
|
216
243
|
"snippet": str(m.get("content") or "")[:240],
|
|
217
244
|
"kind": m.get("kind"),
|
|
218
|
-
"score":
|
|
245
|
+
"score": _lexical_score(m.get("content"), " ".join(m.get("tags") or []), m.get("kind")),
|
|
219
246
|
"tags": m.get("tags") or [],
|
|
220
247
|
})
|
|
221
248
|
|
|
222
249
|
if self._enable_graph and q:
|
|
223
250
|
try:
|
|
224
|
-
|
|
251
|
+
# KnowledgeGraph.search returns {"query": ..., "matches": [...]}.
|
|
252
|
+
hits = self._kg.search(q, limit).get("matches", [])
|
|
225
253
|
except Exception:
|
|
226
254
|
hits = []
|
|
227
|
-
for
|
|
255
|
+
for hit in hits[:limit]:
|
|
228
256
|
results.append({
|
|
229
257
|
"source": "graph",
|
|
230
|
-
"id":
|
|
231
|
-
"title":
|
|
232
|
-
"snippet": str(
|
|
233
|
-
"kind":
|
|
234
|
-
"score":
|
|
258
|
+
"id": hit.get("id") or hit.get("node_id"),
|
|
259
|
+
"title": hit.get("title") or hit.get("name") or "node",
|
|
260
|
+
"snippet": str(hit.get("summary") or hit.get("content") or "")[:240],
|
|
261
|
+
"kind": hit.get("type") or "node",
|
|
262
|
+
"score": _lexical_score(hit.get("title"), hit.get("name"), hit.get("summary"), hit.get("content")),
|
|
235
263
|
})
|
|
236
264
|
|
|
237
265
|
results.sort(key=lambda r: r.get("score", 0), reverse=True)
|