secondbrain-py 0.2.1__py3-none-any.whl

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 (273) hide show
  1. brain/__init__.py +0 -0
  2. brain/__main__.py +18 -0
  3. brain/_capture_command.py +445 -0
  4. brain/_compose.py +52 -0
  5. brain/activity.py +206 -0
  6. brain/ask.py +631 -0
  7. brain/audio.py +591 -0
  8. brain/backfill/__init__.py +12 -0
  9. brain/backfill/search_extras.py +141 -0
  10. brain/backfill/source_rows.py +101 -0
  11. brain/bin/__init__.py +1 -0
  12. brain/bin/_launcher.py +107 -0
  13. brain/bin/down.py +8 -0
  14. brain/bin/launchd.py +268 -0
  15. brain/bin/monitor.py +570 -0
  16. brain/bin/rebuild.py +8 -0
  17. brain/bin/status.py +8 -0
  18. brain/bin/up.py +8 -0
  19. brain/brief.py +272 -0
  20. brain/capture.py +49 -0
  21. brain/chat.py +293 -0
  22. brain/cli.py +9760 -0
  23. brain/cli_claude.py +81 -0
  24. brain/cli_connect.py +285 -0
  25. brain/cli_demo.py +266 -0
  26. brain/config.py +1949 -0
  27. brain/connect.py +925 -0
  28. brain/db.py +540 -0
  29. brain/demo/__init__.py +452 -0
  30. brain/demo/corpus/manifest.json +403 -0
  31. brain/demo/embedder.py +74 -0
  32. brain/durations.py +84 -0
  33. brain/edit_session.py +156 -0
  34. brain/editor.py +67 -0
  35. brain/elicit/__init__.py +16 -0
  36. brain/elicit/detectors.py +250 -0
  37. brain/elicit/drafter.py +70 -0
  38. brain/elicit/queue.py +220 -0
  39. brain/elicit/schema.py +48 -0
  40. brain/elicit/session.py +445 -0
  41. brain/embedding_targets.py +54 -0
  42. brain/embeddings.py +424 -0
  43. brain/enrichment.py +808 -0
  44. brain/errors.py +357 -0
  45. brain/eval/__init__.py +129 -0
  46. brain/eval/answer_eval.py +281 -0
  47. brain/eval/baseline.py +265 -0
  48. brain/eval/concept_extraction.py +378 -0
  49. brain/eval/corpus.py +152 -0
  50. brain/eval/errors.py +19 -0
  51. brain/eval/graph_baseline.py +226 -0
  52. brain/eval/graph_retrieval.py +202 -0
  53. brain/eval/graph_runner.py +319 -0
  54. brain/eval/metrics.py +101 -0
  55. brain/eval/runner.py +223 -0
  56. brain/format.py +783 -0
  57. brain/gaps.py +390 -0
  58. brain/graph_rag/__init__.py +94 -0
  59. brain/graph_rag/_retrieval_common.py +113 -0
  60. brain/graph_rag/aggregates.py +303 -0
  61. brain/graph_rag/aliases/__init__.py +583 -0
  62. brain/graph_rag/backends/__init__.py +10 -0
  63. brain/graph_rag/backends/_age_helpers.py +473 -0
  64. brain/graph_rag/backends/age.py +782 -0
  65. brain/graph_rag/backends/base.py +272 -0
  66. brain/graph_rag/build.py +344 -0
  67. brain/graph_rag/communities.py +644 -0
  68. brain/graph_rag/communities_summary.py +437 -0
  69. brain/graph_rag/concepts.py +202 -0
  70. brain/graph_rag/cooccur.py +193 -0
  71. brain/graph_rag/cross_type.py +312 -0
  72. brain/graph_rag/extract.py +885 -0
  73. brain/graph_rag/fuse.py +371 -0
  74. brain/graph_rag/global_.py +412 -0
  75. brain/graph_rag/grouping.py +372 -0
  76. brain/graph_rag/person_resolver.py +167 -0
  77. brain/graph_rag/reconcile.py +792 -0
  78. brain/graph_rag/relational.py +353 -0
  79. brain/graph_rag/retrieve.py +526 -0
  80. brain/graph_rag/router.py +288 -0
  81. brain/graph_rag/schema.py +320 -0
  82. brain/graph_rag/sync.py +237 -0
  83. brain/graph_rag/tenancy.py +43 -0
  84. brain/graph_rag/themes.py +501 -0
  85. brain/graph_rag/weighting.py +202 -0
  86. brain/ingest/__init__.py +1926 -0
  87. brain/ingest/chunker.py +249 -0
  88. brain/ingest/docx.py +40 -0
  89. brain/ingest/gmail.py +621 -0
  90. brain/ingest/markdown.py +37 -0
  91. brain/ingest/pdf.py +61 -0
  92. brain/ingest/stdin.py +22 -0
  93. brain/ingest/sub_tokens.py +91 -0
  94. brain/ingest/text.py +16 -0
  95. brain/interactions.py +205 -0
  96. brain/maintenance.py +355 -0
  97. brain/mcp_server.py +3405 -0
  98. brain/migrations/001_init.sql +43 -0
  99. brain/migrations/002_qwen3_embedding.sql +17 -0
  100. brain/migrations/003_vault_model.sql +41 -0
  101. brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
  102. brain/migrations/005_derived_links.sql +67 -0
  103. brain/migrations/006_dedup_file_by_source_path.sql +25 -0
  104. brain/migrations/007_email_thread_and_draft.sql +15 -0
  105. brain/migrations/008_gmail_thread_unique.sql +11 -0
  106. brain/migrations/009_chunks_weighted_tsv.sql +28 -0
  107. brain/migrations/010_interactions.sql +30 -0
  108. brain/migrations/011_documents_summary.sql +23 -0
  109. brain/migrations/012_graphrag.sql +171 -0
  110. brain/migrations/013_graphrag_communities.sql +125 -0
  111. brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
  112. brain/migrations/015_interactions_graph_targets.sql +89 -0
  113. brain/migrations/016_index_hygiene.sql +61 -0
  114. brain/migrations/017_elicit.sql +30 -0
  115. brain/migrations/018_review_gap_signal_kinds.sql +40 -0
  116. brain/migrations/019_search_queries.sql +35 -0
  117. brain/migrations/020_link_suggestions.sql +40 -0
  118. brain/migrations/021_timeline_doc_date.sql +34 -0
  119. brain/migrations/022_link_suggestions_undirected.sql +84 -0
  120. brain/migrations/023_search_queries_fts_count.sql +28 -0
  121. brain/quartz_overrides/__init__.py +8 -0
  122. brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
  123. brain/quartz_overrides/quartz/build.ts +568 -0
  124. brain/quartz_overrides/quartz/cli/args.js +152 -0
  125. brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
  126. brain/quartz_overrides/quartz/cli/handlers.js +636 -0
  127. brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
  128. brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
  129. brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
  130. brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
  131. brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
  132. brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
  133. brain/quartz_overrides/quartz/components/Search.tsx +161 -0
  134. brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
  135. brain/quartz_overrides/quartz/components/index.ts +92 -0
  136. brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
  137. brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
  138. brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
  139. brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
  140. brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
  141. brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
  142. brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
  143. brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
  144. brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
  145. brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
  146. brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
  147. brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
  148. brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
  149. brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
  150. brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
  151. brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
  152. brain/quartz_overrides/quartz/processors/parse.ts +371 -0
  153. brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
  154. brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
  155. brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
  156. brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
  157. brain/quartz_overrides/quartz/static/emailThread.js +334 -0
  158. brain/quartz_overrides/quartz/static/favicon.ico +0 -0
  159. brain/quartz_overrides/quartz/static/icon.png +0 -0
  160. brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
  161. brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
  162. brain/quartz_overrides/quartz/static/reload.js +168 -0
  163. brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
  164. brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
  165. brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
  166. brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
  167. brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
  168. brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
  169. brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
  170. brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
  171. brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
  172. brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
  173. brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
  174. brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
  175. brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
  176. brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
  177. brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
  178. brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
  179. brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
  180. brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
  181. brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
  182. brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
  183. brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
  184. brain/quartz_overrides/quartz/styles/custom.scss +89 -0
  185. brain/quartz_overrides/quartz/styles/graph.scss +505 -0
  186. brain/quartz_overrides/quartz/util/ctx.ts +92 -0
  187. brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
  188. brain/quartz_overrides/quartz/util/path.ts +358 -0
  189. brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
  190. brain/quartz_overrides/quartz.config.ts +270 -0
  191. brain/quartz_overrides/quartz.layout.ts +314 -0
  192. brain/queries.py +1188 -0
  193. brain/rank_fusion.py +8 -0
  194. brain/resurface.py +210 -0
  195. brain/review/__init__.py +26 -0
  196. brain/review/emit.py +27 -0
  197. brain/review/queries.py +436 -0
  198. brain/review/render.py +196 -0
  199. brain/review/scans.py +355 -0
  200. brain/review/weekly.py +413 -0
  201. brain/search.py +704 -0
  202. brain/set_similarity.py +15 -0
  203. brain/setup.py +1205 -0
  204. brain/tags.py +56 -0
  205. brain/templates/Caddyfile.j2 +9 -0
  206. brain/templates/__init__.py +1 -0
  207. brain/templates/bin/__init__.py +1 -0
  208. brain/templates/bin/_brain-brief-fg.sh +25 -0
  209. brain/templates/bin/_brain-build-fg.sh +53 -0
  210. brain/templates/bin/_brain-watcher-fg.sh +65 -0
  211. brain/templates/bin/brain-down.sh +89 -0
  212. brain/templates/bin/brain-status.sh +83 -0
  213. brain/templates/bin/brain-up.sh +221 -0
  214. brain/templates/docker/age/Dockerfile +79 -0
  215. brain/templates/docker-compose.stock.yml.j2 +26 -0
  216. brain/templates/docker-compose.yml.j2 +34 -0
  217. brain/templates/env.example +190 -0
  218. brain/templates/launchd/__init__.py +1 -0
  219. brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
  220. brain/templates/launchd/com.brain.build.plist.j2 +46 -0
  221. brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
  222. brain/templates/skill/SKILL.md +63 -0
  223. brain/templates/skill/__init__.py +1 -0
  224. brain/timeline.py +834 -0
  225. brain/todo.py +124 -0
  226. brain/uninstall.py +185 -0
  227. brain/vault/__init__.py +115 -0
  228. brain/vault/_atomic.py +25 -0
  229. brain/vault/daily_index.py +228 -0
  230. brain/vault/derived_links/__init__.py +50 -0
  231. brain/vault/derived_links/directory.py +683 -0
  232. brain/vault/derived_links/fence.py +408 -0
  233. brain/vault/derived_links/gws.py +64 -0
  234. brain/vault/derived_links/participants.py +143 -0
  235. brain/vault/derived_links/pass_runner.py +362 -0
  236. brain/vault/derived_links/rules.py +137 -0
  237. brain/vault/export.py +683 -0
  238. brain/vault/frontmatter.py +165 -0
  239. brain/vault/graph.py +620 -0
  240. brain/vault/graph_format.py +388 -0
  241. brain/vault/link_rewrite.py +235 -0
  242. brain/vault/links.py +260 -0
  243. brain/vault/note_builder.py +211 -0
  244. brain/vault/paths.py +55 -0
  245. brain/vault/quartz_overlay.py +236 -0
  246. brain/vault/rename.py +591 -0
  247. brain/vault/resolver.py +304 -0
  248. brain/vault/slug.py +127 -0
  249. brain/vault/sync.py +1513 -0
  250. brain/vault/sync_summaries.py +264 -0
  251. brain/vault/templates.py +145 -0
  252. brain/vault/watch.py +1052 -0
  253. brain/wiki/__init__.py +6 -0
  254. brain/wiki/_github_slugger.py +76 -0
  255. brain/wiki/_person_name.py +314 -0
  256. brain/wiki/build_homepage.py +541 -0
  257. brain/wiki/build_partial.py +273 -0
  258. brain/wiki/build_people.py +934 -0
  259. brain/wiki/build_related.py +758 -0
  260. brain/wiki/build_swap.py +585 -0
  261. brain/wiki/build_watcher.py +975 -0
  262. brain/wiki/edit_classifier.py +215 -0
  263. brain/wiki/errors.py +10 -0
  264. brain/wiki/fastpath_manifest.py +475 -0
  265. brain/wiki/fastpath_state.py +174 -0
  266. brain/wiki/install.py +296 -0
  267. brain/wiki/slug.py +111 -0
  268. secondbrain_py-0.2.1.dist-info/METADATA +195 -0
  269. secondbrain_py-0.2.1.dist-info/RECORD +273 -0
  270. secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
  271. secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
  272. secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
  273. secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,272 @@
