ltcai 2.2.7 → 3.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 +72 -34
- package/docs/CHANGELOG.md +119 -0
- package/docs/V3_BACKEND_ARCHITECTURE.md +138 -0
- package/docs/V3_FRONTEND.md +139 -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 +5 -2
- package/latticeai/api/chat.py +10 -2
- package/latticeai/api/search.py +240 -0
- package/latticeai/api/static_routes.py +11 -2
- package/latticeai/core/config.py +18 -0
- package/latticeai/core/embedding_providers.py +625 -0
- package/latticeai/core/local_embeddings.py +86 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +65 -1
- package/latticeai/services/agent_runtime.py +245 -0
- package/latticeai/services/search_service.py +346 -0
- package/package.json +13 -6
- package/scripts/build_v3_assets.mjs +164 -0
- package/scripts/capture/README.md +28 -0
- package/scripts/capture/capture_enterprise.js +8 -0
- package/scripts/capture/capture_graph.js +8 -0
- package/scripts/capture/capture_onboarding.js +8 -0
- package/scripts/capture/capture_page.js +43 -0
- package/scripts/capture/capture_release_media.js +125 -0
- package/scripts/capture/capture_skills.js +8 -0
- package/scripts/capture/capture_workspace.js +8 -0
- package/scripts/generate_diagrams.py +513 -0
- package/scripts/lint_v3.mjs +33 -0
- package/scripts/release-0.3.1.sh +105 -0
- package/scripts/take_screenshots.js +69 -0
- package/scripts/validate_release_artifacts.py +167 -0
- 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.5a595671.css +260 -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/sw.js +3 -1
- package/static/v3/asset-manifest.json +47 -0
- package/static/v3/css/lattice.base.css +128 -0
- package/static/v3/css/lattice.base.e4cdd05d.css +128 -0
- package/static/v3/css/lattice.components.011e988b.css +447 -0
- package/static/v3/css/lattice.components.css +447 -0
- package/static/v3/css/lattice.shell.4920f42d.css +407 -0
- package/static/v3/css/lattice.shell.css +407 -0
- package/static/v3/css/lattice.tokens.c597ff81.css +132 -0
- package/static/v3/css/lattice.tokens.css +132 -0
- package/static/v3/css/lattice.views.3ee19d4e.css +277 -0
- package/static/v3/css/lattice.views.css +277 -0
- package/static/v3/index.html +69 -0
- package/static/v3/js/app.46fb61d9.js +26 -0
- package/static/v3/js/app.js +26 -0
- package/static/v3/js/core/api.22a41d42.js +344 -0
- package/static/v3/js/core/api.js +344 -0
- package/static/v3/js/core/components.4c83e0a9.js +222 -0
- package/static/v3/js/core/components.js +222 -0
- package/static/v3/js/core/dom.a2773eb0.js +148 -0
- package/static/v3/js/core/dom.js +148 -0
- package/static/v3/js/core/router.584570f2.js +37 -0
- package/static/v3/js/core/router.js +37 -0
- package/static/v3/js/core/routes.f935dd50.js +78 -0
- package/static/v3/js/core/routes.js +78 -0
- package/static/v3/js/core/shell.1b6199d6.js +363 -0
- package/static/v3/js/core/shell.js +363 -0
- package/static/v3/js/core/store.34ebd5e6.js +113 -0
- package/static/v3/js/core/store.js +113 -0
- package/static/v3/js/views/admin-audit.660a1fb1.js +185 -0
- package/static/v3/js/views/admin-audit.js +185 -0
- package/static/v3/js/views/admin-permissions.a7ae5f09.js +177 -0
- package/static/v3/js/views/admin-permissions.js +177 -0
- package/static/v3/js/views/admin-policies.3658fd86.js +102 -0
- package/static/v3/js/views/admin-policies.js +102 -0
- package/static/v3/js/views/admin-private-vpc.7d342d36.js +135 -0
- package/static/v3/js/views/admin-private-vpc.js +135 -0
- package/static/v3/js/views/admin-security.07c66b72.js +180 -0
- package/static/v3/js/views/admin-security.js +180 -0
- package/static/v3/js/views/admin-users.03bac88c.js +168 -0
- package/static/v3/js/views/admin-users.js +168 -0
- package/static/v3/js/views/agents.14e48bdd.js +193 -0
- package/static/v3/js/views/agents.js +193 -0
- package/static/v3/js/views/chat.718144ce.js +449 -0
- package/static/v3/js/views/chat.js +449 -0
- package/static/v3/js/views/files.4935197e.js +186 -0
- package/static/v3/js/views/files.js +186 -0
- package/static/v3/js/views/home.cdde3b32.js +119 -0
- package/static/v3/js/views/home.js +119 -0
- package/static/v3/js/views/hybrid-search.b22b97e0.js +195 -0
- package/static/v3/js/views/hybrid-search.js +195 -0
- package/static/v3/js/views/knowledge-graph.a14ea7e7.js +237 -0
- package/static/v3/js/views/knowledge-graph.js +237 -0
- package/static/v3/js/views/models.a1ffa147.js +256 -0
- package/static/v3/js/views/models.js +256 -0
- package/static/v3/js/views/my-computer.1b2ff621.js +237 -0
- package/static/v3/js/views/my-computer.js +237 -0
- package/static/v3/js/views/pipeline.c522f1ce.js +157 -0
- package/static/v3/js/views/pipeline.js +157 -0
- package/static/v3/js/views/settings.4f777210.js +250 -0
- package/static/v3/js/views/settings.js +250 -0
- package/static/workflows.html +4 -4
- package/static/workspace.css +340 -2
- package/static/workspace.html +43 -24
- package/docs/images/tmp_frames/frame_00.png +0 -0
- package/docs/images/tmp_frames/frame_01.png +0 -0
- package/docs/images/tmp_frames/frame_02.png +0 -0
- package/docs/images/tmp_frames/frame_03.png +0 -0
- package/docs/images/tmp_frames/hero_00.png +0 -0
- package/docs/images/tmp_frames/hero_01.png +0 -0
- package/docs/images/tmp_frames/hero_02.png +0 -0
- package/docs/images/tmp_frames/hero_03.png +0 -0
|
@@ -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.1.0",
|
|
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",
|
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
"scripts": {
|
|
18
18
|
"start": "LTCAI",
|
|
19
19
|
"dev": "python3 ltcai_cli.py --reload",
|
|
20
|
-
"build": "npm run build:python",
|
|
20
|
+
"build": "npm run build:assets && npm run build:python",
|
|
21
|
+
"build:assets": "node scripts/build_v3_assets.mjs",
|
|
21
22
|
"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",
|
|
23
|
+
"check:python": "python3 -m py_compile ltcai_cli.py server.py latticeai/server_app.py latticeai/api/auth.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",
|
|
24
|
+
"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",
|
|
25
|
+
"lint:v3": "node scripts/lint_v3.mjs",
|
|
24
26
|
"typecheck": "cd vscode-extension && npm run build",
|
|
25
27
|
"test": "python3 -m pytest tests/ -v",
|
|
26
28
|
"test:unit": "python3 -m pytest tests/unit/ -v",
|
|
@@ -31,7 +33,7 @@
|
|
|
31
33
|
"capture:skills": "node scripts/capture/capture_skills.js",
|
|
32
34
|
"capture:enterprise": "node scripts/capture/capture_enterprise.js",
|
|
33
35
|
"capture:onboarding": "node scripts/capture/capture_onboarding.js",
|
|
34
|
-
"release:artifacts": "npm run build:python && npm pack && cd vscode-extension && npm run package:vsix",
|
|
36
|
+
"release:artifacts": "npm run build:assets && npm run build:python && npm pack && cd vscode-extension && npm run package:vsix",
|
|
35
37
|
"release:validate": "python3 scripts/validate_release_artifacts.py $npm_package_version --require-vsix --require-tgz",
|
|
36
38
|
"publish:npm": "npm pack && npm publish ltcai-$npm_package_version.tgz --access public",
|
|
37
39
|
"publish:pypi": "npm run build:python && python3 -m twine upload --skip-existing dist/ltcai-$npm_package_version.tar.gz dist/ltcai-$npm_package_version-py3-none-any.whl",
|
|
@@ -89,9 +91,14 @@
|
|
|
89
91
|
"static/platform.css",
|
|
90
92
|
"static/scripts/",
|
|
91
93
|
"static/css/",
|
|
94
|
+
"static/v3/",
|
|
92
95
|
"static/icons/",
|
|
93
96
|
"plugins/",
|
|
97
|
+
"scripts/",
|
|
94
98
|
"docs/",
|
|
99
|
+
"!docs/images/tmp_frames/",
|
|
100
|
+
"!**/__pycache__/",
|
|
101
|
+
"!**/*.pyc",
|
|
95
102
|
"requirements.txt",
|
|
96
103
|
"README.md"
|
|
97
104
|
],
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Build the v3 browser asset manifest.
|
|
4
|
+
*
|
|
5
|
+
* The source files stay importable in development. This script writes hashed
|
|
6
|
+
* siblings next to each runtime asset, rewrites ES-module imports to those
|
|
7
|
+
* hashed siblings, and emits static/v3/asset-manifest.json for /app.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
statSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { basename, dirname, extname, join, relative } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
|
+
const staticRoot = join(repoRoot, "static");
|
|
24
|
+
const manifestPath = join(staticRoot, "v3", "asset-manifest.json");
|
|
25
|
+
|
|
26
|
+
const cssSources = [
|
|
27
|
+
"static/css/tokens.css",
|
|
28
|
+
"static/v3/css/lattice.tokens.css",
|
|
29
|
+
"static/v3/css/lattice.base.css",
|
|
30
|
+
"static/v3/css/lattice.components.css",
|
|
31
|
+
"static/v3/css/lattice.shell.css",
|
|
32
|
+
"static/v3/css/lattice.views.css",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const moduleRoot = join(staticRoot, "v3", "js");
|
|
36
|
+
const entry = "static/v3/js/app.js";
|
|
37
|
+
|
|
38
|
+
function posix(p) {
|
|
39
|
+
return p.replaceAll("\\", "/");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sha(text) {
|
|
43
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 8);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function repoPath(abs) {
|
|
47
|
+
return posix(relative(repoRoot, abs));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function publicUrl(repoRel) {
|
|
51
|
+
return "/" + posix(repoRel);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function walk(dir) {
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const name of readdirSync(dir)) {
|
|
57
|
+
const p = join(dir, name);
|
|
58
|
+
const st = statSync(p);
|
|
59
|
+
if (st.isDirectory()) out.push(...walk(p));
|
|
60
|
+
else if (name.endsWith(".js")) out.push(p);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function removeGenerated(dir, ext) {
|
|
66
|
+
if (!existsSync(dir)) return;
|
|
67
|
+
for (const name of readdirSync(dir)) {
|
|
68
|
+
const p = join(dir, name);
|
|
69
|
+
const st = statSync(p);
|
|
70
|
+
if (st.isDirectory()) removeGenerated(p, ext);
|
|
71
|
+
else if (new RegExp(`\\.[0-9a-f]{8}\\${ext}$`).test(name)) rmSync(p);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
removeGenerated(join(staticRoot, "css"), ".css");
|
|
76
|
+
removeGenerated(join(staticRoot, "v3", "css"), ".css");
|
|
77
|
+
removeGenerated(moduleRoot, ".js");
|
|
78
|
+
|
|
79
|
+
const modules = new Map();
|
|
80
|
+
for (const abs of walk(moduleRoot)) {
|
|
81
|
+
modules.set(repoPath(abs), {
|
|
82
|
+
abs,
|
|
83
|
+
rel: repoPath(abs),
|
|
84
|
+
source: readFileSync(abs, "utf8"),
|
|
85
|
+
deps: [],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const importFromRe = /\b(?:import|export)\s+(?:[^"'()]*?\s+from\s*)?["']([^"']+\.js)["']/g;
|
|
90
|
+
for (const mod of modules.values()) {
|
|
91
|
+
const deps = [];
|
|
92
|
+
let match;
|
|
93
|
+
while ((match = importFromRe.exec(mod.source))) {
|
|
94
|
+
const spec = match[1];
|
|
95
|
+
if (!spec.startsWith(".")) continue;
|
|
96
|
+
const depRel = repoPath(join(dirname(mod.abs), spec));
|
|
97
|
+
if (modules.has(depRel)) deps.push(depRel);
|
|
98
|
+
}
|
|
99
|
+
mod.deps = deps;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const hashMemo = new Map();
|
|
103
|
+
function moduleHash(rel, stack = []) {
|
|
104
|
+
if (hashMemo.has(rel)) return hashMemo.get(rel);
|
|
105
|
+
if (stack.includes(rel)) return sha(modules.get(rel).source);
|
|
106
|
+
const mod = modules.get(rel);
|
|
107
|
+
const depHashes = mod.deps
|
|
108
|
+
.sort()
|
|
109
|
+
.map((dep) => `${dep}:${moduleHash(dep, [...stack, rel])}`)
|
|
110
|
+
.join("\n");
|
|
111
|
+
const h = sha(`${mod.source}\n/* dependency-hashes */\n${depHashes}`);
|
|
112
|
+
hashMemo.set(rel, h);
|
|
113
|
+
return h;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const rel of modules.keys()) moduleHash(rel);
|
|
117
|
+
|
|
118
|
+
function hashedRel(rel, hash) {
|
|
119
|
+
const ext = extname(rel);
|
|
120
|
+
return posix(join(dirname(rel), `${basename(rel, ext)}.${hash}${ext}`));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const assets = {};
|
|
124
|
+
|
|
125
|
+
for (const sourceRel of cssSources) {
|
|
126
|
+
const abs = join(repoRoot, sourceRel);
|
|
127
|
+
const source = readFileSync(abs, "utf8");
|
|
128
|
+
const outRel = hashedRel(sourceRel, sha(source));
|
|
129
|
+
writeFileSync(join(repoRoot, outRel), source, "utf8");
|
|
130
|
+
assets[sourceRel] = publicUrl(outRel);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function rewriteModule(mod) {
|
|
134
|
+
return mod.source.replace(importFromRe, (full, spec) => {
|
|
135
|
+
if (!spec.startsWith(".")) return full;
|
|
136
|
+
const depRel = repoPath(join(dirname(mod.abs), spec));
|
|
137
|
+
const depHash = hashMemo.get(depRel);
|
|
138
|
+
if (!depHash) return full;
|
|
139
|
+
const depOutAbs = join(repoRoot, hashedRel(depRel, depHash));
|
|
140
|
+
const nextSpec = posix(relative(dirname(join(repoRoot, hashedRel(mod.rel, hashMemo.get(mod.rel)))), depOutAbs));
|
|
141
|
+
const normalized = nextSpec.startsWith(".") ? nextSpec : `./${nextSpec}`;
|
|
142
|
+
return full.replace(spec, normalized);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const mod of modules.values()) {
|
|
147
|
+
const outRel = hashedRel(mod.rel, hashMemo.get(mod.rel));
|
|
148
|
+
mkdirSync(dirname(join(repoRoot, outRel)), { recursive: true });
|
|
149
|
+
writeFileSync(join(repoRoot, outRel), rewriteModule(mod), "utf8");
|
|
150
|
+
assets[mod.rel] = publicUrl(outRel);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const manifest = {
|
|
154
|
+
version: "3.1.0",
|
|
155
|
+
generated_at: "deterministic",
|
|
156
|
+
entrypoints: {
|
|
157
|
+
app: assets[entry],
|
|
158
|
+
styles: cssSources.map((rel) => assets[rel]),
|
|
159
|
+
},
|
|
160
|
+
assets,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
164
|
+
console.log(`wrote ${repoPath(manifestPath)} with ${Object.keys(assets).length} assets`);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Screenshot Capture
|
|
2
|
+
|
|
3
|
+
Reproducible Playwright capture scripts for release screenshots.
|
|
4
|
+
|
|
5
|
+
Prerequisites:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm ci
|
|
9
|
+
npx playwright install chromium
|
|
10
|
+
LTCAI
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Optional environment:
|
|
14
|
+
|
|
15
|
+
- `LTCAI_CAPTURE_BASE_URL` defaults to `http://localhost:4825`
|
|
16
|
+
- `SESSION_TOKEN` or `LTCAI_SESSION_TOKEN` injects an authenticated session cookie
|
|
17
|
+
- `LTCAI_CAPTURE_OUT` defaults to `docs/images`
|
|
18
|
+
- `LTCAI_CAPTURE_HEADED=1` runs with a visible browser
|
|
19
|
+
|
|
20
|
+
Commands:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm run capture:workspace
|
|
24
|
+
npm run capture:graph
|
|
25
|
+
npm run capture:skills
|
|
26
|
+
npm run capture:enterprise
|
|
27
|
+
npm run capture:onboarding
|
|
28
|
+
```
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
async function loadPlaywright() {
|
|
5
|
+
try {
|
|
6
|
+
return require("@playwright/test");
|
|
7
|
+
} catch (_) {
|
|
8
|
+
return require("playwright");
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function capturePage(options) {
|
|
13
|
+
const { chromium } = await loadPlaywright();
|
|
14
|
+
const baseURL = process.env.LTCAI_CAPTURE_BASE_URL || "http://localhost:4825";
|
|
15
|
+
const outDir = path.resolve(process.env.LTCAI_CAPTURE_OUT || path.join(__dirname, "..", "..", "docs", "images"));
|
|
16
|
+
const sessionToken = process.env.SESSION_TOKEN || process.env.LTCAI_SESSION_TOKEN || "";
|
|
17
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
18
|
+
|
|
19
|
+
const browser = await chromium.launch({ headless: process.env.LTCAI_CAPTURE_HEADED !== "1" });
|
|
20
|
+
const context = await browser.newContext({
|
|
21
|
+
viewport: { width: Number(process.env.LTCAI_CAPTURE_WIDTH || 1440), height: Number(process.env.LTCAI_CAPTURE_HEIGHT || 920) },
|
|
22
|
+
deviceScaleFactor: Number(process.env.LTCAI_CAPTURE_SCALE || 2),
|
|
23
|
+
});
|
|
24
|
+
if (sessionToken) {
|
|
25
|
+
const host = new URL(baseURL).hostname;
|
|
26
|
+
await context.addCookies([{ name: "session_token", value: sessionToken, domain: host, path: "/", httpOnly: true, secure: false }]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const page = await context.newPage();
|
|
30
|
+
const target = new URL(options.path, baseURL).toString();
|
|
31
|
+
await page.goto(target, { waitUntil: "networkidle", timeout: Number(process.env.LTCAI_CAPTURE_TIMEOUT || 30_000) });
|
|
32
|
+
if (options.hash) await page.evaluate((hash) => { location.hash = hash; }, options.hash);
|
|
33
|
+
if (options.waitFor) await page.waitForSelector(options.waitFor, { timeout: 15_000 });
|
|
34
|
+
await page.waitForTimeout(Number(process.env.LTCAI_CAPTURE_SETTLE_MS || options.settleMs || 900));
|
|
35
|
+
|
|
36
|
+
const outPath = path.join(outDir, options.filename);
|
|
37
|
+
await page.screenshot({ path: outPath, fullPage: Boolean(options.fullPage) });
|
|
38
|
+
await browser.close();
|
|
39
|
+
console.log(outPath);
|
|
40
|
+
return outPath;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { capturePage };
|