ltcai 8.8.0 → 9.0.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 +33 -37
- package/auto_setup.py +84 -70
- package/docs/CHANGELOG.md +113 -237
- package/docs/CODE_REVIEW_2026-07-06.md +764 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
- package/docs/DEVELOPMENT.md +10 -10
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
- package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
- package/docs/TRUST_MODEL.md +5 -1
- package/docs/WHY_LATTICE.md +4 -3
- package/docs/architecture.md +4 -0
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +4 -9
- package/lattice_brain/conversations.py +156 -21
- package/lattice_brain/embeddings.py +38 -2
- package/lattice_brain/graph/_kg_common.py +39 -496
- package/lattice_brain/graph/_kg_constants.py +243 -0
- package/lattice_brain/graph/_kg_fsutil.py +297 -0
- package/lattice_brain/graph/discovery.py +0 -948
- package/lattice_brain/graph/discovery_index.py +972 -0
- package/lattice_brain/graph/json_utils.py +25 -0
- package/lattice_brain/graph/retrieval.py +66 -597
- package/lattice_brain/graph/retrieval_docgen.py +210 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -0
- package/lattice_brain/graph/runtime.py +16 -0
- package/lattice_brain/graph/store.py +6 -0
- package/lattice_brain/ingestion.py +68 -0
- package/lattice_brain/portability.py +1 -9
- package/lattice_brain/quality.py +98 -4
- package/lattice_brain/runtime/agent_runtime.py +166 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/utils.py +28 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +368 -418
- package/latticeai/api/chat_helpers.py +227 -0
- package/latticeai/api/computer_use.py +149 -31
- package/latticeai/api/marketplace.py +11 -0
- package/latticeai/api/mcp.py +3 -2
- package/latticeai/api/models.py +4 -1
- package/latticeai/api/permissions.py +72 -33
- package/latticeai/api/setup.py +17 -2
- package/latticeai/api/tools.py +105 -62
- package/latticeai/app_factory.py +101 -296
- package/latticeai/core/agent.py +25 -7
- package/latticeai/core/io_utils.py +37 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/local_embeddings.py +2 -4
- package/latticeai/core/marketplace.py +33 -2
- package/latticeai/core/mcp_catalog.py +450 -0
- package/latticeai/core/mcp_registry.py +2 -441
- package/latticeai/core/sessions.py +11 -3
- package/latticeai/core/tool_registry.py +15 -4
- package/latticeai/core/users.py +4 -9
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/core/workspace_os_utils.py +3 -17
- package/latticeai/integrations/telegram_bot.py +7 -2
- package/latticeai/models/model_providers.py +111 -0
- package/latticeai/models/router.py +58 -136
- package/latticeai/runtime/audit_runtime.py +27 -16
- package/latticeai/runtime/automation_runtime.py +9 -0
- package/latticeai/runtime/bootstrap.py +1 -1
- package/latticeai/runtime/history_runtime.py +163 -0
- package/latticeai/runtime/namespace_runtime.py +173 -0
- package/latticeai/runtime/network_config_runtime.py +56 -0
- package/latticeai/runtime/sso_config_runtime.py +128 -0
- package/latticeai/runtime/user_key_runtime.py +106 -0
- package/latticeai/services/app_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +2 -2
- package/latticeai/services/memory_service.py +213 -0
- package/latticeai/services/model_engines.py +79 -12
- package/latticeai/services/model_runtime.py +24 -4
- package/latticeai/services/platform_runtime.py +9 -1
- package/latticeai/services/process_audit.py +208 -0
- package/latticeai/services/product_readiness.py +11 -11
- package/latticeai/services/review_queue.py +64 -11
- package/latticeai/services/run_executor.py +21 -0
- package/latticeai/services/search_service.py +106 -30
- package/latticeai/services/setup_detection.py +80 -0
- package/latticeai/services/tool_dispatch.py +66 -0
- package/latticeai/services/workspace_service.py +15 -0
- package/package.json +1 -1
- package/scripts/check_i18n_literals.mjs +20 -8
- package/scripts/i18n_literal_allowlist.json +34 -0
- package/scripts/lint_frontend.mjs +6 -2
- package/setup_wizard.py +196 -74
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/{Act-C7K9wsO9.js → Act-21lIXx2E.js} +1 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
- package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-BA7Z2Q1u.js} +1 -1
- package/static/app/assets/{Library-Cgj-EF50.js → Library-bFMtyni3.js} +1 -1
- package/static/app/assets/System-K6krGCqn.js +1 -0
- package/static/app/assets/index-C4R3ws30.js +17 -0
- package/static/app/assets/index-ChSeOB02.css +2 -0
- package/static/app/assets/primitives-sQU3it5I.js +1 -0
- package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-DK3Fd_lR.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/css/tokens.css +4 -2
- package/static/sw.js +1 -1
- package/tools/local_files.py +6 -0
- package/latticeai/runtime/sso_runtime.py +0 -52
- package/static/app/assets/Brain-I1OSzxJu.js +0 -321
- package/static/app/assets/System-D1Lkei3I.js +0 -1
- package/static/app/assets/index--P0ksosz.js +0 -17
- package/static/app/assets/index-DCh5AoXt.css +0 -2
- package/static/app/assets/primitives-BLqaKk5g.js +0 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# ruff: noqa: F403,F405
|
|
4
|
+
|
|
5
|
+
from ._kg_common import * # noqa: F403,F401
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KnowledgeGraphDocGenMixin:
|
|
9
|
+
"""Multi-hop retrieval specialised for document generation, split out
|
|
10
|
+
of retrieval. Composed into KnowledgeGraphStore alongside the other
|
|
11
|
+
retrieval mixins; shared instance means sibling helpers resolve via MRO.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def search_for_document_generation(
|
|
15
|
+
self, query: str, limit: int = 10
|
|
16
|
+
) -> List[Dict[str, Any]]:
|
|
17
|
+
"""Hybrid retrieval optimized for document generation.
|
|
18
|
+
|
|
19
|
+
Scoring: 0.5*text_relevance + 0.3*graph_relationship + 0.2*recency
|
|
20
|
+
Returns nodes with rich context for document generation prompts.
|
|
21
|
+
"""
|
|
22
|
+
query = str(query or "").strip()
|
|
23
|
+
if not query:
|
|
24
|
+
return []
|
|
25
|
+
limit = max(1, min(int(limit or 10), 50))
|
|
26
|
+
terms = _topic_candidates(query, limit=12)
|
|
27
|
+
now = datetime.now()
|
|
28
|
+
nt, et = self._read_tables()
|
|
29
|
+
|
|
30
|
+
with self._connect() as conn:
|
|
31
|
+
candidate_rows = []
|
|
32
|
+
seen_ids = set()
|
|
33
|
+
|
|
34
|
+
if query:
|
|
35
|
+
q = f"%{query}%"
|
|
36
|
+
rows = conn.execute(
|
|
37
|
+
f"""
|
|
38
|
+
SELECT id, type, title, summary, metadata_json, updated_at
|
|
39
|
+
FROM {nt}
|
|
40
|
+
WHERE (title LIKE ? OR summary LIKE ? OR metadata_json LIKE ?)
|
|
41
|
+
AND type IN ('Document', 'File', 'CodeFile', 'SlideDeck',
|
|
42
|
+
'Spreadsheet', 'Image', 'ImageText', 'Chat',
|
|
43
|
+
'Decision', 'Task', 'Concept', 'Feature',
|
|
44
|
+
'Page', 'Slide')
|
|
45
|
+
ORDER BY updated_at DESC, id ASC
|
|
46
|
+
LIMIT ?
|
|
47
|
+
""",
|
|
48
|
+
(q, q, q, limit * 5),
|
|
49
|
+
).fetchall()
|
|
50
|
+
for row in rows:
|
|
51
|
+
if row["id"] not in seen_ids:
|
|
52
|
+
seen_ids.add(row["id"])
|
|
53
|
+
candidate_rows.append(row)
|
|
54
|
+
|
|
55
|
+
for term in terms:
|
|
56
|
+
t = f"%{term}%"
|
|
57
|
+
rows = conn.execute(
|
|
58
|
+
f"""
|
|
59
|
+
SELECT id, type, title, summary, metadata_json, updated_at
|
|
60
|
+
FROM {nt}
|
|
61
|
+
WHERE (title LIKE ? OR summary LIKE ? OR metadata_json LIKE ?)
|
|
62
|
+
AND type IN ('Document', 'File', 'CodeFile', 'SlideDeck',
|
|
63
|
+
'Spreadsheet', 'Image', 'ImageText', 'Chat',
|
|
64
|
+
'Decision', 'Task', 'Concept', 'Feature',
|
|
65
|
+
'Page', 'Slide')
|
|
66
|
+
ORDER BY updated_at DESC, id ASC
|
|
67
|
+
LIMIT ?
|
|
68
|
+
""",
|
|
69
|
+
(t, t, t, limit * 3),
|
|
70
|
+
).fetchall()
|
|
71
|
+
for row in rows:
|
|
72
|
+
if row["id"] not in seen_ids:
|
|
73
|
+
seen_ids.add(row["id"])
|
|
74
|
+
candidate_rows.append(row)
|
|
75
|
+
|
|
76
|
+
scored_results = []
|
|
77
|
+
for row in candidate_rows:
|
|
78
|
+
haystack = (
|
|
79
|
+
f"{row['title']} {row['summary']} {row['metadata_json']}".lower()
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
text_hits = sum(1 for term in terms if term.lower() in haystack)
|
|
83
|
+
text_score = min(1.0, text_hits / max(len(terms), 1))
|
|
84
|
+
|
|
85
|
+
edge_count = conn.execute(
|
|
86
|
+
f"SELECT COUNT(*) AS c FROM {et} WHERE from_node=? OR to_node=?",
|
|
87
|
+
(row["id"], row["id"]),
|
|
88
|
+
).fetchone()["c"]
|
|
89
|
+
graph_score = min(1.0, math.log1p(edge_count) / 4.0)
|
|
90
|
+
|
|
91
|
+
recency = _recency_score(
|
|
92
|
+
row["updated_at"], now=now, half_life_days=14.0
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
doc_type_boost = (
|
|
96
|
+
1.2
|
|
97
|
+
if row["type"]
|
|
98
|
+
in (
|
|
99
|
+
"Document",
|
|
100
|
+
"File",
|
|
101
|
+
"SlideDeck",
|
|
102
|
+
"Decision",
|
|
103
|
+
)
|
|
104
|
+
else 1.0
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
hybrid_score = (
|
|
108
|
+
0.5 * text_score + 0.3 * graph_score + 0.2 * recency
|
|
109
|
+
) * doc_type_boost
|
|
110
|
+
|
|
111
|
+
meta = _safe_loads(row["metadata_json"])
|
|
112
|
+
neighbor_concepts = []
|
|
113
|
+
neighbor_rows = conn.execute(
|
|
114
|
+
f"""
|
|
115
|
+
SELECT n.title, n.type FROM {et} e
|
|
116
|
+
JOIN {nt} n ON n.id = CASE WHEN e.from_node = ? THEN e.to_node ELSE e.from_node END
|
|
117
|
+
WHERE (e.from_node = ? OR e.to_node = ?)
|
|
118
|
+
AND n.type IN ('Concept', 'Feature', 'Decision', 'Task')
|
|
119
|
+
LIMIT 8
|
|
120
|
+
""",
|
|
121
|
+
(row["id"], row["id"], row["id"]),
|
|
122
|
+
).fetchall()
|
|
123
|
+
for nr in neighbor_rows:
|
|
124
|
+
neighbor_concepts.append({"title": nr["title"], "type": nr["type"]})
|
|
125
|
+
|
|
126
|
+
scored_results.append(
|
|
127
|
+
{
|
|
128
|
+
"id": row["id"],
|
|
129
|
+
"type": row["type"],
|
|
130
|
+
"title": row["title"],
|
|
131
|
+
"summary": row["summary"],
|
|
132
|
+
"metadata": meta,
|
|
133
|
+
"updated_at": row["updated_at"],
|
|
134
|
+
"hybrid_score": round(hybrid_score, 4),
|
|
135
|
+
"scores": {
|
|
136
|
+
"text": round(text_score, 4),
|
|
137
|
+
"graph": round(graph_score, 4),
|
|
138
|
+
"recency": round(recency, 4),
|
|
139
|
+
},
|
|
140
|
+
"related_concepts": neighbor_concepts,
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
scored_results.sort(key=lambda x: x["hybrid_score"], reverse=True)
|
|
145
|
+
return scored_results[:limit]
|
|
146
|
+
|
|
147
|
+
def multi_hop_context(
|
|
148
|
+
self, node_ids: List[str], max_hops: int = 2
|
|
149
|
+
) -> Dict[str, Any]:
|
|
150
|
+
"""Multi-hop graph traversal from seed nodes for richer context."""
|
|
151
|
+
visited_nodes = set()
|
|
152
|
+
visited_edges = set()
|
|
153
|
+
all_nodes = []
|
|
154
|
+
all_edges = []
|
|
155
|
+
frontier = set(node_ids)
|
|
156
|
+
nt, et = self._read_tables()
|
|
157
|
+
|
|
158
|
+
with self._connect() as conn:
|
|
159
|
+
for hop in range(max_hops):
|
|
160
|
+
if not frontier:
|
|
161
|
+
break
|
|
162
|
+
next_frontier = set()
|
|
163
|
+
for nid in frontier:
|
|
164
|
+
if nid in visited_nodes:
|
|
165
|
+
continue
|
|
166
|
+
visited_nodes.add(nid)
|
|
167
|
+
row = conn.execute(
|
|
168
|
+
f"SELECT id, type, title, summary, metadata_json, updated_at FROM {nt} WHERE id=?",
|
|
169
|
+
(nid,),
|
|
170
|
+
).fetchone()
|
|
171
|
+
if row:
|
|
172
|
+
all_nodes.append(
|
|
173
|
+
{
|
|
174
|
+
"id": row["id"],
|
|
175
|
+
"type": row["type"],
|
|
176
|
+
"title": row["title"],
|
|
177
|
+
"summary": row["summary"],
|
|
178
|
+
"metadata": _safe_loads(row["metadata_json"]),
|
|
179
|
+
"hop": hop,
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
edge_rows = conn.execute(
|
|
183
|
+
f"""
|
|
184
|
+
SELECT id, from_node, to_node, type, weight
|
|
185
|
+
FROM {et} WHERE from_node=? OR to_node=?
|
|
186
|
+
ORDER BY id ASC
|
|
187
|
+
""",
|
|
188
|
+
(nid, nid),
|
|
189
|
+
).fetchall()
|
|
190
|
+
for er in edge_rows:
|
|
191
|
+
if er["id"] not in visited_edges:
|
|
192
|
+
visited_edges.add(er["id"])
|
|
193
|
+
all_edges.append(
|
|
194
|
+
{
|
|
195
|
+
"from": er["from_node"],
|
|
196
|
+
"to": er["to_node"],
|
|
197
|
+
"type": er["type"],
|
|
198
|
+
"weight": er["weight"],
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
other = (
|
|
202
|
+
er["to_node"]
|
|
203
|
+
if er["from_node"] == nid
|
|
204
|
+
else er["from_node"]
|
|
205
|
+
)
|
|
206
|
+
if other not in visited_nodes:
|
|
207
|
+
next_frontier.add(other)
|
|
208
|
+
frontier = next_frontier
|
|
209
|
+
|
|
210
|
+
return {"nodes": all_nodes, "edges": all_edges}
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# ruff: noqa: F403,F405
|
|
4
|
+
|
|
5
|
+
from ._kg_common import * # noqa: F403,F401
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KnowledgeGraphVectorMixin:
|
|
9
|
+
"""Vector-embedding index build/status/search, split out of retrieval.
|
|
10
|
+
|
|
11
|
+
Composed into KnowledgeGraphStore alongside KnowledgeGraphRetrievalMixin;
|
|
12
|
+
both mixins share the same instance, so vector methods still reach sibling
|
|
13
|
+
retrieval/write helpers (e.g. self._vector_text_for_node) through the MRO.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def _iter_vector_source_items(
|
|
17
|
+
self,
|
|
18
|
+
conn: sqlite3.Connection,
|
|
19
|
+
*,
|
|
20
|
+
include_nodes: bool = True,
|
|
21
|
+
include_chunks: bool = True,
|
|
22
|
+
) -> List[Dict[str, Any]]:
|
|
23
|
+
items: List[Dict[str, Any]] = []
|
|
24
|
+
if include_nodes:
|
|
25
|
+
for row in conn.execute(
|
|
26
|
+
"""
|
|
27
|
+
SELECT id, type, title, summary, metadata_json
|
|
28
|
+
FROM nodes
|
|
29
|
+
WHERE type <> 'Chunk'
|
|
30
|
+
ORDER BY updated_at DESC, id ASC
|
|
31
|
+
"""
|
|
32
|
+
).fetchall():
|
|
33
|
+
metadata = _safe_loads(row["metadata_json"])
|
|
34
|
+
text = self._vector_text_for_node(
|
|
35
|
+
title=row["title"],
|
|
36
|
+
summary=row["summary"] or "",
|
|
37
|
+
metadata=metadata,
|
|
38
|
+
)
|
|
39
|
+
if text:
|
|
40
|
+
items.append(
|
|
41
|
+
{
|
|
42
|
+
"item_id": row["id"],
|
|
43
|
+
"item_type": "node",
|
|
44
|
+
"source_node": row["id"],
|
|
45
|
+
"text": text,
|
|
46
|
+
"metadata": {"node_type": row["type"], **metadata},
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
if include_chunks:
|
|
50
|
+
for row in conn.execute(
|
|
51
|
+
"""
|
|
52
|
+
SELECT c.id, c.source_node AS parent_source_node, c.text, c.metadata_json
|
|
53
|
+
FROM chunks c
|
|
54
|
+
JOIN nodes n ON n.id=c.id
|
|
55
|
+
ORDER BY c.created_at DESC, c.id ASC
|
|
56
|
+
"""
|
|
57
|
+
).fetchall():
|
|
58
|
+
metadata = _safe_loads(row["metadata_json"])
|
|
59
|
+
text = _clean_text(row["text"] or "")
|
|
60
|
+
if text:
|
|
61
|
+
items.append(
|
|
62
|
+
{
|
|
63
|
+
"item_id": row["id"],
|
|
64
|
+
"item_type": "chunk",
|
|
65
|
+
"source_node": row["id"],
|
|
66
|
+
"text": text,
|
|
67
|
+
"metadata": {
|
|
68
|
+
**metadata,
|
|
69
|
+
"parent_source_node": row["parent_source_node"],
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
return items
|
|
74
|
+
|
|
75
|
+
def rebuild_vector_index(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
full: bool = False,
|
|
79
|
+
include_nodes: bool = True,
|
|
80
|
+
include_chunks: bool = True,
|
|
81
|
+
) -> Dict[str, Any]:
|
|
82
|
+
"""Rebuild the derived vector index without mutating graph content."""
|
|
83
|
+
op_id = f"vector-op:{_sha256_text(f'{time.time()}:{os.getpid()}')[:24]}"
|
|
84
|
+
requested_at = _now()
|
|
85
|
+
started = time.perf_counter()
|
|
86
|
+
try:
|
|
87
|
+
with self._connect() as conn:
|
|
88
|
+
conn.execute(
|
|
89
|
+
"""
|
|
90
|
+
INSERT INTO vector_index_operations(
|
|
91
|
+
id, operation, status, requested_at, started_at, metadata_json
|
|
92
|
+
)
|
|
93
|
+
VALUES (?, ?, 'running', ?, ?, ?)
|
|
94
|
+
""",
|
|
95
|
+
(
|
|
96
|
+
op_id,
|
|
97
|
+
"rebuild_full" if full else "rebuild_incremental",
|
|
98
|
+
requested_at,
|
|
99
|
+
requested_at,
|
|
100
|
+
_json(
|
|
101
|
+
{
|
|
102
|
+
"include_nodes": include_nodes,
|
|
103
|
+
"include_chunks": include_chunks,
|
|
104
|
+
}
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
if full:
|
|
109
|
+
filters = []
|
|
110
|
+
if include_nodes:
|
|
111
|
+
filters.append("'node'")
|
|
112
|
+
if include_chunks:
|
|
113
|
+
filters.append("'chunk'")
|
|
114
|
+
if filters:
|
|
115
|
+
conn.execute(
|
|
116
|
+
f"DELETE FROM vector_embeddings WHERE item_type IN ({','.join(filters)})"
|
|
117
|
+
)
|
|
118
|
+
items = self._iter_vector_source_items(
|
|
119
|
+
conn,
|
|
120
|
+
include_nodes=include_nodes,
|
|
121
|
+
include_chunks=include_chunks,
|
|
122
|
+
)
|
|
123
|
+
indexed = skipped = 0
|
|
124
|
+
for item in items:
|
|
125
|
+
changed = self._upsert_vector_item(conn, **item)
|
|
126
|
+
if changed:
|
|
127
|
+
indexed += 1
|
|
128
|
+
else:
|
|
129
|
+
skipped += 1
|
|
130
|
+
duration_ms = round((time.perf_counter() - started) * 1000, 2)
|
|
131
|
+
conn.execute(
|
|
132
|
+
"""
|
|
133
|
+
UPDATE vector_index_operations
|
|
134
|
+
SET status='completed', completed_at=?, items_total=?,
|
|
135
|
+
items_indexed=?, items_skipped=?, metadata_json=?
|
|
136
|
+
WHERE id=?
|
|
137
|
+
""",
|
|
138
|
+
(
|
|
139
|
+
_now(),
|
|
140
|
+
len(items),
|
|
141
|
+
indexed,
|
|
142
|
+
skipped,
|
|
143
|
+
_json(
|
|
144
|
+
{
|
|
145
|
+
"include_nodes": include_nodes,
|
|
146
|
+
"include_chunks": include_chunks,
|
|
147
|
+
"duration_ms": duration_ms,
|
|
148
|
+
"embedding_model": self._embedding_model.model_id,
|
|
149
|
+
"embedding_dim": self._embedding_model.dim,
|
|
150
|
+
}
|
|
151
|
+
),
|
|
152
|
+
op_id,
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
return {
|
|
156
|
+
"status": "completed",
|
|
157
|
+
"operation_id": op_id,
|
|
158
|
+
"full": bool(full),
|
|
159
|
+
"items_total": len(items),
|
|
160
|
+
"items_indexed": indexed,
|
|
161
|
+
"items_skipped": skipped,
|
|
162
|
+
"duration_ms": duration_ms,
|
|
163
|
+
"embedding_model": self._embedding_model.model_id,
|
|
164
|
+
"embedding_dim": self._embedding_model.dim,
|
|
165
|
+
}
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
duration_ms = round((time.perf_counter() - started) * 1000, 2)
|
|
168
|
+
with self._connect() as conn:
|
|
169
|
+
conn.execute(
|
|
170
|
+
"""
|
|
171
|
+
INSERT INTO vector_index_operations(
|
|
172
|
+
id, operation, status, requested_at, started_at, completed_at,
|
|
173
|
+
error_message, metadata_json
|
|
174
|
+
)
|
|
175
|
+
VALUES (?, ?, 'failed', ?, ?, ?, ?, ?)
|
|
176
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
177
|
+
status='failed',
|
|
178
|
+
completed_at=excluded.completed_at,
|
|
179
|
+
error_message=excluded.error_message,
|
|
180
|
+
metadata_json=excluded.metadata_json
|
|
181
|
+
""",
|
|
182
|
+
(
|
|
183
|
+
op_id,
|
|
184
|
+
"rebuild_full" if full else "rebuild_incremental",
|
|
185
|
+
requested_at,
|
|
186
|
+
requested_at,
|
|
187
|
+
_now(),
|
|
188
|
+
str(exc),
|
|
189
|
+
_json({"duration_ms": duration_ms}),
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
raise
|
|
193
|
+
|
|
194
|
+
def index_status(self) -> Dict[str, Any]:
|
|
195
|
+
storage_capabilities = None
|
|
196
|
+
try:
|
|
197
|
+
storage_capabilities = self.storage_engine.capabilities().as_dict()
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
storage_capabilities = {
|
|
200
|
+
"engine": "sqlite",
|
|
201
|
+
"available": False,
|
|
202
|
+
"reason": str(exc),
|
|
203
|
+
}
|
|
204
|
+
with self._connect() as conn:
|
|
205
|
+
vector_counts = {
|
|
206
|
+
row["item_type"]: row["count"]
|
|
207
|
+
for row in conn.execute(
|
|
208
|
+
"SELECT item_type, COUNT(*) AS count FROM vector_embeddings GROUP BY item_type"
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
source_items = self._iter_vector_source_items(conn)
|
|
212
|
+
vector_rows = {
|
|
213
|
+
row["item_id"]: row
|
|
214
|
+
for row in conn.execute(
|
|
215
|
+
"""
|
|
216
|
+
SELECT item_id, text_hash, embedding_dim, embedding_model, indexed_at
|
|
217
|
+
FROM vector_embeddings
|
|
218
|
+
"""
|
|
219
|
+
).fetchall()
|
|
220
|
+
}
|
|
221
|
+
latest_rows = conn.execute(
|
|
222
|
+
"""
|
|
223
|
+
SELECT id, operation, status, requested_at, started_at, completed_at,
|
|
224
|
+
items_total, items_indexed, items_skipped, error_message, metadata_json
|
|
225
|
+
FROM vector_index_operations
|
|
226
|
+
ORDER BY requested_at DESC, id DESC
|
|
227
|
+
LIMIT 5
|
|
228
|
+
"""
|
|
229
|
+
).fetchall()
|
|
230
|
+
missing = stale = ready = 0
|
|
231
|
+
source_item_ids = {str(item["item_id"]) for item in source_items}
|
|
232
|
+
backlog_by_type: Dict[str, int] = {}
|
|
233
|
+
backlog_reasons: Dict[str, int] = {}
|
|
234
|
+
backlog_samples: List[Dict[str, Any]] = []
|
|
235
|
+
|
|
236
|
+
def add_backlog(item: Dict[str, Any], reason: str) -> None:
|
|
237
|
+
item_type = str(item.get("item_type") or "unknown")
|
|
238
|
+
backlog_by_type[item_type] = backlog_by_type.get(item_type, 0) + 1
|
|
239
|
+
backlog_reasons[reason] = backlog_reasons.get(reason, 0) + 1
|
|
240
|
+
if len(backlog_samples) >= 20:
|
|
241
|
+
return
|
|
242
|
+
backlog_samples.append(
|
|
243
|
+
{
|
|
244
|
+
"item_id": item.get("item_id"),
|
|
245
|
+
"item_type": item_type,
|
|
246
|
+
"source_node": item.get("source_node"),
|
|
247
|
+
"reason": reason,
|
|
248
|
+
"metadata": {
|
|
249
|
+
key: value
|
|
250
|
+
for key, value in dict(item.get("metadata") or {}).items()
|
|
251
|
+
if key in {"node_type", "source", "conversation_id", "parent_source_node"}
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
for item in source_items:
|
|
257
|
+
vector_row = vector_rows.get(item["item_id"])
|
|
258
|
+
expected_hash = _sha256_text(_clean_text(item["text"]))
|
|
259
|
+
if not vector_row:
|
|
260
|
+
missing += 1
|
|
261
|
+
add_backlog(item, "missing_vector")
|
|
262
|
+
elif (
|
|
263
|
+
vector_row["text_hash"] != expected_hash
|
|
264
|
+
or vector_row["embedding_dim"] != self._embedding_model.dim
|
|
265
|
+
or vector_row["embedding_model"] != self._embedding_model.model_id
|
|
266
|
+
):
|
|
267
|
+
stale += 1
|
|
268
|
+
reason = "text_changed"
|
|
269
|
+
if vector_row["embedding_model"] != self._embedding_model.model_id:
|
|
270
|
+
reason = "model_changed"
|
|
271
|
+
elif vector_row["embedding_dim"] != self._embedding_model.dim:
|
|
272
|
+
reason = "dimension_changed"
|
|
273
|
+
add_backlog(item, reason)
|
|
274
|
+
else:
|
|
275
|
+
ready += 1
|
|
276
|
+
pending = missing + stale
|
|
277
|
+
orphaned_items = max(0, len(set(vector_rows) - source_item_ids))
|
|
278
|
+
coverage_ratio = round(ready / len(source_items), 6) if source_items else 1.0
|
|
279
|
+
latest_completed = None
|
|
280
|
+
for row in latest_rows:
|
|
281
|
+
if row["status"] == "completed":
|
|
282
|
+
latest_completed = row
|
|
283
|
+
break
|
|
284
|
+
latency_budget: Dict[str, Any] = {
|
|
285
|
+
"target_rebuild_ms": 10_000,
|
|
286
|
+
"last_rebuild_duration_ms": None,
|
|
287
|
+
"last_items_per_second": None,
|
|
288
|
+
"within_target": None,
|
|
289
|
+
}
|
|
290
|
+
if latest_completed is not None:
|
|
291
|
+
metadata = _safe_loads(latest_completed["metadata_json"])
|
|
292
|
+
duration_ms = metadata.get("duration_ms")
|
|
293
|
+
items_total = int(latest_completed["items_total"] or 0)
|
|
294
|
+
if isinstance(duration_ms, (int, float)) and duration_ms > 0:
|
|
295
|
+
latency_budget.update(
|
|
296
|
+
{
|
|
297
|
+
"last_rebuild_duration_ms": round(float(duration_ms), 2),
|
|
298
|
+
"last_items_per_second": round(items_total / (float(duration_ms) / 1000.0), 2),
|
|
299
|
+
"within_target": float(duration_ms) <= 10_000,
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
return {
|
|
303
|
+
"status": "ready" if pending == 0 else "needs_reindex",
|
|
304
|
+
"storage": {
|
|
305
|
+
"db_path": str(self.db_path),
|
|
306
|
+
"backend": "sqlite",
|
|
307
|
+
"embedding_model": self._embedding_model.model_id,
|
|
308
|
+
"embedding_dim": self._embedding_model.dim,
|
|
309
|
+
# Honest capability report: trigram FTS5 keyword index, or
|
|
310
|
+
# LIKE-scan fallback when this SQLite build lacks it.
|
|
311
|
+
"fts_enabled": bool(getattr(self, "_fts_enabled", False)),
|
|
312
|
+
"engine": storage_capabilities,
|
|
313
|
+
"vector_search_backend": (
|
|
314
|
+
storage_capabilities.get("vector_backend")
|
|
315
|
+
if isinstance(storage_capabilities, dict)
|
|
316
|
+
else "bruteforce-cosine"
|
|
317
|
+
),
|
|
318
|
+
"vector_search_mode": (
|
|
319
|
+
(storage_capabilities.get("metadata") or {}).get("vector_mode")
|
|
320
|
+
if isinstance(storage_capabilities, dict)
|
|
321
|
+
else "fallback"
|
|
322
|
+
),
|
|
323
|
+
"sqlite_vec_ann_available": (
|
|
324
|
+
bool((storage_capabilities.get("metadata") or {}).get("sqlite_vec_ann_available"))
|
|
325
|
+
if isinstance(storage_capabilities, dict)
|
|
326
|
+
else False
|
|
327
|
+
),
|
|
328
|
+
},
|
|
329
|
+
"source_items": len(source_items),
|
|
330
|
+
"indexed_items": sum(vector_counts.values()),
|
|
331
|
+
"ready_items": ready,
|
|
332
|
+
"missing_items": missing,
|
|
333
|
+
"stale_items": stale,
|
|
334
|
+
"pending_items": pending,
|
|
335
|
+
"by_item_type": vector_counts,
|
|
336
|
+
"scale": {
|
|
337
|
+
"version": 1,
|
|
338
|
+
"coverage_ratio": coverage_ratio,
|
|
339
|
+
"coverage_percent": round(coverage_ratio * 100.0, 2),
|
|
340
|
+
"source_items": len(source_items),
|
|
341
|
+
"ready_items": ready,
|
|
342
|
+
"pending_items": pending,
|
|
343
|
+
"missing_items": missing,
|
|
344
|
+
"stale_items": stale,
|
|
345
|
+
"orphaned_items": orphaned_items,
|
|
346
|
+
"backlog_by_item_type": backlog_by_type,
|
|
347
|
+
"backlog_reasons": backlog_reasons,
|
|
348
|
+
"backlog_samples": backlog_samples,
|
|
349
|
+
"incremental_reindex_recommended": pending > 0,
|
|
350
|
+
"full_rebuild_recommended": orphaned_items > 0,
|
|
351
|
+
"latency_budget": latency_budget,
|
|
352
|
+
},
|
|
353
|
+
"operations": [
|
|
354
|
+
{
|
|
355
|
+
"id": row["id"],
|
|
356
|
+
"operation": row["operation"],
|
|
357
|
+
"status": row["status"],
|
|
358
|
+
"requested_at": row["requested_at"],
|
|
359
|
+
"started_at": row["started_at"],
|
|
360
|
+
"completed_at": row["completed_at"],
|
|
361
|
+
"items_total": row["items_total"],
|
|
362
|
+
"items_indexed": row["items_indexed"],
|
|
363
|
+
"items_skipped": row["items_skipped"],
|
|
364
|
+
"error_message": row["error_message"],
|
|
365
|
+
"metadata": _safe_loads(row["metadata_json"]),
|
|
366
|
+
}
|
|
367
|
+
for row in latest_rows
|
|
368
|
+
],
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
def vector_search(
|
|
372
|
+
self,
|
|
373
|
+
query: str,
|
|
374
|
+
*,
|
|
375
|
+
limit: int = 30,
|
|
376
|
+
min_score: float = 0.0,
|
|
377
|
+
max_candidates: int = 10_000,
|
|
378
|
+
) -> Dict[str, Any]:
|
|
379
|
+
query = str(query or "").strip()
|
|
380
|
+
limit = max(1, min(int(limit or 30), 100))
|
|
381
|
+
min_score = float(min_score or 0.0)
|
|
382
|
+
if not query:
|
|
383
|
+
return {"query": query, "matches": []}
|
|
384
|
+
query_vector = self._embedding_model.embed(query)
|
|
385
|
+
max_candidates = max(limit, min(int(max_candidates or 10_000), 50_000))
|
|
386
|
+
with self._connect() as conn:
|
|
387
|
+
rows = conn.execute(
|
|
388
|
+
"""
|
|
389
|
+
SELECT
|
|
390
|
+
ve.item_id, ve.item_type, ve.source_node, ve.embedding,
|
|
391
|
+
ve.embedding_dim, ve.embedding_model, ve.metadata_json AS vector_metadata,
|
|
392
|
+
n.type AS node_type, n.title AS node_title, n.summary AS node_summary,
|
|
393
|
+
n.metadata_json AS node_metadata, n.updated_at AS node_updated_at,
|
|
394
|
+
c.text AS chunk_text, c.source_node AS parent_node_id,
|
|
395
|
+
pn.type AS parent_type, pn.title AS parent_title,
|
|
396
|
+
pn.summary AS parent_summary, pn.metadata_json AS parent_metadata,
|
|
397
|
+
pn.updated_at AS parent_updated_at
|
|
398
|
+
FROM vector_embeddings ve
|
|
399
|
+
LEFT JOIN nodes n ON n.id=ve.source_node
|
|
400
|
+
LEFT JOIN chunks c ON c.id=ve.item_id
|
|
401
|
+
LEFT JOIN nodes pn ON pn.id=c.source_node
|
|
402
|
+
WHERE ve.embedding_model=? AND ve.embedding_dim=?
|
|
403
|
+
ORDER BY ve.indexed_at DESC
|
|
404
|
+
LIMIT ?
|
|
405
|
+
""",
|
|
406
|
+
(
|
|
407
|
+
self._embedding_model.model_id,
|
|
408
|
+
self._embedding_model.dim,
|
|
409
|
+
max_candidates,
|
|
410
|
+
),
|
|
411
|
+
).fetchall()
|
|
412
|
+
scored = []
|
|
413
|
+
for row in rows:
|
|
414
|
+
vector = self._embedding_model.decode(
|
|
415
|
+
row["embedding"], row["embedding_dim"]
|
|
416
|
+
)
|
|
417
|
+
score = self._embedding_model.similarity(query_vector, vector)
|
|
418
|
+
if score < min_score:
|
|
419
|
+
continue
|
|
420
|
+
is_chunk = row["item_type"] == "chunk"
|
|
421
|
+
summary = (
|
|
422
|
+
row["chunk_text"]
|
|
423
|
+
if is_chunk and row["chunk_text"]
|
|
424
|
+
else row["node_summary"]
|
|
425
|
+
)
|
|
426
|
+
parent_metadata = _safe_loads(row["parent_metadata"])
|
|
427
|
+
node_metadata = _safe_loads(row["node_metadata"])
|
|
428
|
+
scored.append(
|
|
429
|
+
{
|
|
430
|
+
"id": row["item_id"],
|
|
431
|
+
"node_id": row["parent_node_id"]
|
|
432
|
+
if is_chunk and row["parent_node_id"]
|
|
433
|
+
else row["source_node"],
|
|
434
|
+
"item_type": row["item_type"],
|
|
435
|
+
"type": "Chunk" if is_chunk else row["node_type"],
|
|
436
|
+
"title": row["parent_title"]
|
|
437
|
+
if is_chunk and row["parent_title"]
|
|
438
|
+
else row["node_title"],
|
|
439
|
+
"summary": _clean_text(summary or "")[:1000],
|
|
440
|
+
"score": round(float(score), 6),
|
|
441
|
+
"metadata": {
|
|
442
|
+
**(parent_metadata if is_chunk else node_metadata),
|
|
443
|
+
"vector": _safe_loads(row["vector_metadata"]),
|
|
444
|
+
"parent_node_id": row["parent_node_id"],
|
|
445
|
+
"parent_type": row["parent_type"],
|
|
446
|
+
},
|
|
447
|
+
"updated_at": row["parent_updated_at"]
|
|
448
|
+
if is_chunk and row["parent_updated_at"]
|
|
449
|
+
else row["node_updated_at"],
|
|
450
|
+
}
|
|
451
|
+
)
|
|
452
|
+
scored.sort(
|
|
453
|
+
key=lambda item: (item["score"], item.get("updated_at") or ""), reverse=True
|
|
454
|
+
)
|
|
455
|
+
return {
|
|
456
|
+
"query": query,
|
|
457
|
+
"embedding_model": self._embedding_model.model_id,
|
|
458
|
+
"embedding_dim": self._embedding_model.dim,
|
|
459
|
+
"matches": scored[:limit],
|
|
460
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Runtime hooks for the knowledge graph package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
_llm_router_ref: Any = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def set_llm_router(router_instance: Any) -> None:
|
|
11
|
+
global _llm_router_ref
|
|
12
|
+
_llm_router_ref = router_instance
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_llm_router() -> Any:
|
|
16
|
+
return _llm_router_ref
|