1
+ """Storage/traversal backend Protocol for the GraphRAG layer (spec §4 D10).
2
+
3
+ A :class:`GraphBackend` is the narrow, tenant-scoped interface the rest of the
4
+ GraphRAG layer (reconcile in G1, retrieval in G2) depends on. The default
5
+ implementation is :class:`brain.graph_rag.backends.age.AgeBackend` (Apache AGE
6
+ inside Postgres, openCypher). Keeping the surface narrow is what makes the
7
+ Neo4j/Memgraph kill-switch (spec §16) a drop-in replacement: a second backend
8
+ conforms to this Protocol and the callers do not change.
9
+
10
+ Every method is **tenant-scoped** — ``tenant_id`` is a required argument on each
11
+ one (spec §4 D9). AGE has no native tenant isolation, so the AGE implementation
12
+ enforces the tenant on every matched vertex *and* edge; a backend that cannot
13
+ make that guarantee must not claim conformance.
14
+
15
+ The graph mirrors the relational source-of-truth (migration 012); the durable
16
+ app identity of an entity is its ``graph_entities.id`` UUID, carried on the AGE
17
+ vertex as the ``entity_uuid`` property. Backend methods speak ``entity_uuid`` /
18
+ ``document_uuid`` (both equal the relational UUIDs), never AGE-internal IDs.
19
+
20
+ This module defines only the Protocol and its return value objects; it imports
21
+ no DB driver beyond the connection type, so it stays a pure contract that fakes
22
+ can satisfy in unit tests.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Mapping, Sequence
27
+ from dataclasses import dataclass
28
+ from typing import Any, Protocol, runtime_checkable
29
+
30
+ import psycopg
31
+
32
+ from ..schema import EntityMention, GraphEntity
33
+
34
+ __all__ = ["GraphBackend", "PersonScope", "TraversalHit"]
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class TraversalHit:
39
+ """One entity reached by :meth:`GraphBackend.traverse`, with its best path.
40
+
41
+ ``entity_uuid`` is the reached entity's durable id (= ``graph_entities.id``).
42
+ ``affinity`` is the product of the winning path's edge weights
43
+ (normalized lift ∈ (0, 1]; a 1-hop neighbour's affinity is just that edge's
44
+ weight). ``hops`` is the length of the winning (highest-affinity) path. The
45
+ seed itself is never returned. ``tenant_id`` records the scope the traversal
46
+ ran under — every element of the path was verified to carry it.
47
+ """
48
+
49
+ entity_uuid: str
50
+ affinity: float
51
+ hops: int
52
+ tenant_id: str = "default"
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class PersonScope:
57
+ """The scoped subgraph for "themes with X" expansion (spec §6b step 1).
58
+
59
+ Produced by :meth:`GraphBackend.scope_person`: starting from a seed person,
60
+ follow ``MENTIONED_IN`` into the person's documents and back out to the
61
+ entities co-mentioned in those documents. ``entity_uuids`` is the distinct
62
+ set of co-mentioned entities (the seed excluded); ``document_uuids`` is the
63
+ distinct set of connecting documents. Both are tenant-scoped. The grouping
64
+ stage (G2) consumes this set; SQL provides the lift weights and evidence.
65
+ """
66
+
67
+ seed_entity_uuid: str
68
+ entity_uuids: tuple[str, ...]
69
+ document_uuids: tuple[str, ...]
70
+ tenant_id: str = "default"
71
+
72
+
73
+ @runtime_checkable
74
+ class GraphBackend(Protocol):
75
+ """Tenant-scoped graph storage + bounded traversal (spec §4 D10, §8).
76
+
77
+ Implementations own a single named graph and translate these calls into
78
+ their engine's native operations. All write methods are idempotent: calling
79
+ them twice with the same arguments converges to the same graph state (the
80
+ relational source-of-truth is authoritative; the graph is a recomputable
81
+ mirror, spec §7).
82
+
83
+ Connection contract: every method takes the caller's ``conn`` so the caller
84
+ owns the transaction (reconcile in G1 wraps the relational rewrite and the
85
+ graph sync in one unit). The connection MUST already have the engine's
86
+ session bootstrap applied (for AGE: ``LOAD 'age'`` via
87
+ :func:`brain.db.connect_age` / :func:`brain.db.load_age`). :meth:`bootstrap`
88
+ additionally requires an autocommit connection (graph/label DDL).
89
+ """
90
+
91
+ def bootstrap(self, conn: psycopg.Connection[Any]) -> None:
92
+ """Idempotently provision the graph, its labels, and property indexes.
93
+
94
+ Ensures the graph exists, creates the vertex labels (``Entity``,
95
+ ``Document``) and edge labels (``MENTIONED_IN``, ``CO_OCCURS``), and
96
+ builds the property indexes (spec §5b). Safe to re-run. Requires an
97
+ autocommit connection (catalog DDL).
98
+ """
99
+ ...
100
+
101
+ def upsert_entities(
102
+ self,
103
+ conn: psycopg.Connection[Any],
104
+ tenant_id: str,
105
+ entities: Sequence[GraphEntity],
106
+ ) -> int:
107
+ """MERGE ``Entity`` vertices keyed on ``(tenant_id, entity_uuid)``.
108
+
109
+ Creates missing vertices and updates the mutable properties
110
+ (``name``/``entity_type``/``canonical_key``) of existing ones.
111
+ Embeddings stay relational (spec §5) and are never written here. Returns
112
+ the number of entities processed.
113
+ """
114
+ ...
115
+
116
+ def upsert_mention_edges(
117
+ self,
118
+ conn: psycopg.Connection[Any],
119
+ tenant_id: str,
120
+ document_id: str,
121
+ mentions: Sequence[EntityMention],
122
+ *,
123
+ document_props: Mapping[str, Any] | None = None,
124
+ ) -> int:
125
+ """Rebuild one document's ``MENTIONED_IN`` edges (split MERGE; spec §7.3).
126
+
127
+ MERGEs the ``Document`` vertex and each mentioned ``Entity`` vertex on
128
+ their own, then deletes and recreates this document's ``MENTIONED_IN``
129
+ edges. Never MERGEs the whole ``(e)-[:MENTIONED_IN]->(d)`` pattern in
130
+ one clause — AGE re-creates the endpoint vertices when it does. Returns
131
+ the number of mention edges written.
132
+ """
133
+ ...
134
+
135
+ def refresh_cooccur_edges(
136
+ self,
137
+ conn: psycopg.Connection[Any],
138
+ tenant_id: str,
139
+ ) -> int:
140
+ """Rematerialize the tenant's ``CO_OCCURS`` edges from the relational mirror.
141
+
142
+ Deletes the tenant's existing ``CO_OCCURS`` edges and recreates them
143
+ from ``graph_relationships`` (the derived aggregate mirror, recomputed
144
+ from contributions upstream; spec §5/§7). Full recompute is correct
145
+ because aggregates derive from the source-of-truth. Returns the number
146
+ of aggregate edges written.
147
+ """
148
+ ...
149
+
150
+ def traverse(
151
+ self,
152
+ conn: psycopg.Connection[Any],
153
+ tenant_id: str,
154
+ seed_entity_uuid: str,
155
+ *,
156
+ depth: int,
157
+ frontier_cap: int,
158
+ min_edge_weight: float = 0.0,
159
+ ) -> list[TraversalHit]:
160
+ """Bounded variable-length ``CO_OCCURS`` traversal from a seed (spec §6a).
161
+
162
+ Walks ``CO_OCCURS`` paths of length 1..``depth`` from the seed, enforces
163
+ ``tenant_id`` on every vertex and edge, scores each reached entity by
164
+ path affinity (product of edge weights) keeping the best path per
165
+ entity, drops paths with any edge below ``min_edge_weight``, and caps
166
+ the frontier. The seed itself is never returned. Returns hits ordered by
167
+ affinity descending, ties broken deterministically.
168
+
169
+ **Complete-or-failure contract:** the cap is applied *after* affinity
170
+ scoring, so an implementation must score every within-depth path rather
171
+ than truncate before scoring. To stay bounded it MUST detect when more
172
+ paths exist than it can safely score and raise :class:`GraphBackendError`
173
+ rather than returning a silently-truncated (possibly wrong) result — a
174
+ result is always either complete/correct or a loud failure.
175
+ """
176
+ ...
177
+
178
+ def scope_person(
179
+ self,
180
+ conn: psycopg.Connection[Any],
181
+ tenant_id: str,
182
+ seed_entity_uuid: str,
183
+ *,
184
+ frontier_cap: int,
185
+ ) -> PersonScope:
186
+ """Scope a person to co-mentioned entities + documents (spec §6b step 1).
187
+
188
+ Tenant-scoped: ``person -> MENTIONED_IN -> Document <- MENTIONED_IN <-
189
+ co-entity``, excluding the seed. Returns the distinct co-mentioned
190
+ entity set and the connecting document set, deterministically ordered.
191
+
192
+ **Bounded ranked-truncation contract:** when more than ``frontier_cap``
193
+ co-mention rows exist the implementation MUST keep the STRONGEST
194
+ ``frontier_cap`` worth of scope — co-entities ranked by co-mention
195
+ frequency with the seed (ties → newest shared document → entity id, a
196
+ total order for reproducibility) — rather than crash, so the headline
197
+ themes/audio surfaces stay usable for hub people. The truncation MUST
198
+ stay within the same bound the cap guaranteed (≤ ``frontier_cap`` rows
199
+ reach downstream) and MUST log a single actionable WARNING naming the
200
+ ``BRAIN_GRAPH_FRONTIER_CAP`` knob — never a silent partial result.
201
+ """
202
+ ...
203
+
204
+ def detach_delete_entities(
205
+ self,
206
+ conn: psycopg.Connection[Any],
207
+ tenant_id: str,
208
+ entity_uuids: Sequence[str],
209
+ ) -> int:
210
+ """``DETACH DELETE`` specific ``Entity`` vertices for a tenant (spec §7.4).
211
+
212
+ The orphan-GC primitive the reconcile layer (G1) calls after the
213
+ relational source-of-truth has dropped a person's last mention: the
214
+ catalog row is gone, so its now-bare AGE vertex (no remaining
215
+ ``MENTIONED_IN`` / ``CO_OCCURS`` edges) must be removed too, or the
216
+ graph drifts from the relational mirror. Only the listed
217
+ ``entity_uuids`` carrying ``tenant_id`` are removed; an empty sequence
218
+ is a no-op. Returns the number of vertices actually deleted (reflects
219
+ reality — a uuid with no matching vertex does not inflate the count).
220
+ Idempotent: re-deleting an already-gone vertex matches nothing and
221
+ contributes zero.
222
+ """
223
+ ...
224
+
225
+ def detach_delete_documents(
226
+ self,
227
+ conn: psycopg.Connection[Any],
228
+ tenant_id: str,
229
+ document_uuids: Sequence[str],
230
+ ) -> int:
231
+ """``DETACH DELETE`` specific ``Document`` vertices for a tenant (spec §7).
232
+
233
+ The document-removal primitive the reconcile layer (G1) calls from
234
+ ``remove_document`` (and from ``reconcile_document`` when an edit drops a
235
+ document to zero person mentions): ``DETACH DELETE`` removes the matched
236
+ ``Document`` vertices together with their attached ``MENTIONED_IN``
237
+ edges. Only the listed ``document_uuids`` carrying ``tenant_id`` are
238
+ removed; an empty sequence is a no-op. Returns the number of vertices
239
+ actually deleted. Idempotent.
240
+ """
241
+ ...
242
+
243
+ def clear_tenant(self, conn: psycopg.Connection[Any], tenant_id: str) -> int:
244
+ """Atomically clear ALL of one tenant's vertices + edges (spec §7 rebuild).
245
+
246
+ ``DETACH DELETE`` every ``Entity`` and ``Document`` vertex carrying
247
+ ``tenant_id`` (which removes their attached ``MENTIONED_IN`` /
248
+ ``CO_OCCURS`` edges) — including any AGE-only state with no relational
249
+ counterpart (a ``Document`` vertex for a doc removed from the relational
250
+ source, a stale AGE-only ``Entity``). The clear-then-rebuild primitive
251
+ the ``brain graphrag build --force`` path calls FIRST so the AGE mirror
252
+ is rebuilt fresh from the relational source-of-truth with no stale
253
+ survivors. Other tenants are untouched even when they share
254
+ ``entity_uuid`` / ``document_uuid`` values (the tenant property is matched
255
+ on every vertex). Atomic — runs inside its own ``conn.transaction()`` (a
256
+ real transaction on an autocommit connection, a SAVEPOINT when nested),
257
+ so a mid-clear failure rolls back wholesale rather than leaving a
258
+ half-cleared mirror. Returns the number of vertices deleted.
259
+ """
260
+ ...
261
+
262
+ def drop_graph(self, conn: psycopg.Connection[Any], tenant_id: str) -> int:
263
+ """Tenant-aware teardown: remove all of one tenant's vertices + edges.
264
+
265
+ ``DETACH DELETE`` every ``Entity`` and ``Document`` vertex carrying
266
+ ``tenant_id`` (which removes their attached edges). Other tenants are
267
+ untouched. Returns the number of vertices deleted. Equivalent to
268
+ :meth:`clear_tenant` (teardown vs clear-before-rebuild are the same
269
+ operation); kept as a distinct, intention-revealing name for decommission
270
+ callers.
271
+ """
272
+ ...
@@ -0,0 +1,344 @@
1
+ """Corpus-wide people-aspect graph build driver (wave G1-d, GraphRAG, spec §9/§12).
2
+
3
+ The batch equivalent of the per-document reconcile hook (wave G1-c): walk a
4
+ stream of document ids and reconcile each one's people aspect into the graph.
5
+ Backs ``brain graphrag build --backfill`` — the one-shot way to populate (or
6
+ re-derive) the people graph for a corpus that was ingested before graph sync was
7
+ enabled, or to propagate a corpus-wide config change.
8
+
9
+ **Single source of truth for the per-doc logic.** :func:`build_graph` calls the
10
+ SAME :func:`brain.graph_rag.reconcile.reconcile_document` the incremental hook
11
+ uses, with the SAME immutable :class:`~brain.graph_rag.reconcile.ReconcileConfig`
12
+ — so a batched backfill produces a graph identical to having reconciled each
13
+ document one-by-one as it was ingested. No co-occurrence / weighting / AGE-sync
14
+ logic is reimplemented here; this module only iterates and tallies.
15
+
16
+ **Resumability (idempotent via the watermark).** ``reconcile_document`` skips a
17
+ document whose per-aspect ``graph_index_state`` watermark is unchanged, so a
18
+ build is safe to re-run and to resume after an interruption: re-running revisits
19
+ the documents in the same ascending-id order (see
20
+ :func:`brain.queries.iter_all_document_ids`) and the already-indexed prefix is
21
+ skipped cheaply (one ``SELECT`` per doc). This only holds when each document
22
+ commits independently, which it does when the caller passes an **autocommit**
23
+ connection — ``reconcile_document`` then opens its own top-level transaction per
24
+ document (see the reconcile module's "Connection contract"). The
25
+ ``brain graphrag build`` CLI sets ``conn.autocommit = True`` before calling this.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from collections.abc import Callable, Iterable
30
+ from dataclasses import dataclass
31
+ from typing import Any
32
+
33
+ import psycopg
34
+
35
+ from ..errors import GraphReconcileError
36
+ from .aggregates import (
37
+ _gc_orphan_concepts,
38
+ _gc_orphan_persons,
39
+ _recompute_aggregates,
40
+ refresh_aggregates,
41
+ )
42
+ from .backends.base import GraphBackend
43
+ from .extract import EntityExtractor
44
+ from .person_resolver import prebuilt_directory_resolver
45
+ from .reconcile import ReconcileConfig, ReconcileResult, reconcile_document
46
+ from .schema import GraphEntity
47
+
48
+ __all__ = ["BuildResult", "ProgressCallback", "build_graph"]
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class BuildResult:
53
+ """Tally of a :func:`build_graph` run.
54
+
55
+ ``processed`` is the number of documents reconcile was attempted on (bounded
56
+ by ``limit``); ``reconciled`` is how many did graph work; ``skipped`` is how
57
+ many short-circuited on an unchanged watermark (``reconciled + skipped ==
58
+ processed``); ``orphans_removed`` is the total zero-mention entity vertices
59
+ GC'd across the run; ``relationship_count`` is the number of tenant
60
+ ``graph_relationships`` edges materialized by the post-loop refresh (0 when no
61
+ document did work, so no refresh ran). Because the per-document reconcile
62
+ defers the tenant-wide aggregate recompute + orphan GC + AGE ``CO_OCCURS``
63
+ rebuild, ``orphans_removed`` and ``relationship_count`` are sourced from the
64
+ single post-loop :func:`~brain.graph_rag.aggregates.refresh_aggregates`, not
65
+ summed per document.
66
+ """
67
+
68
+ processed: int = 0
69
+ reconciled: int = 0
70
+ skipped: int = 0
71
+ orphans_removed: int = 0
72
+ relationship_count: int = 0
73
+
74
+
75
+ # Invoked after each document with ``(processed_count, document_id, result)`` so
76
+ # a CLI caller can throttle progress output without this module owning any I/O.
77
+ ProgressCallback = Callable[[int, str, ReconcileResult], None]
78
+
79
+
80
+ def build_graph(
81
+ conn: psycopg.Connection[Any],
82
+ document_ids: Iterable[str],
83
+ *,
84
+ backend: GraphBackend,
85
+ config: ReconcileConfig,
86
+ limit: int | None = None,
87
+ progress: ProgressCallback | None = None,
88
+ extractor: EntityExtractor | None = None,
89
+ force: bool = False,
90
+ ) -> BuildResult:
91
+ """Reconcile each document's people aspect into the graph (batch backfill).
92
+
93
+ Iterates ``document_ids`` (already ordered by the caller — ascending id for
94
+ a resumable build), calling :func:`reconcile_document` on each with the
95
+ shared ``config`` + ``backend``. Stops after ``limit`` documents when set
96
+ (caps the corpus for testing / partial runs). Returns a :class:`BuildResult`
97
+ tally; ``progress`` (when given) is called once per processed document.
98
+
99
+ **Deferred whole-tenant refresh (perf).** Every per-document reconcile runs
100
+ with ``defer_tenant_refresh=True``, so the tenant-wide derived layers — the
101
+ ``graph_relationships`` recompute, the orphan GC, and the AGE ``CO_OCCURS``
102
+ rematerialization — are skipped per document and recomputed ONCE after the
103
+ loop via :func:`~brain.graph_rag.aggregates.refresh_aggregates` (only when at
104
+ least one document did work). The AGE ``CO_OCCURS`` rebuild is a whole-tenant
105
+ operation whose cost scales with the tenant's relationship count R, so running
106
+ it per document made a corpus build O(docs × R); hoisting it to a single final
107
+ pass makes the build O(docs × doc_entities + R) while producing the identical
108
+ end state (the derived layers are a deterministic full recompute from the
109
+ per-document relational source-of-truth, which IS still written per document).
110
+ Applies to both the plain backfill and the ``force`` rebuild below.
111
+
112
+ **Directory index hoist (perf, Fix B).** The People-Hub directory index
113
+ (``directory_entries``) drives person resolution but is corpus-wide and does
114
+ NOT change during a build, so this driver builds it ONCE before the loop and
115
+ injects it through reconcile's ``person_resolver`` DI seam (via
116
+ :func:`brain.graph_rag.person_resolver.prebuilt_directory_resolver`) — instead
117
+ of every per-document reconcile rebuilding the ~1.2k-row ``SELECT`` + dict.
118
+ The incremental ingest hook (``sync.py``) is unaffected: it uses the default
119
+ resolver, which builds its own single-document index.
120
+
121
+ The connection SHOULD be autocommit so each document commits on its own,
122
+ making the build resumable after an interruption (see the module docstring).
123
+ A non-autocommit connection still works but the whole run becomes one
124
+ transaction — an interruption then rolls everything back.
125
+
126
+ ``extractor`` is the concept-aspect DI seam (wave G2-c): when
127
+ ``config.concepts_enabled`` is True it MUST be provided (production passes
128
+ :func:`brain.graph_rag.extract.make_extractor`; tests pass a fake), and each
129
+ document's concept aspect is (re)indexed alongside its person aspect under its
130
+ own watermark. When concepts are disabled (the default) it is ignored and the
131
+ build is person-only — fully backward compatible. The force pre-pass GCs
132
+ orphans of BOTH aspects + restores ALL of the tenant's entity vertices (any
133
+ type), so a force rebuild faithfully restores any existing concept graph from
134
+ the relational source-of-truth even without re-extraction.
135
+
136
+ ``force=True`` is the authoritative **clean-then-rebuild** recovery path
137
+ (``brain graphrag build --force``) that produces a clean state in BOTH the
138
+ relational source AND the AGE mirror. It:
139
+
140
+ 1. cleans the RELATIONAL source for the tenant — recompute
141
+ ``graph_relationships`` from the current contributions (drops aggregate
142
+ rows stranded by deleted docs) and GC orphan entities (zero remaining
143
+ mentions); reuses :func:`~brain.graph_rag.aggregates._recompute_aggregates`
144
+ + :func:`~brain.graph_rag.aggregates._gc_orphan_persons`. This is what
145
+ makes the zero-doc / all-docs-deleted case authoritative (no per-doc
146
+ reconcile runs there to clean up);
147
+ 2. clears the tenant's ENTIRE existing AGE mirror
148
+ (:meth:`~brain.graph_rag.backends.base.GraphBackend.clear_tenant`), so no
149
+ stale survivors remain — a ``Document`` vertex for a doc removed from the
150
+ relational source, an AGE-only ``Entity`` with no catalog row, orphan
151
+ edges;
152
+ 3. restores every (now non-orphan) tenant ``Entity`` vertex from the clean
153
+ relational catalog (the pre-pass that lets the per-doc full-tenant
154
+ ``CO_OCCURS`` rematerialization find its endpoints); then
155
+ 4. threads :func:`reconcile_document`'s force flag through every document,
156
+ bypassing the per-aspect watermark so each doc is fully re-reconciled and
157
+ its watermark rewritten.
158
+
159
+ Net result: BOTH stores are clean — no orphan entities and
160
+ ``graph_relationships`` (relational + the AGE ``CO_OCCURS`` mirror) reflect
161
+ only current contributions — and the AGE mirror is rebuilt fresh from the
162
+ relational source-of-truth (entities + ``MENTIONED_IN`` + ``Document``
163
+ vertices + ``CO_OCCURS``). A zero-document corpus ends empty in both stores.
164
+ No document short-circuits, so ``reconciled == processed`` and
165
+ ``skipped == 0``.
166
+
167
+ ``force`` is incompatible with ``limit`` (a clear-then-partial-rebuild would
168
+ permanently lose the un-rebuilt remainder) — passing both raises
169
+ :class:`brain.errors.GraphReconcileError` (the CLI rejects it earlier as a
170
+ ``BadParameter``). A zero-document corpus + ``force`` clears the tenant to an
171
+ empty graph (correct).
172
+ """
173
+ if force and limit is not None:
174
+ # A clear-then-partial-rebuild is incoherent: the clear wipes the whole
175
+ # tenant mirror, but a limited loop would only rebuild a prefix, leaving
176
+ # the rest permanently missing. Reject the combination (the CLI surfaces
177
+ # this as a BadParameter before reaching here).
178
+ raise GraphReconcileError(
179
+ "build_graph(force=True) rebuilds the full corpus and cannot be "
180
+ "combined with limit (a clear-then-partial-rebuild is incoherent)"
181
+ )
182
+
183
+ if force:
184
+ # Authoritative rebuild = a clean state in BOTH stores (spec §7), in this
185
+ # order: clean the relational source, then clear + rebuild the AGE mirror
186
+ # FROM that clean source.
187
+ #
188
+ # The pre-pass (steps 1-3) runs inside ONE outer transaction so it is
189
+ # atomic AS A UNIT: a failure during the restore (step 3), AFTER the AGE
190
+ # mirror was cleared (step 2), rolls EVERYTHING back — relational + AGE
191
+ # are left exactly as they were before the --force call. A recovery
192
+ # command must never leave the graph in a worse (cleared / half-restored)
193
+ # state than it started. ``clear_tenant`` and ``upsert_entities`` open
194
+ # their own ``conn.transaction()``, which nest here as SAVEPOINTs, and
195
+ # each AGE primitive activates its own ``ag_catalog`` search_path for the
196
+ # duration of its statements; the relational steps run on the default
197
+ # search_path. The per-doc reconcile LOOP below stays incremental and
198
+ # resumable (each document commits in its own transaction) — only the
199
+ # clear/restore must be atomic together.
200
+ with conn.transaction():
201
+ # 1. RELATIONAL clean: recompute graph_relationships from the CURRENT
202
+ # contributions (drops aggregate rows stranded by deleted docs) and
203
+ # GC orphan entities (zero remaining mentions). Reuses the same
204
+ # aggregate helpers as reconcile/refresh — no duplicated logic.
205
+ # This is what makes the zero-doc / all-docs-deleted case
206
+ # authoritative: there is no per-doc reconcile then to GC/recompute,
207
+ # so the stale relational rows would otherwise survive (and be
208
+ # mirrored into AGE by the pre-pass below).
209
+ _recompute_aggregates(conn, config.tenant_id, config.generic_df_ratio)
210
+ # GC orphans of BOTH aspects (concept GC is a no-op when no concept
211
+ # entities exist, so this is safe for the person-only default too).
212
+ _gc_orphan_persons(conn, config.tenant_id)
213
+ _gc_orphan_concepts(conn, config.tenant_id)
214
+ # 2. Clear the tenant's entire AGE mirror so nothing stale survives — a
215
+ # Document vertex for a doc removed from the relational source, an
216
+ # AGE-only Entity with no catalog row, orphan edges.
217
+ backend.clear_tenant(conn, config.tenant_id)
218
+ # 3. Restore Entity vertices from the now-clean relational catalog. The
219
+ # GC in step 1 ran first, so orphan entities never reach AGE; this
220
+ # pre-pass lets each per-doc CO_OCCURS refresh bind its endpoints (a
221
+ # per-doc reconcile rematerializes the WHOLE tenant's CO_OCCURS,
222
+ # which requires every contribution-referenced Entity vertex to
223
+ # exist).
224
+ _restore_tenant_entity_vertices(conn, config.tenant_id, backend=backend)
225
+
226
+ # Perf Fix B (2026-05-24): build the People-Hub directory index ONCE for the
227
+ # whole batch and inject it through reconcile's person_resolver DI seam,
228
+ # instead of every per-document reconcile rebuilding it. The directory
229
+ # (``directory_entries``, ~1.2k rows) is corpus-wide and does NOT change
230
+ # during a build, so rebuilding the SELECT + dict per document is pure waste
231
+ # (~30-80 ms/doc). The incremental ingest hook (``sync.py``) keeps using the
232
+ # default resolver, which builds its own single-document index, so the
233
+ # one-doc path is unchanged. Late import keeps this module import-cheap
234
+ # (``build_people`` pulls in the wiki package), mirroring person_resolver's
235
+ # own late import of the same helper.
236
+ from ..wiki.build_people import _build_directory_index
237
+
238
+ directory = _build_directory_index(conn, sender_denylist=config.sender_denylist)
239
+ resolver = prebuilt_directory_resolver(directory)
240
+
241
+ processed = 0
242
+ reconciled = 0
243
+ skipped = 0
244
+ orphans_removed = 0
245
+ for document_id in document_ids:
246
+ if limit is not None and processed >= limit:
247
+ break
248
+ result = reconcile_document(
249
+ conn,
250
+ document_id,
251
+ backend=backend,
252
+ config=config,
253
+ person_resolver=resolver,
254
+ extractor=extractor,
255
+ force=force,
256
+ defer_tenant_refresh=True,
257
+ )
258
+ processed += 1
259
+ if result.skipped:
260
+ skipped += 1
261
+ else:
262
+ reconciled += 1
263
+ orphans_removed += result.orphans_removed
264
+ if progress is not None:
265
+ progress(processed, document_id, result)
266
+
267
+ # Each per-document reconcile above ran with ``defer_tenant_refresh=True``, so
268
+ # the tenant-wide derived layers — ``graph_relationships`` (normalized lift +
269
+ # generic suppression), the orphan GC of both aspects, the AGE ``CO_OCCURS``
270
+ # rematerialization, and the orphan-vertex DETACH DELETE — were skipped per
271
+ # document and are recomputed ONCE here from the now-complete relational
272
+ # source-of-truth. This is the SAME whole-tenant operation reconcile ran per
273
+ # document before (``refresh_aggregates`` reuses the identical aggregate + GC
274
+ # helpers), hoisted out of the loop: a corpus build drops from O(docs ×
275
+ # tenant_R) per-doc CO_OCCURS rebuilds to O(docs × doc_entities + tenant_R)
276
+ # (one final rebuild), and the end state is identical (a deterministic full
277
+ # recompute from the source-of-truth).
278
+ #
279
+ # Gate on ``reconciled > 0``: the refresh runs only when at least one document
280
+ # changed the relational source (so the derived layers are stale). When every
281
+ # document short-circuited on its watermark (an idempotent re-run, or a plain
282
+ # ``--backfill`` over an unchanged corpus) the derived layers are already
283
+ # consistent from the build that did the work, so skipping the refresh keeps
284
+ # the re-run a true no-op AND avoids a spurious ``refresh_cooccur_edges`` that
285
+ # would raise against a deliberately-dropped AGE mirror (the recovery path for
286
+ # that is ``--force``, which always reconciles).
287
+ #
288
+ # CRASH-WINDOW RECOVERY: if a deferred build is interrupted AFTER the last
289
+ # document's watermark is written but BEFORE this final refresh runs, a
290
+ # same-command rerun all-skips (``reconciled == 0``) and will NOT repair the
291
+ # derived layer (``graph_relationships`` / AGE ``CO_OCCURS``) — run
292
+ # ``brain graphrag refresh`` (or ``brain graphrag build --force``) to repair.
293
+ # The relational source-of-truth stays correct throughout.
294
+ relationship_count = 0
295
+ if reconciled > 0:
296
+ refresh = refresh_aggregates(conn, backend=backend, config=config)
297
+ relationship_count = refresh.relationship_count
298
+ orphans_removed += refresh.orphans_removed
299
+ return BuildResult(
300
+ processed=processed,
301
+ reconciled=reconciled,
302
+ skipped=skipped,
303
+ orphans_removed=orphans_removed,
304
+ relationship_count=relationship_count,
305
+ )
306
+
307
+
308
+ def _restore_tenant_entity_vertices(
309
+ conn: psycopg.Connection[Any], tenant_id: str, *, backend: GraphBackend
310
+ ) -> int:
311
+ """Re-MERGE every relational ``graph_entities`` row for a tenant into AGE.
312
+
313
+ The force-rebuild pre-pass (spec §7 authoritative rebuild). A per-document
314
+ :func:`reconcile_document` recreates the doc's own entity vertices but then
315
+ rematerializes the WHOLE tenant's ``CO_OCCURS`` edges, which requires *every*
316
+ contribution-referenced entity to already have an AGE vertex
317
+ (:meth:`~brain.graph_rag.backends.base.GraphBackend.refresh_cooccur_edges`
318
+ raises if an endpoint vertex is missing). After a dropped or corrupted AGE
319
+ mirror that invariant is broken — the relational catalog still lists every
320
+ entity but no AGE vertex exists — so the first forced doc's cooccur refresh
321
+ would fail to bind an endpoint that belongs to a not-yet-processed doc.
322
+ Restoring all of the tenant's Entity vertices up front (an idempotent MERGE)
323
+ re-establishes the invariant, so each subsequent forced per-doc reconcile
324
+ rebuilds mentions / MENTIONED_IN / Document vertices / CO_OCCURS exactly as
325
+ the incremental path would. Returns the number of entity vertices restored.
326
+ """
327
+ rows = conn.execute(
328
+ "SELECT id::text, entity_type, name, canonical_key "
329
+ "FROM graph_entities WHERE tenant_id = %s",
330
+ (tenant_id,),
331
+ ).fetchall()
332
+ entities = [
333
+ GraphEntity(
334
+ id=str(row[0]),
335
+ entity_type=str(row[1]),
336
+ name=str(row[2]),
337
+ canonical_key=str(row[3]),
338
+ tenant_id=tenant_id,
339
+ )
340
+ for row in rows
341
+ ]
342
+ if entities:
343
+ backend.upsert_entities(conn, tenant_id, entities)
344
+ return len(entities)