ltcai 2.2.7 → 3.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 +63 -32
- package/docs/CHANGELOG.md +82 -0
- package/docs/V3_BACKEND_ARCHITECTURE.md +138 -0
- package/docs/V3_FRONTEND.md +136 -0
- package/knowledge_graph.py +649 -21
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +47 -0
- package/latticeai/api/agents.py +54 -31
- package/latticeai/api/auth.py +1 -1
- package/latticeai/api/chat.py +10 -2
- package/latticeai/api/search.py +236 -0
- package/latticeai/api/static_routes.py +11 -2
- package/latticeai/core/config.py +16 -0
- package/latticeai/core/embedding_providers.py +502 -0
- package/latticeai/core/local_embeddings.py +86 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +49 -1
- package/latticeai/services/agent_runtime.py +245 -0
- package/latticeai/services/search_service.py +346 -0
- package/package.json +6 -4
- package/static/account.html +9 -9
- package/static/activity.html +4 -4
- package/static/admin.html +8 -8
- package/static/agents.html +4 -4
- package/static/chat.html +10 -10
- package/static/css/reference/account.css +137 -1
- package/static/css/reference/chat.css +31 -37
- package/static/css/responsive.css +42 -0
- package/static/css/tokens.css +125 -130
- package/static/graph.html +9 -9
- package/static/manifest.json +3 -3
- package/static/plugins.html +4 -4
- package/static/scripts/account.js +4 -4
- package/static/scripts/chat.js +40 -8
- package/static/scripts/workspace.js +78 -0
- package/static/v3/css/lattice.base.css +128 -0
- package/static/v3/css/lattice.components.css +447 -0
- package/static/v3/css/lattice.shell.css +407 -0
- package/static/v3/css/lattice.tokens.css +132 -0
- package/static/v3/css/lattice.views.css +277 -0
- package/static/v3/index.html +40 -0
- package/static/v3/js/app.js +26 -0
- package/static/v3/js/core/api.js +327 -0
- package/static/v3/js/core/components.js +215 -0
- package/static/v3/js/core/dom.js +148 -0
- package/static/v3/js/core/fixtures.js +171 -0
- package/static/v3/js/core/router.js +37 -0
- package/static/v3/js/core/routes.js +73 -0
- package/static/v3/js/core/shell.js +363 -0
- package/static/v3/js/core/store.js +113 -0
- package/static/v3/js/views/admin-audit.js +185 -0
- package/static/v3/js/views/admin-permissions.js +178 -0
- package/static/v3/js/views/admin-policies.js +103 -0
- package/static/v3/js/views/admin-private-vpc.js +138 -0
- package/static/v3/js/views/admin-security.js +181 -0
- package/static/v3/js/views/admin-users.js +168 -0
- package/static/v3/js/views/agents.js +194 -0
- package/static/v3/js/views/chat.js +450 -0
- package/static/v3/js/views/files.js +180 -0
- package/static/v3/js/views/home.js +119 -0
- package/static/v3/js/views/hybrid-search.js +195 -0
- package/static/v3/js/views/knowledge-graph.js +238 -0
- package/static/v3/js/views/models.js +247 -0
- package/static/v3/js/views/my-computer.js +237 -0
- package/static/v3/js/views/pipeline.js +161 -0
- package/static/v3/js/views/settings.js +258 -0
- package/static/workflows.html +4 -4
- package/static/workspace.css +340 -2
- package/static/workspace.html +43 -24
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""AgentRuntime — the single boundary for agent execution and observability.
|
|
2
|
+
|
|
3
|
+
Before this module the agent concern was spread across three places: the
|
|
4
|
+
:class:`~latticeai.core.multi_agent.MultiAgentOrchestrator` (role pipeline),
|
|
5
|
+
the :class:`~latticeai.services.platform_runtime.PlatformRuntime` (cross-system
|
|
6
|
+
wiring + an ad-hoc ``run_agent``), and ``api/agents.py`` (HTTP transport that
|
|
7
|
+
also owned orchestration + persistence + audit). The frontend reached past all
|
|
8
|
+
of them into the workspace store via ``/workspace/agents``.
|
|
9
|
+
|
|
10
|
+
``AgentRuntime`` collapses that into one façade with a small, stable surface:
|
|
11
|
+
|
|
12
|
+
* **configuration** — :meth:`config`, :meth:`roles`
|
|
13
|
+
* **status / health** — :meth:`status`, :meth:`health`
|
|
14
|
+
* **execution** — :meth:`start`, :meth:`stop`
|
|
15
|
+
* **events / state** — :meth:`list_runs`, :meth:`get_run`, :meth:`events`, :meth:`replay`
|
|
16
|
+
|
|
17
|
+
It *wraps* the existing orchestrator and run store rather than reimplementing
|
|
18
|
+
them — execution semantics are unchanged, but every caller (HTTP router and, via
|
|
19
|
+
it, the frontend) now depends on this boundary instead of internal paths.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
from latticeai.core.multi_agent import (
|
|
27
|
+
AGENT_ROLES,
|
|
28
|
+
CORE_PIPELINE,
|
|
29
|
+
MULTI_AGENT_VERSION,
|
|
30
|
+
ROLE_AGENT_IDS,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
ROLE_DESCRIPTIONS = {
|
|
34
|
+
"researcher": "Gathers workspace context and memory for the goal.",
|
|
35
|
+
"planner": "Decomposes the goal into an ordered, bounded plan.",
|
|
36
|
+
"executor": "Executes each planned step, invoking tools and workflows.",
|
|
37
|
+
"reviewer": "Reviews the executed work and approves, rejects, or retries.",
|
|
38
|
+
"release": "Finalizes and summarizes the approved outcome.",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Run statuses the orchestrator can emit that mean "still working". The default
|
|
42
|
+
# orchestrator runs synchronously, so persisted runs are always terminal; this
|
|
43
|
+
# 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"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AgentRuntime:
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
store: Any,
|
|
53
|
+
orchestrator_factory: Callable[[Optional[str], Optional[str]], Any],
|
|
54
|
+
workspace_graph: Callable[[], Any],
|
|
55
|
+
append_audit_event: Callable[..., None],
|
|
56
|
+
max_retries_cap: int = 5,
|
|
57
|
+
):
|
|
58
|
+
self._store = store
|
|
59
|
+
self._orchestrator_factory = orchestrator_factory
|
|
60
|
+
self._workspace_graph = workspace_graph
|
|
61
|
+
self._append_audit_event = append_audit_event
|
|
62
|
+
self._max_retries_cap = int(max_retries_cap)
|
|
63
|
+
|
|
64
|
+
# ── configuration ─────────────────────────────────────────────────────
|
|
65
|
+
def config(self) -> Dict[str, Any]:
|
|
66
|
+
return {
|
|
67
|
+
"version": MULTI_AGENT_VERSION,
|
|
68
|
+
"roles": list(AGENT_ROLES),
|
|
69
|
+
"default_pipeline": list(CORE_PIPELINE),
|
|
70
|
+
"max_retries_cap": self._max_retries_cap,
|
|
71
|
+
"execution_mode": "synchronous",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def roles(self) -> List[Dict[str, Any]]:
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
"role": role,
|
|
78
|
+
"agent_id": ROLE_AGENT_IDS.get(role, f"agent:{role}"),
|
|
79
|
+
"description": ROLE_DESCRIPTIONS.get(role, ""),
|
|
80
|
+
"terminal": role not in {"researcher", "planner", "executor", "reviewer"},
|
|
81
|
+
}
|
|
82
|
+
for role in AGENT_ROLES
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
# ── health ────────────────────────────────────────────────────────────
|
|
86
|
+
def health(self) -> Dict[str, Any]:
|
|
87
|
+
checks: Dict[str, Any] = {}
|
|
88
|
+
ok = True
|
|
89
|
+
try:
|
|
90
|
+
self._store.list_agents(workspace_id=None)
|
|
91
|
+
checks["run_store"] = {"status": "ok"}
|
|
92
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
93
|
+
ok = False
|
|
94
|
+
checks["run_store"] = {"status": "error", "detail": str(exc)}
|
|
95
|
+
try:
|
|
96
|
+
self._orchestrator_factory(None, None)
|
|
97
|
+
checks["orchestrator"] = {"status": "ok"}
|
|
98
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
99
|
+
ok = False
|
|
100
|
+
checks["orchestrator"] = {"status": "error", "detail": str(exc)}
|
|
101
|
+
return {"status": "ok" if ok else "degraded", "checks": checks}
|
|
102
|
+
|
|
103
|
+
# ── roster + status ───────────────────────────────────────────────────
|
|
104
|
+
def _roster(self, runs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
105
|
+
"""Canonical role roster enriched with real run statistics."""
|
|
106
|
+
by_agent: Dict[str, Dict[str, Any]] = {}
|
|
107
|
+
for run in runs:
|
|
108
|
+
aid = str(run.get("agent_id") or "")
|
|
109
|
+
entry = by_agent.setdefault(aid, {"runs": 0, "last_status": None, "last_at": None})
|
|
110
|
+
entry["runs"] += 1
|
|
111
|
+
if entry["last_at"] is None: # runs are newest-first
|
|
112
|
+
entry["last_status"] = run.get("status")
|
|
113
|
+
entry["last_at"] = run.get("created_at") or run.get("completed_at")
|
|
114
|
+
|
|
115
|
+
roster: List[Dict[str, Any]] = []
|
|
116
|
+
order = list(CORE_PIPELINE) # planner, executor, reviewer first
|
|
117
|
+
ordered_roles = order + [r for r in AGENT_ROLES if r not in order]
|
|
118
|
+
for role in ordered_roles:
|
|
119
|
+
agent_id = ROLE_AGENT_IDS.get(role, f"agent:{role}")
|
|
120
|
+
stats = by_agent.get(agent_id, {"runs": 0, "last_status": None, "last_at": None})
|
|
121
|
+
handoffs = []
|
|
122
|
+
if role == "planner":
|
|
123
|
+
handoffs = [ROLE_AGENT_IDS["executor"]]
|
|
124
|
+
elif role == "executor":
|
|
125
|
+
handoffs = [ROLE_AGENT_IDS["reviewer"]]
|
|
126
|
+
roster.append({
|
|
127
|
+
"id": agent_id,
|
|
128
|
+
"name": role.capitalize(),
|
|
129
|
+
"role": ROLE_DESCRIPTIONS.get(role, ""),
|
|
130
|
+
"state": "available" if role != "release" else "idle",
|
|
131
|
+
"runs": stats["runs"],
|
|
132
|
+
"last_status": stats["last_status"],
|
|
133
|
+
"last_at": stats["last_at"],
|
|
134
|
+
"handoffs": handoffs,
|
|
135
|
+
})
|
|
136
|
+
return roster
|
|
137
|
+
|
|
138
|
+
def status(self, *, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
139
|
+
try:
|
|
140
|
+
listing = self._store.list_agents(workspace_id=scope)
|
|
141
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
142
|
+
listing = {"agents": [], "runs": [], "error": str(exc)}
|
|
143
|
+
runs = list(listing.get("runs") or [])
|
|
144
|
+
active = sum(1 for r in runs if str(r.get("status")) in _ACTIVE_STATUSES)
|
|
145
|
+
return {
|
|
146
|
+
"runtime": {
|
|
147
|
+
"ready": True,
|
|
148
|
+
"version": MULTI_AGENT_VERSION,
|
|
149
|
+
"execution_mode": "synchronous",
|
|
150
|
+
"default_pipeline": list(CORE_PIPELINE),
|
|
151
|
+
"total_runs": len(runs),
|
|
152
|
+
"active_runs": active,
|
|
153
|
+
},
|
|
154
|
+
"health": self.health(),
|
|
155
|
+
"roles": self.roles(),
|
|
156
|
+
"agents": self._roster(runs),
|
|
157
|
+
"runs": runs[:25],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
# ── events / state ────────────────────────────────────────────────────
|
|
161
|
+
def list_runs(self, *, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
162
|
+
return self._store.list_agents(workspace_id=scope)
|
|
163
|
+
|
|
164
|
+
def get_run(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
165
|
+
return {"run": self._store.get_agent_run(run_id, workspace_id=scope)}
|
|
166
|
+
|
|
167
|
+
def replay(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
168
|
+
return {"replay": self._store.replay_agent_run(run_id, workspace_id=scope)}
|
|
169
|
+
|
|
170
|
+
def events(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
171
|
+
run = self._store.get_agent_run(run_id, workspace_id=scope)
|
|
172
|
+
status = str(run.get("status") or "")
|
|
173
|
+
return {
|
|
174
|
+
"run_id": run_id,
|
|
175
|
+
"status": status,
|
|
176
|
+
"is_final": status in _TERMINAL_STATUSES or status not in _ACTIVE_STATUSES,
|
|
177
|
+
"current_role": run.get("current_role"),
|
|
178
|
+
"timeline": run.get("timeline") or [],
|
|
179
|
+
"handoffs": run.get("handoffs") or [],
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# ── execution ─────────────────────────────────────────────────────────
|
|
183
|
+
def start(
|
|
184
|
+
self,
|
|
185
|
+
goal: str,
|
|
186
|
+
*,
|
|
187
|
+
user_email: Optional[str],
|
|
188
|
+
scope: Optional[str],
|
|
189
|
+
roles: Optional[List[str]] = None,
|
|
190
|
+
inputs: Optional[Dict[str, Any]] = None,
|
|
191
|
+
max_retries: int = 2,
|
|
192
|
+
) -> Dict[str, Any]:
|
|
193
|
+
if not str(goal or "").strip():
|
|
194
|
+
raise ValueError("goal is required")
|
|
195
|
+
orchestrator = self._orchestrator_factory(user_email or None, scope)
|
|
196
|
+
result = orchestrator.run(
|
|
197
|
+
goal,
|
|
198
|
+
user_email=user_email or None,
|
|
199
|
+
workspace_id=scope,
|
|
200
|
+
inputs=inputs or {},
|
|
201
|
+
roles=roles or None,
|
|
202
|
+
max_retries=max(0, min(int(max_retries or 0), self._max_retries_cap)),
|
|
203
|
+
)
|
|
204
|
+
run = self._store.record_agent_run(
|
|
205
|
+
agent_id=result.agent_id,
|
|
206
|
+
status=result.status,
|
|
207
|
+
input_text=goal,
|
|
208
|
+
output_text=result.output,
|
|
209
|
+
timeline=result.timeline,
|
|
210
|
+
relationships=[ROLE_AGENT_IDS.get(r, f"agent:{r}") for r in result.roles_run],
|
|
211
|
+
handoffs=result.handoffs,
|
|
212
|
+
context_packets=result.context_packets,
|
|
213
|
+
plan=result.plan,
|
|
214
|
+
plan_review=result.plan_review,
|
|
215
|
+
review_history=result.review_history,
|
|
216
|
+
retry_history=result.retry_history,
|
|
217
|
+
memory_snapshots=result.memory_snapshots,
|
|
218
|
+
user_email=user_email or None,
|
|
219
|
+
graph=self._workspace_graph(),
|
|
220
|
+
workspace_id=scope,
|
|
221
|
+
)
|
|
222
|
+
self._append_audit_event(
|
|
223
|
+
"multi_agent_run",
|
|
224
|
+
user_email=user_email,
|
|
225
|
+
agent_id=result.agent_id,
|
|
226
|
+
status=result.status,
|
|
227
|
+
retries=result.retries,
|
|
228
|
+
)
|
|
229
|
+
return {"run": run, "result": result.as_dict()}
|
|
230
|
+
|
|
231
|
+
def stop(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
232
|
+
"""Best-effort stop.
|
|
233
|
+
|
|
234
|
+
The default runtime executes synchronously, so by the time a run id
|
|
235
|
+
exists the run has already completed. Report that honestly rather than
|
|
236
|
+
pretending a cancellation occurred.
|
|
237
|
+
"""
|
|
238
|
+
try:
|
|
239
|
+
run = self._store.get_agent_run(run_id, workspace_id=scope)
|
|
240
|
+
except FileNotFoundError:
|
|
241
|
+
return {"stopped": False, "reason": "run not found", "run_id": run_id}
|
|
242
|
+
status = str(run.get("status") or "")
|
|
243
|
+
if status in _ACTIVE_STATUSES:
|
|
244
|
+
return {"stopped": False, "reason": "asynchronous cancellation is not supported by the synchronous runtime", "run_id": run_id, "status": status}
|
|
245
|
+
return {"stopped": False, "reason": "run already finished", "run_id": run_id, "status": status}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Backend search orchestration for Lattice AI v3.
|
|
2
|
+
|
|
3
|
+
The service composes the existing knowledge graph, the local vector index, and
|
|
4
|
+
keyword search into UI-ready contracts without tying routers to store internals.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DEFAULT_HYBRID_WEIGHTS = {
|
|
14
|
+
"keyword": 0.35,
|
|
15
|
+
"vector": 0.40,
|
|
16
|
+
"graph": 0.25,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _clean(text: Any, limit: int = 1000) -> str:
|
|
21
|
+
return " ".join(str(text or "").split())[:limit]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _result_key(result: Mapping[str, Any]) -> str:
|
|
25
|
+
return str(result.get("id") or result.get("node_id") or "")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class SearchService:
|
|
30
|
+
graph_store: Any
|
|
31
|
+
|
|
32
|
+
def _require_graph(self) -> Any:
|
|
33
|
+
if self.graph_store is None:
|
|
34
|
+
raise ValueError("knowledge graph is disabled")
|
|
35
|
+
return self.graph_store
|
|
36
|
+
|
|
37
|
+
def keyword_search(self, query: str, *, limit: int = 30) -> Dict[str, Any]:
|
|
38
|
+
graph = self._require_graph()
|
|
39
|
+
payload = graph.search(query, limit)
|
|
40
|
+
matches = []
|
|
41
|
+
for rank, match in enumerate(payload.get("matches", []), start=1):
|
|
42
|
+
matches.append({
|
|
43
|
+
"id": match["id"],
|
|
44
|
+
"node_id": match["id"],
|
|
45
|
+
"item_type": "node",
|
|
46
|
+
"type": match.get("type"),
|
|
47
|
+
"title": match.get("title"),
|
|
48
|
+
"summary": _clean(match.get("summary")),
|
|
49
|
+
"score": round(1.0 / rank, 6),
|
|
50
|
+
"rank": rank,
|
|
51
|
+
"sources": ["keyword"],
|
|
52
|
+
"source_scores": {"keyword": round(1.0 / rank, 6)},
|
|
53
|
+
"metadata": match.get("metadata") or {},
|
|
54
|
+
"updated_at": match.get("updated_at"),
|
|
55
|
+
})
|
|
56
|
+
return {"query": query, "mode": "keyword", "matches": matches}
|
|
57
|
+
|
|
58
|
+
def vector_search(self, query: str, *, limit: int = 30, min_score: float = 0.0) -> Dict[str, Any]:
|
|
59
|
+
graph = self._require_graph()
|
|
60
|
+
payload = graph.vector_search(query, limit=limit, min_score=min_score)
|
|
61
|
+
matches = []
|
|
62
|
+
for rank, match in enumerate(payload.get("matches", []), start=1):
|
|
63
|
+
score = float(match.get("score") or 0.0)
|
|
64
|
+
matches.append({
|
|
65
|
+
"id": match.get("id"),
|
|
66
|
+
"node_id": match.get("node_id"),
|
|
67
|
+
"item_type": match.get("item_type"),
|
|
68
|
+
"type": match.get("type"),
|
|
69
|
+
"title": match.get("title"),
|
|
70
|
+
"summary": _clean(match.get("summary")),
|
|
71
|
+
"score": round(score, 6),
|
|
72
|
+
"rank": rank,
|
|
73
|
+
"sources": ["vector"],
|
|
74
|
+
"source_scores": {"vector": round(score, 6)},
|
|
75
|
+
"metadata": match.get("metadata") or {},
|
|
76
|
+
"updated_at": match.get("updated_at"),
|
|
77
|
+
})
|
|
78
|
+
return {
|
|
79
|
+
"query": query,
|
|
80
|
+
"mode": "vector",
|
|
81
|
+
"embedding_model": payload.get("embedding_model"),
|
|
82
|
+
"embedding_dim": payload.get("embedding_dim"),
|
|
83
|
+
"matches": matches,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
def graph_search(self, query: str, *, limit: int = 30, expand_depth: int = 1) -> Dict[str, Any]:
|
|
87
|
+
graph = self._require_graph()
|
|
88
|
+
limit = max(1, min(int(limit or 30), 100))
|
|
89
|
+
expand_depth = max(0, min(int(expand_depth or 1), 3))
|
|
90
|
+
direct = graph.search(query, limit=max(limit, 10)).get("matches", [])
|
|
91
|
+
relationships = graph.relationship_search(query=query, limit=limit).get("relationships", [])
|
|
92
|
+
by_id: Dict[str, Dict[str, Any]] = {}
|
|
93
|
+
|
|
94
|
+
def add_node(node: Mapping[str, Any], score: float, reason: str, edge: Optional[Mapping[str, Any]] = None) -> None:
|
|
95
|
+
node_id = str(node.get("id") or "")
|
|
96
|
+
if not node_id:
|
|
97
|
+
return
|
|
98
|
+
current = by_id.get(node_id)
|
|
99
|
+
if not current:
|
|
100
|
+
current = {
|
|
101
|
+
"id": node_id,
|
|
102
|
+
"node_id": node_id,
|
|
103
|
+
"item_type": "node",
|
|
104
|
+
"type": node.get("type"),
|
|
105
|
+
"title": node.get("title"),
|
|
106
|
+
"summary": _clean(node.get("summary")),
|
|
107
|
+
"score": 0.0,
|
|
108
|
+
"sources": ["graph"],
|
|
109
|
+
"source_scores": {"graph": 0.0},
|
|
110
|
+
"metadata": node.get("metadata") or {},
|
|
111
|
+
"updated_at": node.get("updated_at"),
|
|
112
|
+
"graph_context": [],
|
|
113
|
+
}
|
|
114
|
+
by_id[node_id] = current
|
|
115
|
+
current["score"] = max(float(current["score"]), score)
|
|
116
|
+
current["source_scores"]["graph"] = max(float(current["source_scores"]["graph"]), score)
|
|
117
|
+
context = {"reason": reason}
|
|
118
|
+
if edge:
|
|
119
|
+
context["relationship"] = {
|
|
120
|
+
"id": edge.get("id"),
|
|
121
|
+
"type": edge.get("type"),
|
|
122
|
+
"weight": edge.get("weight"),
|
|
123
|
+
"from": edge.get("from") or (edge.get("source") or {}).get("id"),
|
|
124
|
+
"to": edge.get("to") or (edge.get("target") or {}).get("id"),
|
|
125
|
+
}
|
|
126
|
+
current["graph_context"].append(context)
|
|
127
|
+
|
|
128
|
+
for rank, match in enumerate(direct, start=1):
|
|
129
|
+
add_node(match, 1.0 / rank, "direct_match")
|
|
130
|
+
if expand_depth <= 0:
|
|
131
|
+
continue
|
|
132
|
+
try:
|
|
133
|
+
neighborhood = graph.traverse(match["id"], depth=expand_depth, limit=limit * 3)
|
|
134
|
+
except Exception:
|
|
135
|
+
neighborhood = {"nodes": [], "edges": []}
|
|
136
|
+
edge_by_pair = {
|
|
137
|
+
(edge.get("from"), edge.get("to")): edge
|
|
138
|
+
for edge in neighborhood.get("edges", [])
|
|
139
|
+
}
|
|
140
|
+
for node in neighborhood.get("nodes", []):
|
|
141
|
+
if node.get("id") == match.get("id"):
|
|
142
|
+
continue
|
|
143
|
+
related_edge = None
|
|
144
|
+
for pair, edge in edge_by_pair.items():
|
|
145
|
+
if match.get("id") in pair and node.get("id") in pair:
|
|
146
|
+
related_edge = edge
|
|
147
|
+
break
|
|
148
|
+
add_node(node, 0.45 / rank, "neighbor_expansion", related_edge)
|
|
149
|
+
|
|
150
|
+
for rank, rel in enumerate(relationships, start=1):
|
|
151
|
+
rel_score = 0.75 / rank
|
|
152
|
+
add_node(rel.get("source") or {}, rel_score, "relationship_match", rel)
|
|
153
|
+
add_node(rel.get("target") or {}, rel_score, "relationship_match", rel)
|
|
154
|
+
|
|
155
|
+
matches = sorted(by_id.values(), key=lambda item: item["score"], reverse=True)[:limit]
|
|
156
|
+
for rank, match in enumerate(matches, start=1):
|
|
157
|
+
match["rank"] = rank
|
|
158
|
+
match["score"] = round(float(match["score"]), 6)
|
|
159
|
+
match["source_scores"]["graph"] = round(float(match["source_scores"]["graph"]), 6)
|
|
160
|
+
return {"query": query, "mode": "graph", "expand_depth": expand_depth, "matches": matches}
|
|
161
|
+
|
|
162
|
+
def hybrid_search(
|
|
163
|
+
self,
|
|
164
|
+
query: str,
|
|
165
|
+
*,
|
|
166
|
+
limit: int = 30,
|
|
167
|
+
keyword_limit: int = 30,
|
|
168
|
+
vector_limit: int = 30,
|
|
169
|
+
graph_limit: int = 30,
|
|
170
|
+
weights: Optional[Mapping[str, float]] = None,
|
|
171
|
+
) -> Dict[str, Any]:
|
|
172
|
+
weights = {**DEFAULT_HYBRID_WEIGHTS, **dict(weights or {})}
|
|
173
|
+
channels = {
|
|
174
|
+
"keyword": self.keyword_search(query, limit=keyword_limit),
|
|
175
|
+
"vector": self.vector_search(query, limit=vector_limit),
|
|
176
|
+
"graph": self.graph_search(query, limit=graph_limit),
|
|
177
|
+
}
|
|
178
|
+
fused: Dict[str, Dict[str, Any]] = {}
|
|
179
|
+
for source, payload in channels.items():
|
|
180
|
+
source_weight = float(weights.get(source, 0.0))
|
|
181
|
+
for rank, result in enumerate(payload.get("matches", []), start=1):
|
|
182
|
+
key = _result_key(result)
|
|
183
|
+
if not key:
|
|
184
|
+
continue
|
|
185
|
+
source_score = float((result.get("source_scores") or {}).get(source, result.get("score") or 0.0))
|
|
186
|
+
rank_score = 1.0 / rank
|
|
187
|
+
contribution = source_weight * max(source_score, rank_score)
|
|
188
|
+
current = fused.get(key)
|
|
189
|
+
if not current:
|
|
190
|
+
current = {
|
|
191
|
+
**result,
|
|
192
|
+
"sources": [],
|
|
193
|
+
"source_scores": {},
|
|
194
|
+
"score": 0.0,
|
|
195
|
+
}
|
|
196
|
+
fused[key] = current
|
|
197
|
+
current["score"] = float(current["score"]) + contribution
|
|
198
|
+
if source not in current["sources"]:
|
|
199
|
+
current["sources"].append(source)
|
|
200
|
+
current["source_scores"][source] = round(source_score, 6)
|
|
201
|
+
if result.get("graph_context"):
|
|
202
|
+
current.setdefault("graph_context", [])
|
|
203
|
+
current["graph_context"].extend(result.get("graph_context") or [])
|
|
204
|
+
|
|
205
|
+
matches = sorted(fused.values(), key=lambda item: item["score"], reverse=True)[: max(1, min(limit, 100))]
|
|
206
|
+
for rank, match in enumerate(matches, start=1):
|
|
207
|
+
match["rank"] = rank
|
|
208
|
+
match["score"] = round(float(match["score"]), 6)
|
|
209
|
+
match["fusion"] = {
|
|
210
|
+
"weights": weights,
|
|
211
|
+
"sources": match.get("sources", []),
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
"query": query,
|
|
215
|
+
"mode": "hybrid",
|
|
216
|
+
"weights": weights,
|
|
217
|
+
"channels": {
|
|
218
|
+
name: {
|
|
219
|
+
key: value
|
|
220
|
+
for key, value in payload.items()
|
|
221
|
+
if key not in {"matches"}
|
|
222
|
+
}
|
|
223
|
+
for name, payload in channels.items()
|
|
224
|
+
},
|
|
225
|
+
"matches": matches,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
def graph(self, *, limit: int = 300) -> Dict[str, Any]:
|
|
229
|
+
return self._require_graph().graph(limit=limit)
|
|
230
|
+
|
|
231
|
+
def node(self, node_id: str, *, include_neighbors: bool = True, depth: int = 1, limit: int = 100) -> Dict[str, Any]:
|
|
232
|
+
graph = self._require_graph()
|
|
233
|
+
payload = {"node": graph.get_node(node_id)}
|
|
234
|
+
if include_neighbors:
|
|
235
|
+
payload["neighborhood"] = graph.traverse(node_id, depth=depth, limit=limit)
|
|
236
|
+
return payload
|
|
237
|
+
|
|
238
|
+
def relationships(
|
|
239
|
+
self,
|
|
240
|
+
*,
|
|
241
|
+
query: str = "",
|
|
242
|
+
node_id: str = "",
|
|
243
|
+
relationship_type: str = "",
|
|
244
|
+
limit: int = 30,
|
|
245
|
+
) -> Dict[str, Any]:
|
|
246
|
+
return self._require_graph().relationship_search(
|
|
247
|
+
query=query,
|
|
248
|
+
node_id=node_id,
|
|
249
|
+
relationship_type=relationship_type,
|
|
250
|
+
limit=limit,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def index_status(self) -> Dict[str, Any]:
|
|
254
|
+
return self._require_graph().index_status()
|
|
255
|
+
|
|
256
|
+
def embeddings_status(
|
|
257
|
+
self,
|
|
258
|
+
*,
|
|
259
|
+
resolved: Optional[Mapping[str, Any]] = None,
|
|
260
|
+
refresh: bool = False,
|
|
261
|
+
) -> Dict[str, Any]:
|
|
262
|
+
"""Report the active embedding provider for the Models → Embeddings UI.
|
|
263
|
+
|
|
264
|
+
Combines the resolved-provider info (requested vs active, fallback,
|
|
265
|
+
health) with the vector index's identity and last build time. The
|
|
266
|
+
``state`` is one of ``production`` | ``fallback`` | ``unavailable`` so
|
|
267
|
+
the UI never shows a down provider as live.
|
|
268
|
+
"""
|
|
269
|
+
resolved = dict(resolved or {})
|
|
270
|
+
graph = self.graph_store
|
|
271
|
+
embedder = getattr(graph, "_embedding_model", None)
|
|
272
|
+
|
|
273
|
+
meta: Dict[str, Any] = {}
|
|
274
|
+
if embedder is not None and hasattr(embedder, "metadata"):
|
|
275
|
+
try:
|
|
276
|
+
meta = dict(embedder.metadata())
|
|
277
|
+
except Exception:
|
|
278
|
+
meta = {}
|
|
279
|
+
else: # legacy LocalEmbeddingModel
|
|
280
|
+
meta = {
|
|
281
|
+
"provider": "hash",
|
|
282
|
+
"model": getattr(embedder, "model_id", "lattice-local-hash-v1"),
|
|
283
|
+
"model_id": getattr(embedder, "model_id", "lattice-local-hash-v1"),
|
|
284
|
+
"dim": getattr(embedder, "dim", 384),
|
|
285
|
+
"grade": "fallback",
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
health = resolved.get("health") or {"status": "unknown", "detail": ""}
|
|
289
|
+
if refresh and embedder is not None and hasattr(embedder, "health"):
|
|
290
|
+
try:
|
|
291
|
+
health = embedder.health()
|
|
292
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
293
|
+
health = {"status": "unavailable", "detail": str(exc)}
|
|
294
|
+
|
|
295
|
+
fell_back = bool(resolved.get("fell_back"))
|
|
296
|
+
grade = str(meta.get("grade") or ("fallback" if fell_back else "production"))
|
|
297
|
+
if fell_back or health.get("status") == "unavailable":
|
|
298
|
+
state = "unavailable" if fell_back else "fallback"
|
|
299
|
+
else:
|
|
300
|
+
state = "fallback" if grade == "fallback" else "production"
|
|
301
|
+
|
|
302
|
+
index: Dict[str, Any] = {}
|
|
303
|
+
last_indexed_at = None
|
|
304
|
+
if graph is not None:
|
|
305
|
+
try:
|
|
306
|
+
status = graph.index_status()
|
|
307
|
+
index = {
|
|
308
|
+
"status": status.get("status"),
|
|
309
|
+
"source_items": status.get("source_items"),
|
|
310
|
+
"indexed_items": status.get("indexed_items"),
|
|
311
|
+
"ready_items": status.get("ready_items"),
|
|
312
|
+
"pending_items": status.get("pending_items"),
|
|
313
|
+
"stale_items": status.get("stale_items"),
|
|
314
|
+
"embedding_model": (status.get("storage") or {}).get("embedding_model"),
|
|
315
|
+
"embedding_dim": (status.get("storage") or {}).get("embedding_dim"),
|
|
316
|
+
}
|
|
317
|
+
for op in status.get("operations", []):
|
|
318
|
+
if op.get("status") == "completed" and op.get("completed_at"):
|
|
319
|
+
last_indexed_at = op.get("completed_at")
|
|
320
|
+
break
|
|
321
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
322
|
+
index = {"error": str(exc)}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
"provider": meta.get("provider"),
|
|
326
|
+
"requested_provider": resolved.get("requested_provider") or meta.get("provider"),
|
|
327
|
+
"active_provider": resolved.get("active_provider") or meta.get("provider"),
|
|
328
|
+
"model": meta.get("model"),
|
|
329
|
+
"model_id": meta.get("model_id"),
|
|
330
|
+
"dimensions": meta.get("dim"),
|
|
331
|
+
"grade": grade,
|
|
332
|
+
"state": state,
|
|
333
|
+
"fell_back": fell_back,
|
|
334
|
+
"health": health,
|
|
335
|
+
"detail": resolved.get("detail", ""),
|
|
336
|
+
"last_indexed_at": last_indexed_at,
|
|
337
|
+
"index": index,
|
|
338
|
+
"available_providers": list(resolved.get("available_providers") or []),
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
def rebuild_index(self, *, full: bool = False, include_nodes: bool = True, include_chunks: bool = True) -> Dict[str, Any]:
|
|
342
|
+
return self._require_graph().rebuild_vector_index(
|
|
343
|
+
full=full,
|
|
344
|
+
include_nodes=include_nodes,
|
|
345
|
+
include_chunks=include_chunks,
|
|
346
|
+
)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ltcai",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
|
+
"description": "Lattice AI v3 local-first AI workspace platform with knowledge graph, vector index, hybrid search, agents, and workspace modes.",
|
|
5
5
|
"homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
"dev": "python3 ltcai_cli.py --reload",
|
|
20
20
|
"build": "npm run build:python",
|
|
21
21
|
"build:python": "python3 -m build",
|
|
22
|
-
"check:python": "python3 -m py_compile ltcai_cli.py server.py latticeai/server_app.py latticeai/api/chat.py latticeai/api/computer_use.py latticeai/api/deps.py latticeai/api/garden.py latticeai/api/local_files.py latticeai/api/permissions.py latticeai/api/setup.py latticeai/api/static_routes.py latticeai/api/tools.py latticeai/api/plugins.py latticeai/api/workflow_designer.py latticeai/api/agents.py latticeai/api/realtime.py latticeai/api/marketplace.py latticeai/services/app_context.py latticeai/services/model_runtime.py latticeai/services/model_catalog.py latticeai/services/model_recommendation.py latticeai/services/tool_dispatch.py latticeai/services/upload_service.py latticeai/core/tool_registry.py latticeai/core/enterprise.py latticeai/core/enterprise_admin.py latticeai/core/agent_prompts.py latticeai/core/workspace_os.py latticeai/core/plugins.py latticeai/core/marketplace.py latticeai/core/workflow_engine.py latticeai/core/multi_agent.py latticeai/core/realtime.py knowledge_graph.py knowledge_graph_api.py local_knowledge_api.py llm_router.py p_reinforce.py telegram_bot.py tools.py codex_telegram_bot.py",
|
|
23
|
-
"lint": "node --check static/scripts/account.js && node --check static/scripts/admin.js && node --check static/scripts/chat.js && node --check static/scripts/graph.js && node --check static/scripts/platform.js && node --check static/scripts/ux.js && node --check static/scripts/workspace.js && node --check tests/visual/mock_server.cjs",
|
|
22
|
+
"check:python": "python3 -m py_compile ltcai_cli.py server.py latticeai/server_app.py latticeai/api/chat.py latticeai/api/computer_use.py latticeai/api/deps.py latticeai/api/garden.py latticeai/api/local_files.py latticeai/api/permissions.py latticeai/api/setup.py latticeai/api/static_routes.py latticeai/api/tools.py latticeai/api/plugins.py latticeai/api/workflow_designer.py latticeai/api/agents.py latticeai/api/realtime.py latticeai/api/marketplace.py latticeai/api/search.py latticeai/services/search_service.py latticeai/core/local_embeddings.py latticeai/core/embedding_providers.py latticeai/services/agent_runtime.py latticeai/core/config.py latticeai/api/admin.py latticeai/services/app_context.py latticeai/services/model_runtime.py latticeai/services/model_catalog.py latticeai/services/model_recommendation.py latticeai/services/tool_dispatch.py latticeai/services/upload_service.py latticeai/core/tool_registry.py latticeai/core/enterprise.py latticeai/core/enterprise_admin.py latticeai/core/agent_prompts.py latticeai/core/workspace_os.py latticeai/core/plugins.py latticeai/core/marketplace.py latticeai/core/workflow_engine.py latticeai/core/multi_agent.py latticeai/core/realtime.py knowledge_graph.py knowledge_graph_api.py local_knowledge_api.py llm_router.py p_reinforce.py telegram_bot.py tools.py codex_telegram_bot.py",
|
|
23
|
+
"lint": "node --check static/scripts/account.js && node --check static/scripts/admin.js && node --check static/scripts/chat.js && node --check static/scripts/graph.js && node --check static/scripts/platform.js && node --check static/scripts/ux.js && node --check static/scripts/workspace.js && node --check tests/visual/mock_server.cjs && node --check tests/visual/v3.spec.js && npm run lint:v3",
|
|
24
|
+
"lint:v3": "node scripts/lint_v3.mjs",
|
|
24
25
|
"typecheck": "cd vscode-extension && npm run build",
|
|
25
26
|
"test": "python3 -m pytest tests/ -v",
|
|
26
27
|
"test:unit": "python3 -m pytest tests/unit/ -v",
|
|
@@ -89,6 +90,7 @@
|
|
|
89
90
|
"static/platform.css",
|
|
90
91
|
"static/scripts/",
|
|
91
92
|
"static/css/",
|
|
93
|
+
"static/v3/",
|
|
92
94
|
"static/icons/",
|
|
93
95
|
"plugins/",
|
|
94
96
|
"docs/",
|
package/static/account.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
|
|
6
6
|
<title>Lattice AI</title>
|
|
7
|
-
<script src="/static/scripts/ux.js?v=
|
|
7
|
+
<script src="/static/scripts/ux.js?v=3.0.0"></script>
|
|
8
8
|
<link rel="manifest" href="/manifest.json">
|
|
9
9
|
<meta name="theme-color" content="#f3ecff">
|
|
10
10
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
@@ -14,13 +14,13 @@
|
|
|
14
14
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
15
15
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap">
|
|
16
16
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
|
17
|
-
<link rel="stylesheet" href="/static/css/tokens.css?v=
|
|
18
|
-
<link rel="stylesheet" href="/static/css/reference/base.css?v=
|
|
19
|
-
<link rel="stylesheet" href="/static/css/reference/account.css?v=
|
|
20
|
-
<link rel="stylesheet" href="/static/css/reference/admin.css?v=
|
|
21
|
-
<link rel="stylesheet" href="/static/css/reference/graph.css?v=
|
|
22
|
-
<link rel="stylesheet" href="/static/css/reference/chat.css?v=
|
|
23
|
-
<link rel="stylesheet" href="/static/css/responsive.css?v=
|
|
17
|
+
<link rel="stylesheet" href="/static/css/tokens.css?v=3.0.0">
|
|
18
|
+
<link rel="stylesheet" href="/static/css/reference/base.css?v=3.0.0">
|
|
19
|
+
<link rel="stylesheet" href="/static/css/reference/account.css?v=3.0.0">
|
|
20
|
+
<link rel="stylesheet" href="/static/css/reference/admin.css?v=3.0.0">
|
|
21
|
+
<link rel="stylesheet" href="/static/css/reference/graph.css?v=3.0.0">
|
|
22
|
+
<link rel="stylesheet" href="/static/css/reference/chat.css?v=3.0.0">
|
|
23
|
+
<link rel="stylesheet" href="/static/css/responsive.css?v=3.0.0">
|
|
24
24
|
</head>
|
|
25
25
|
<body class="lattice-ref-auth">
|
|
26
26
|
<div class="orb orb-1"></div>
|
|
@@ -110,6 +110,6 @@
|
|
|
110
110
|
<a href="#" onclick="return false;" id="privacy-link">개인정보 처리방침</a>
|
|
111
111
|
</footer>
|
|
112
112
|
|
|
113
|
-
<script src="/static/scripts/account.js?v=
|
|
113
|
+
<script src="/static/scripts/account.js?v=3.0.0"></script>
|
|
114
114
|
</body>
|
|
115
115
|
</html>
|