ltcai 4.0.0 → 4.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.
Files changed (195) hide show
  1. package/README.md +42 -33
  2. package/desktop/electron/main.cjs +44 -0
  3. package/docs/CHANGELOG.md +106 -0
  4. package/docs/REALTIME_COLLABORATION.md +3 -3
  5. package/docs/V3_FRONTEND.md +9 -8
  6. package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +65 -0
  7. package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +70 -0
  8. package/docs/V4_1_VALIDATION_REPORT.md +47 -0
  9. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +95 -45
  10. package/docs/kg-schema.md +6 -2
  11. package/docs/spec-vs-impl.md +10 -10
  12. package/frontend/index.html +24 -0
  13. package/frontend/openapi.json +14190 -0
  14. package/frontend/src/App.tsx +184 -0
  15. package/frontend/src/api/client.ts +317 -0
  16. package/frontend/src/api/openapi.ts +16637 -0
  17. package/frontend/src/components/primitives.tsx +204 -0
  18. package/frontend/src/components/ui/badge.tsx +27 -0
  19. package/frontend/src/components/ui/button.tsx +37 -0
  20. package/frontend/src/components/ui/card.tsx +22 -0
  21. package/frontend/src/components/ui/input.tsx +16 -0
  22. package/frontend/src/components/ui/textarea.tsx +16 -0
  23. package/frontend/src/lib/utils.ts +33 -0
  24. package/frontend/src/main.tsx +23 -0
  25. package/frontend/src/pages/Act.tsx +245 -0
  26. package/frontend/src/pages/Ask.tsx +200 -0
  27. package/frontend/src/pages/Brain.tsx +267 -0
  28. package/frontend/src/pages/Capture.tsx +158 -0
  29. package/frontend/src/pages/Library.tsx +187 -0
  30. package/frontend/src/pages/System.tsx +344 -0
  31. package/frontend/src/routes.ts +85 -0
  32. package/frontend/src/store/appStore.ts +54 -0
  33. package/frontend/src/styles.css +107 -0
  34. package/kg_schema.py +2 -603
  35. package/knowledge_graph.py +37 -4958
  36. package/latticeai/__init__.py +1 -1
  37. package/latticeai/api/admin.py +15 -16
  38. package/latticeai/api/agents.py +13 -6
  39. package/latticeai/api/auth.py +19 -11
  40. package/latticeai/api/invitations.py +100 -0
  41. package/latticeai/api/knowledge_graph.py +4 -11
  42. package/latticeai/api/plugins.py +3 -6
  43. package/latticeai/api/realtime.py +4 -7
  44. package/latticeai/api/setup.py +5 -4
  45. package/latticeai/api/static_routes.py +13 -16
  46. package/latticeai/api/ui_redirects.py +26 -0
  47. package/latticeai/api/workflow_designer.py +39 -6
  48. package/latticeai/api/workspace.py +24 -10
  49. package/latticeai/app_factory.py +88 -17
  50. package/latticeai/brain/_kg_common.py +1123 -0
  51. package/latticeai/brain/discovery.py +1455 -0
  52. package/latticeai/brain/documents.py +218 -0
  53. package/latticeai/brain/ingest.py +644 -0
  54. package/latticeai/brain/projection.py +561 -0
  55. package/latticeai/brain/provenance.py +401 -0
  56. package/latticeai/brain/retrieval.py +1316 -0
  57. package/latticeai/brain/schema.py +640 -0
  58. package/latticeai/brain/store.py +216 -0
  59. package/latticeai/brain/write_master.py +225 -0
  60. package/latticeai/core/invitations.py +131 -0
  61. package/latticeai/core/marketplace.py +1 -1
  62. package/latticeai/core/multi_agent.py +1 -1
  63. package/latticeai/core/policy.py +54 -0
  64. package/latticeai/core/realtime.py +65 -44
  65. package/latticeai/core/sessions.py +31 -5
  66. package/latticeai/core/users.py +147 -0
  67. package/latticeai/core/workspace_os.py +420 -20
  68. package/latticeai/services/agent_runtime.py +242 -4
  69. package/latticeai/services/run_executor.py +328 -0
  70. package/latticeai/services/workspace_service.py +27 -19
  71. package/package.json +54 -27
  72. package/scripts/build_frontend_assets.mjs +38 -0
  73. package/scripts/bump_version.py +1 -1
  74. package/scripts/export_openapi.py +31 -0
  75. package/scripts/lint_frontend.mjs +86 -0
  76. package/scripts/run_python.mjs +47 -0
  77. package/src-tauri/Cargo.lock +4833 -0
  78. package/src-tauri/Cargo.toml +19 -0
  79. package/src-tauri/build.rs +3 -0
  80. package/src-tauri/capabilities/default.json +7 -0
  81. package/src-tauri/src/main.rs +78 -0
  82. package/src-tauri/tauri.conf.json +36 -0
  83. package/static/app/asset-manifest.json +32 -0
  84. package/static/app/assets/core-CwxXejkd.js +2 -0
  85. package/static/app/assets/core-CwxXejkd.js.map +1 -0
  86. package/static/app/assets/index-CJRAzNnf.js +333 -0
  87. package/static/app/assets/index-CJRAzNnf.js.map +1 -0
  88. package/static/app/assets/index-CSwBBgf4.css +2 -0
  89. package/static/app/index.html +25 -0
  90. package/static/manifest.json +2 -2
  91. package/static/sw.js +4 -4
  92. package/scripts/build_v3_assets.mjs +0 -170
  93. package/scripts/lint_v3.mjs +0 -97
  94. package/static/account.html +0 -113
  95. package/static/activity.html +0 -73
  96. package/static/admin.html +0 -486
  97. package/static/agents.html +0 -139
  98. package/static/chat.html +0 -841
  99. package/static/css/reference/account.css +0 -439
  100. package/static/css/reference/admin.css +0 -610
  101. package/static/css/reference/base.css +0 -1661
  102. package/static/css/reference/chat.css +0 -4623
  103. package/static/css/reference/graph.css +0 -1016
  104. package/static/css/responsive.css +0 -861
  105. package/static/graph.html +0 -122
  106. package/static/platform.css +0 -104
  107. package/static/plugins.html +0 -136
  108. package/static/scripts/account.js +0 -238
  109. package/static/scripts/admin.js +0 -1614
  110. package/static/scripts/chat.js +0 -5081
  111. package/static/scripts/graph.js +0 -1804
  112. package/static/scripts/platform.js +0 -64
  113. package/static/scripts/ux.js +0 -167
  114. package/static/scripts/workspace.js +0 -948
  115. package/static/v3/asset-manifest.json +0 -56
  116. package/static/v3/css/lattice.base.49deefb5.css +0 -128
  117. package/static/v3/css/lattice.base.css +0 -128
  118. package/static/v3/css/lattice.components.cde18231.css +0 -472
  119. package/static/v3/css/lattice.components.css +0 -472
  120. package/static/v3/css/lattice.shell.29d36d85.css +0 -452
  121. package/static/v3/css/lattice.shell.css +0 -452
  122. package/static/v3/css/lattice.tokens.304cbc40.css +0 -135
  123. package/static/v3/css/lattice.tokens.css +0 -135
  124. package/static/v3/css/lattice.views.0a18b6c5.css +0 -360
  125. package/static/v3/css/lattice.views.css +0 -360
  126. package/static/v3/index.html +0 -68
  127. package/static/v3/js/app.356e6452.js +0 -26
  128. package/static/v3/js/app.js +0 -26
  129. package/static/v3/js/core/api.7a308b89.js +0 -568
  130. package/static/v3/js/core/api.js +0 -568
  131. package/static/v3/js/core/components.f25b3b93.js +0 -230
  132. package/static/v3/js/core/components.js +0 -230
  133. package/static/v3/js/core/dom.a2773eb0.js +0 -148
  134. package/static/v3/js/core/dom.js +0 -148
  135. package/static/v3/js/core/router.584570f2.js +0 -37
  136. package/static/v3/js/core/router.js +0 -37
  137. package/static/v3/js/core/routes.7222343d.js +0 -93
  138. package/static/v3/js/core/routes.js +0 -93
  139. package/static/v3/js/core/shell.a1657f20.js +0 -391
  140. package/static/v3/js/core/shell.js +0 -391
  141. package/static/v3/js/core/store.204a08b2.js +0 -113
  142. package/static/v3/js/core/store.js +0 -113
  143. package/static/v3/js/views/admin-audit.660a1fb1.js +0 -185
  144. package/static/v3/js/views/admin-audit.js +0 -185
  145. package/static/v3/js/views/admin-permissions.a7ae5f09.js +0 -177
  146. package/static/v3/js/views/admin-permissions.js +0 -177
  147. package/static/v3/js/views/admin-policies.3658fd86.js +0 -102
  148. package/static/v3/js/views/admin-policies.js +0 -102
  149. package/static/v3/js/views/admin-private-vpc.7d342d36.js +0 -135
  150. package/static/v3/js/views/admin-private-vpc.js +0 -135
  151. package/static/v3/js/views/admin-security.07c66b72.js +0 -180
  152. package/static/v3/js/views/admin-security.js +0 -180
  153. package/static/v3/js/views/admin-users.03bac88c.js +0 -168
  154. package/static/v3/js/views/admin-users.js +0 -168
  155. package/static/v3/js/views/agents.014d0b74.js +0 -541
  156. package/static/v3/js/views/agents.js +0 -541
  157. package/static/v3/js/views/chat.e6dd7dd0.js +0 -601
  158. package/static/v3/js/views/chat.js +0 -601
  159. package/static/v3/js/views/files.adad14c1.js +0 -365
  160. package/static/v3/js/views/files.js +0 -365
  161. package/static/v3/js/views/graph-canvas.17c15d65.js +0 -509
  162. package/static/v3/js/views/graph-canvas.js +0 -509
  163. package/static/v3/js/views/home.24f8b8ae.js +0 -200
  164. package/static/v3/js/views/home.js +0 -200
  165. package/static/v3/js/views/hooks.37895880.js +0 -220
  166. package/static/v3/js/views/hooks.js +0 -220
  167. package/static/v3/js/views/hybrid-search.2fb63ed9.js +0 -194
  168. package/static/v3/js/views/hybrid-search.js +0 -194
  169. package/static/v3/js/views/knowledge-graph.5e40cbeb.js +0 -509
  170. package/static/v3/js/views/knowledge-graph.js +0 -509
  171. package/static/v3/js/views/marketplace.ab0583d4.js +0 -141
  172. package/static/v3/js/views/marketplace.js +0 -141
  173. package/static/v3/js/views/mcp.99b5c6a7.js +0 -114
  174. package/static/v3/js/views/mcp.js +0 -114
  175. package/static/v3/js/views/memory.4ebdf474.js +0 -147
  176. package/static/v3/js/views/memory.js +0 -147
  177. package/static/v3/js/views/models.a1ffa147.js +0 -256
  178. package/static/v3/js/views/models.js +0 -256
  179. package/static/v3/js/views/my-computer.d9d9ae1c.js +0 -463
  180. package/static/v3/js/views/my-computer.js +0 -463
  181. package/static/v3/js/views/pipeline.c522f1ce.js +0 -157
  182. package/static/v3/js/views/pipeline.js +0 -157
  183. package/static/v3/js/views/planning.9ac3e313.js +0 -153
  184. package/static/v3/js/views/planning.js +0 -153
  185. package/static/v3/js/views/settings.8631fa5e.js +0 -318
  186. package/static/v3/js/views/settings.js +0 -318
  187. package/static/v3/js/views/skills.c6c2f965.js +0 -109
  188. package/static/v3/js/views/skills.js +0 -109
  189. package/static/v3/js/views/tools.e4f11276.js +0 -108
  190. package/static/v3/js/views/tools.js +0 -108
  191. package/static/v3/js/views/workflows.26c57290.js +0 -128
  192. package/static/v3/js/views/workflows.js +0 -128
  193. package/static/workflows.html +0 -146
  194. package/static/workspace.css +0 -1121
  195. package/static/workspace.html +0 -357
@@ -0,0 +1,561 @@
1
+ from __future__ import annotations
2
+
3
+ # ruff: noqa: F403,F405
4
+
5
+ from ._kg_common import * # noqa: F403,F401
6
+
7
+
8
+ class KnowledgeGraphProjectionMixin:
9
+ _FTS_SQL = """
10
+ CREATE VIRTUAL TABLE IF NOT EXISTS node_fts USING fts5(
11
+ node_id UNINDEXED, title, summary, metadata, tokenize='trigram'
12
+ );
13
+ CREATE TRIGGER IF NOT EXISTS node_fts_ai AFTER INSERT ON nodes BEGIN
14
+ INSERT INTO node_fts(node_id, title, summary, metadata)
15
+ VALUES (new.id, new.title, COALESCE(new.summary, ''), new.metadata_json);
16
+ END;
17
+ CREATE TRIGGER IF NOT EXISTS node_fts_au AFTER UPDATE ON nodes BEGIN
18
+ DELETE FROM node_fts WHERE node_id = old.id;
19
+ INSERT INTO node_fts(node_id, title, summary, metadata)
20
+ VALUES (new.id, new.title, COALESCE(new.summary, ''), new.metadata_json);
21
+ END;
22
+ CREATE TRIGGER IF NOT EXISTS node_fts_ad AFTER DELETE ON nodes BEGIN
23
+ DELETE FROM node_fts WHERE node_id = old.id;
24
+ END;
25
+ """
26
+
27
+ _V2_VIEWS_SQL = """
28
+ CREATE VIEW IF NOT EXISTS kgv2_nodes AS
29
+ SELECT id,
30
+ COALESCE(legacy_type, type) AS type,
31
+ label AS title,
32
+ summary,
33
+ attrs AS metadata_json,
34
+ created_at, updated_at
35
+ FROM nodes_v2;
36
+ CREATE VIEW IF NOT EXISTS kgv2_edges AS
37
+ SELECT id, source AS from_node, target AS to_node,
38
+ COALESCE(legacy_type, type) AS type,
39
+ weight,
40
+ metadata AS metadata_json,
41
+ created_at
42
+ FROM edges_v2;
43
+ """
44
+
45
+ def _init_fts(self) -> None:
46
+ self._fts_enabled = False
47
+ try:
48
+ with self._connect() as conn:
49
+ conn.executescript(self._FTS_SQL)
50
+ fts_count = conn.execute(
51
+ "SELECT count(*) AS c FROM node_fts"
52
+ ).fetchone()["c"]
53
+ if fts_count == 0:
54
+ conn.execute(
55
+ "INSERT INTO node_fts(node_id, title, summary, metadata) "
56
+ "SELECT id, title, COALESCE(summary, ''), metadata_json FROM nodes"
57
+ )
58
+ self._fts_enabled = True
59
+ except sqlite3.OperationalError as exc:
60
+ # FTS5/trigram not compiled into this SQLite build. LIKE search
61
+ # stays authoritative; the capability is reported, never faked.
62
+ logging.info(
63
+ "FTS5 trigram index unavailable (%s); keyword search uses LIKE scans.",
64
+ exc,
65
+ )
66
+
67
+ def _fts_match_ids(
68
+ self, conn: sqlite3.Connection, query: str, limit: int
69
+ ) -> List[str]:
70
+ """Ranked node ids for a trigram FTS query ('' on any failure)."""
71
+ if not getattr(self, "_fts_enabled", False) or len(query) < 3:
72
+ return []
73
+ escaped = query.replace('"', '""')
74
+ try:
75
+ rows = conn.execute(
76
+ "SELECT node_id FROM node_fts WHERE node_fts MATCH ? ORDER BY rank LIMIT ?",
77
+ (f'"{escaped}"', limit),
78
+ ).fetchall()
79
+ except sqlite3.OperationalError:
80
+ return []
81
+ return [row["node_id"] for row in rows]
82
+
83
+ def _init_v2_schema(self) -> None:
84
+ """Initialize the normalized v2 tables + reconstruction views, migrating
85
+ the projection layout when it is stale — **atomically**.
86
+
87
+ The entire DROP → CREATE → VIEWS → BACKFILL → version-stamp sequence runs
88
+ in a single transaction on one connection: on any failure it rolls back,
89
+ leaving the prior projection untouched and the version unchanged, so the
90
+ next startup simply retries. The migration only ever touches the v2
91
+ tables/views and the ``projection_version`` key — never the authoritative
92
+ legacy ``nodes``/``edges`` — so legacy data cannot be corrupted even if
93
+ the rebuild fails midway.
94
+ """
95
+ if KGStoreV2 is None or _exec_script is None:
96
+ return
97
+ try:
98
+ self._backup_before_v2_flip()
99
+ with self._connect() as conn:
100
+ conn.execute("BEGIN")
101
+ stale = self._projection_version(conn) != _PROJECTION_VERSION
102
+ if stale:
103
+ # The projection is non-authoritative; drop it so init_schema
104
+ # recreates the tables with the current normalized columns.
105
+ for stmt in (
106
+ "DROP VIEW IF EXISTS kgv2_edges",
107
+ "DROP VIEW IF EXISTS kgv2_nodes",
108
+ "DROP TABLE IF EXISTS edges_v2",
109
+ "DROP TABLE IF EXISTS nodes_v2",
110
+ ):
111
+ conn.execute(stmt)
112
+ # init_schema(conn=...) joins this transaction (no implicit commit)
113
+ KGStoreV2(self.db_path).init_schema(conn=conn)
114
+ _exec_script(conn, self._V2_VIEWS_SQL)
115
+ self._backfill_v2_on(conn, force=stale)
116
+ # version stamp commits together with the backfill — never stranded
117
+ conn.execute(
118
+ "INSERT OR REPLACE INTO kg_meta(key, value) VALUES ('projection_version', ?)",
119
+ (str(_PROJECTION_VERSION),),
120
+ )
121
+ mastered_at = _now()
122
+ conn.execute(
123
+ "INSERT OR REPLACE INTO kg_meta(key, value) VALUES (?, ?)",
124
+ (_KG_DB_FORMAT_KEY, str(_KG_DB_FORMAT_VERSION)),
125
+ )
126
+ conn.execute(
127
+ "INSERT OR REPLACE INTO kg_meta(key, value) VALUES (?, COALESCE((SELECT value FROM kg_meta WHERE key=?), ?))",
128
+ (_V2_WRITE_MASTER_KEY, _V2_WRITE_MASTER_KEY, mastered_at),
129
+ )
130
+ conn.execute(f"PRAGMA user_version={_KG_DB_FORMAT_VERSION}")
131
+ except Exception as e:
132
+ logging.warning("knowledge_graph: v2 schema init/backfill skipped: %s", e)
133
+
134
+ def _backup_before_v2_flip(self) -> Optional[str]:
135
+ """Create one local SQLite backup before the v2 write-master flip."""
136
+ if not self.db_path.exists() or self.db_path.stat().st_size == 0:
137
+ return None
138
+ with self._connect() as conn:
139
+ try:
140
+ stamped = conn.execute(
141
+ "SELECT value FROM kg_meta WHERE key=?", (_V2_WRITE_MASTER_KEY,)
142
+ ).fetchone()
143
+ except sqlite3.Error:
144
+ stamped = None
145
+ if stamped:
146
+ return None
147
+ try:
148
+ rows = int(
149
+ conn.execute("SELECT COUNT(*) FROM nodes").fetchone()[0] or 0
150
+ )
151
+ except sqlite3.Error:
152
+ rows = 0
153
+ if rows == 0:
154
+ return None
155
+ conn.execute("PRAGMA wal_checkpoint(FULL)")
156
+ backup_dir = self.db_path.parent / "backups"
157
+ backup_dir.mkdir(parents=True, exist_ok=True)
158
+ stamp = datetime.now().strftime("%Y%m%dT%H%M%S")
159
+ dest = (
160
+ backup_dir / f"{self.db_path.stem}.pre-v2-write-master.{stamp}.sqlite"
161
+ )
162
+ conn.execute("VACUUM INTO ?", (str(dest),))
163
+ return str(dest)
164
+
165
+ def _projection_version(self, conn: sqlite3.Connection) -> int:
166
+ """Return the stored v2 projection layout version (0 if unknown).
167
+
168
+ A fresh DB (kg_meta absent) raises ``sqlite3.OperationalError`` here and
169
+ is correctly treated as version 0 → rebuild. Only sqlite errors are
170
+ swallowed so a real bug doesn't masquerade as a stale projection.
171
+ """
172
+ try:
173
+ row = conn.execute(
174
+ "SELECT value FROM kg_meta WHERE key='projection_version'"
175
+ ).fetchone()
176
+ return int(row["value"]) if row and row["value"] is not None else 0
177
+ except sqlite3.Error:
178
+ return 0
179
+
180
+ def _backfill_v2_if_needed(self, *, force: bool = False) -> None:
181
+ """Project legacy nodes/edges into v2 on a fresh transaction.
182
+
183
+ Thin wrapper around :meth:`_backfill_v2_on` for callers (tests, ad-hoc
184
+ re-sync) that aren't already inside the migration transaction.
185
+ """
186
+ try:
187
+ with self._connect() as conn:
188
+ self._backfill_v2_on(conn, force=force)
189
+ except Exception as ex:
190
+ logging.warning("knowledge_graph: v2 backfill skipped: %s", ex)
191
+
192
+ def _backfill_v2_on(self, conn: sqlite3.Connection, *, force: bool = False) -> None:
193
+ """Project legacy nodes/edges into the normalized v2 tables on ``conn``.
194
+
195
+ Non-destructive to legacy. ``force`` rebuilds unconditionally (used after
196
+ a layout migration); otherwise it only projects when v2 is empty. The v2
197
+ graph is a derived projection, so clearing + rebuilding it is always safe.
198
+ Idempotent: no-ops once v2 carries the current projection. Copies the
199
+ legacy column values **verbatim** so the kgv2_* views are byte-faithful.
200
+ """
201
+ legacy_nodes = conn.execute("SELECT COUNT(*) FROM nodes").fetchone()[0]
202
+ if legacy_nodes == 0:
203
+ return
204
+ v2_nodes = conn.execute("SELECT COUNT(*) FROM nodes_v2").fetchone()[0]
205
+ if v2_nodes > 0 and not force:
206
+ return # current projection already present
207
+ # (re)project: clear v2 graph (not authoritative) and rebuild
208
+ conn.execute("DELETE FROM edges_v2")
209
+ conn.execute("DELETE FROM nodes_v2")
210
+ n = e = 0
211
+ for r in conn.execute(
212
+ "SELECT id, type, title, summary, metadata_json, created_at, updated_at FROM nodes"
213
+ ).fetchall():
214
+ self._v2_project_node(
215
+ conn,
216
+ r["id"],
217
+ r["type"],
218
+ r["title"],
219
+ r["summary"],
220
+ r["metadata_json"],
221
+ created_at=r["created_at"],
222
+ updated_at=r["updated_at"],
223
+ )
224
+ n += 1
225
+ for r in conn.execute(
226
+ "SELECT id, from_node, to_node, type, weight, metadata_json, created_at FROM edges"
227
+ ).fetchall():
228
+ self._v2_project_edge(
229
+ conn,
230
+ r["from_node"],
231
+ r["to_node"],
232
+ r["type"],
233
+ float(r["weight"] or 1.0),
234
+ r["metadata_json"],
235
+ edge_id=r["id"],
236
+ created_at=r["created_at"],
237
+ )
238
+ e += 1
239
+ logging.info(
240
+ "knowledge_graph: projected legacy → v2 (%d nodes, %d edges)", n, e
241
+ )
242
+
243
+ def _v2_project_node(
244
+ self,
245
+ conn: sqlite3.Connection,
246
+ node_id: str,
247
+ node_type: str,
248
+ title: str,
249
+ summary: Optional[str],
250
+ metadata_json: Optional[str],
251
+ *,
252
+ created_at: Optional[str] = None,
253
+ updated_at: Optional[str] = None,
254
+ owner: Optional[str] = None,
255
+ workspace_id: Optional[str] = None,
256
+ visibility: Optional[str] = None,
257
+ strict: bool = False,
258
+ ) -> None:
259
+ if KGStoreV2 is None:
260
+ if strict:
261
+ raise RuntimeError("Knowledge Graph v2 schema is unavailable")
262
+ return
263
+ ts = updated_at or _now()
264
+ norm_type = (
265
+ NodeType.from_legacy(node_type).value if NodeType is not None else node_type
266
+ )
267
+ # Scope resolution: explicit param > metadata hints > legacy-global.
268
+ # 'legacy' (not 'private') marks unscoped rows — the column default
269
+ # must never silently privatize previously machine-shared data.
270
+ meta = _safe_loads(metadata_json) if metadata_json else {}
271
+ owner = owner or meta.get("user_email") or meta.get("owner") or None
272
+ workspace_id = workspace_id or meta.get("workspace_id") or None
273
+ visibility = visibility or ("legacy" if workspace_id is None else "workspace")
274
+ try:
275
+ conn.execute(
276
+ """
277
+ INSERT INTO nodes_v2(id, type, legacy_type, label, summary, attrs,
278
+ owner_id, workspace_id, visibility,
279
+ created_at, updated_at, importance_score)
280
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0.0)
281
+ ON CONFLICT(id) DO UPDATE SET
282
+ type=excluded.type, legacy_type=excluded.legacy_type,
283
+ label=excluded.label, summary=excluded.summary,
284
+ attrs=excluded.attrs, updated_at=excluded.updated_at,
285
+ owner_id=COALESCE(excluded.owner_id, nodes_v2.owner_id),
286
+ workspace_id=COALESCE(excluded.workspace_id, nodes_v2.workspace_id),
287
+ visibility=CASE WHEN excluded.visibility != 'legacy'
288
+ THEN excluded.visibility
289
+ ELSE nodes_v2.visibility END
290
+ """,
291
+ (
292
+ node_id,
293
+ norm_type,
294
+ node_type,
295
+ title,
296
+ summary,
297
+ metadata_json if metadata_json is not None else "{}",
298
+ owner,
299
+ workspace_id,
300
+ visibility,
301
+ created_at or ts,
302
+ ts,
303
+ ),
304
+ )
305
+ except Exception as ex:
306
+ if strict:
307
+ raise
308
+ logging.debug(
309
+ "knowledge_graph: v2 node projection skipped (%s): %s", node_id, ex
310
+ )
311
+
312
+ def _v2_project_edge(
313
+ self,
314
+ conn: sqlite3.Connection,
315
+ from_node: str,
316
+ to_node: str,
317
+ edge_type: str,
318
+ weight: float,
319
+ metadata_json: Optional[str],
320
+ *,
321
+ edge_id: Optional[str] = None,
322
+ created_at: Optional[str] = None,
323
+ strict: bool = False,
324
+ ) -> None:
325
+ if KGStoreV2 is None:
326
+ if strict:
327
+ raise RuntimeError("Knowledge Graph v2 schema is unavailable")
328
+ return
329
+ eid = (
330
+ edge_id or f"edge:{_sha256_text(f'{from_node}|{edge_type}|{to_node}')[:24]}"
331
+ )
332
+ norm_type = (
333
+ EdgeType.from_legacy(edge_type).value if EdgeType is not None else edge_type
334
+ )
335
+ meta_str = metadata_json if metadata_json is not None else "{}"
336
+ confidence = float(_safe_loads(meta_str).get("confidence", 1.0))
337
+ try:
338
+ conn.execute(
339
+ """
340
+ INSERT INTO edges_v2(id, source, target, type, legacy_type, weight,
341
+ confidence, evidence, metadata, created_by, created_at)
342
+ VALUES (?, ?, ?, ?, ?, ?, ?, '[]', ?, 'legacy', ?)
343
+ ON CONFLICT(source, target, type, legacy_type) DO UPDATE SET
344
+ weight=max(edges_v2.weight, excluded.weight),
345
+ confidence=excluded.confidence,
346
+ metadata=excluded.metadata
347
+ """,
348
+ (
349
+ eid,
350
+ from_node,
351
+ to_node,
352
+ norm_type,
353
+ edge_type,
354
+ float(weight),
355
+ confidence,
356
+ meta_str,
357
+ created_at or _now(),
358
+ ),
359
+ )
360
+ # Temporal record: every observation of this relationship is kept
361
+ # (the UNIQUE upsert + weight=max alone would erase recurrence).
362
+ row = conn.execute(
363
+ "SELECT id FROM edges_v2 WHERE source=? AND target=? AND type=? AND legacy_type=?",
364
+ (from_node, to_node, norm_type, edge_type),
365
+ ).fetchone()
366
+ if row is not None:
367
+ conn.execute(
368
+ "INSERT INTO edge_occurrences(edge_id, observed_at, weight, source) VALUES (?, ?, ?, ?)",
369
+ (
370
+ row["id"],
371
+ created_at or _now(),
372
+ float(weight),
373
+ _safe_loads(meta_str).get("source"),
374
+ ),
375
+ )
376
+ except Exception as ex:
377
+ if strict:
378
+ raise
379
+ logging.debug(
380
+ "knowledge_graph: v2 edge projection skipped (%s->%s): %s",
381
+ from_node,
382
+ to_node,
383
+ ex,
384
+ )
385
+
386
+ def curate(
387
+ self, *, max_documents: int = 200, max_new_nodes: int = 8
388
+ ) -> Dict[str, Any]:
389
+ """On-demand graph curation (T4.4 — graph_curator goes live).
390
+
391
+ Runs the curator's gated topic-promotion pipeline over recent content
392
+ nodes: candidates are clustered, secret-bearing labels are refused,
393
+ and only multi-source topics above the importance threshold become
394
+ Topic nodes (with MENTIONS edges back to their sources and a real
395
+ importance_score in nodes_v2). Explicit and observable — the result
396
+ reports everything promoted AND everything skipped, with reasons.
397
+ """
398
+ from latticeai.core.graph_curator import auto_build_graph_overlay
399
+
400
+ content_types = (
401
+ "Document",
402
+ "File",
403
+ "CodeFile",
404
+ "Message",
405
+ "AIResponse",
406
+ "Chat",
407
+ "Page",
408
+ "Slide",
409
+ "Spreadsheet",
410
+ )
411
+ nt, _ = self._read_tables()
412
+ with self._connect() as conn:
413
+ placeholders = ",".join("?" for _ in content_types)
414
+ rows = conn.execute(
415
+ f"""
416
+ SELECT id, type, title, summary FROM {nt}
417
+ WHERE type IN ({placeholders})
418
+ ORDER BY updated_at DESC, id ASC LIMIT ?
419
+ """,
420
+ (*content_types, max(1, min(int(max_documents), 2000))),
421
+ ).fetchall()
422
+ existing_labels = {
423
+ str(row["title"] or "").strip().lower()
424
+ for row in conn.execute(
425
+ f"SELECT title FROM {nt} WHERE type IN ('Topic', 'Concept')"
426
+ ).fetchall()
427
+ }
428
+ documents = [
429
+ {
430
+ "id": row["id"],
431
+ "text": f"{row['title']} {row['summary'] or ''}",
432
+ "kind": "file"
433
+ if row["type"] in {"Document", "File", "CodeFile", "Spreadsheet"}
434
+ else "chat",
435
+ }
436
+ for row in rows
437
+ ]
438
+ overlay = auto_build_graph_overlay(
439
+ documents,
440
+ existing_node_labels=existing_labels,
441
+ max_new_nodes=max(1, min(int(max_new_nodes), 50)),
442
+ )
443
+ promoted: List[Dict[str, Any]] = []
444
+ with self._connect() as conn:
445
+ valid_ids = {row["id"] for row in rows}
446
+ for promo in overlay["promotions"]:
447
+ topic_id = f"topic:{_slug(promo['label'])}"
448
+ self._upsert_node(
449
+ conn,
450
+ topic_id,
451
+ "Topic",
452
+ promo["label"],
453
+ metadata={
454
+ "curated": True,
455
+ "importance": promo["importance"],
456
+ "aliases": promo["aliases"],
457
+ "source": "graph_curator",
458
+ },
459
+ )
460
+ conn.execute(
461
+ "UPDATE nodes_v2 SET importance_score=? WHERE id=?",
462
+ (float(promo["importance"]), topic_id),
463
+ )
464
+ linked = 0
465
+ for source_id in promo["sources"][:10]:
466
+ if source_id in valid_ids:
467
+ self._upsert_edge(
468
+ conn,
469
+ source_id,
470
+ topic_id,
471
+ "MENTIONS",
472
+ weight=0.6,
473
+ metadata={"source": "graph_curator"},
474
+ )
475
+ linked += 1
476
+ promoted.append(
477
+ {
478
+ "node_id": topic_id,
479
+ "label": promo["label"],
480
+ "importance": promo["importance"],
481
+ "linked_sources": linked,
482
+ }
483
+ )
484
+ return {
485
+ "status": "ok",
486
+ "documents_scanned": len(documents),
487
+ "candidates_total": overlay["candidates_total"],
488
+ "promoted": promoted,
489
+ "skipped": overlay["skipped"][:50],
490
+ "skipped_total": len(overlay["skipped"]),
491
+ }
492
+
493
+ def mark_superseded(self, old_node_id: str, new_node_id: str) -> Dict[str, Any]:
494
+ """Record that ``old_node_id`` was replaced by ``new_node_id``.
495
+
496
+ The old node stays queryable (knowledge is durable); readers can follow
497
+ the revision chain via ``nodes_v2.superseded_by``.
498
+ """
499
+ with self._connect() as conn:
500
+ for node_id in (old_node_id, new_node_id):
501
+ exists = conn.execute(
502
+ "SELECT 1 FROM nodes_v2 WHERE id=?", (node_id,)
503
+ ).fetchone()
504
+ if not exists:
505
+ raise FileNotFoundError(node_id)
506
+ conn.execute(
507
+ "UPDATE nodes_v2 SET superseded_by=?, updated_at=? WHERE id=?",
508
+ (new_node_id, _now(), old_node_id),
509
+ )
510
+ return {"status": "ok", "node_id": old_node_id, "superseded_by": new_node_id}
511
+
512
+ def _v2_delete_nodes(self, conn: sqlite3.Connection, ids) -> None:
513
+ """Mirror legacy node deletions into v2 (edges_v2 cascade on the FK)."""
514
+ if KGStoreV2 is None:
515
+ return
516
+ ids = list(ids)
517
+ if not ids:
518
+ return
519
+ ph = ",".join("?" * len(ids))
520
+ try:
521
+ conn.execute(f"DELETE FROM nodes_v2 WHERE id IN ({ph})", ids)
522
+ except Exception as ex:
523
+ logging.debug("knowledge_graph: v2 node delete mirror skipped: %s", ex)
524
+
525
+ def _v2_delete_edges_from(self, conn: sqlite3.Connection, node_id: str) -> None:
526
+ """Mirror a legacy ``DELETE FROM edges WHERE from_node=?`` into v2."""
527
+ if KGStoreV2 is None:
528
+ return
529
+ try:
530
+ conn.execute("DELETE FROM edges_v2 WHERE source=?", (node_id,))
531
+ except Exception as ex:
532
+ logging.debug("knowledge_graph: v2 edge delete mirror skipped: %s", ex)
533
+
534
+ def _v2_sync_report(self) -> Dict[str, Any]:
535
+ """Diagnose the dual-write invariant: legacy node/edge id sets must equal
536
+ the v2 projection's. Returns counts + any drift (ids missing from / extra
537
+ in v2). ``in_sync`` is True only when both id sets match exactly.
538
+
539
+ All legacy writes go through _upsert_node/_upsert_edge (which dual-write)
540
+ and every legacy delete is mirrored, so a non-empty drift signals a
541
+ bypassed write path — this is the runtime guard for that invariant.
542
+ """
543
+ if KGStoreV2 is None:
544
+ return {"available": False, "in_sync": True}
545
+ with self._connect() as conn:
546
+ legacy_nodes = {r[0] for r in conn.execute("SELECT id FROM nodes")}
547
+ v2_nodes = {r[0] for r in conn.execute("SELECT id FROM nodes_v2")}
548
+ legacy_edges = {r[0] for r in conn.execute("SELECT id FROM edges")}
549
+ v2_edges = {r[0] for r in conn.execute("SELECT id FROM edges_v2")}
550
+ return {
551
+ "available": True,
552
+ "in_sync": legacy_nodes == v2_nodes and legacy_edges == v2_edges,
553
+ "nodes_legacy": len(legacy_nodes),
554
+ "nodes_v2": len(v2_nodes),
555
+ "edges_legacy": len(legacy_edges),
556
+ "edges_v2": len(v2_edges),
557
+ "nodes_missing_from_v2": sorted(legacy_nodes - v2_nodes),
558
+ "nodes_extra_in_v2": sorted(v2_nodes - legacy_nodes),
559
+ "edges_missing_from_v2": sorted(legacy_edges - v2_edges),
560
+ "edges_extra_in_v2": sorted(v2_edges - legacy_edges),
561
+ }