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,237 @@
1
+ """Post-write / post-delete graph reconcile hook (wave G1-c, GraphRAG, spec §7/§10).
2
+
3
+ Bridges the document write/delete paths to the people-aspect reconcile in
4
+ :mod:`brain.graph_rag.reconcile`. One :class:`GraphSyncer` is built per CLI / MCP
5
+ / watcher invocation from the resolved :class:`brain.config.Config` and threaded
6
+ into ``ingest_document`` / ``update_document`` / ``sync_vault`` and the explicit
7
+ delete sites, so a single immutable
8
+ :class:`brain.graph_rag.reconcile.ReconcileConfig` is shared across *every*
9
+ write and delete in that process. That shared object is the divergence-safety
10
+ guarantee G1-b flagged: a build and a later delete can never recompute the
11
+ tenant aggregate with a different ``generic_df_ratio`` (which would corrupt the
12
+ generic-suppression weights).
13
+
14
+ **Discipline (mirrors the Q1-D enrich hook).** Graph sync is best-effort and
15
+ MUST NOT block or crash an ingest / edit / delete:
16
+
17
+ * it runs only when graph sync is enabled (``BRAIN_GRAPH_ENABLED``) AND the
18
+ database actually ships Apache AGE (``age_extension_available`` is a safe
19
+ ``False`` on a stock pgvector DB -- e.g. the prod DB before the AGE cut-over),
20
+ * it provisions the graph labels/indexes lazily on first use (idempotent;
21
+ guarded by the connection being in autocommit mode, since AGE catalog DDL
22
+ needs it), and
23
+ * any failure is logged at WARNING with the document id and swallowed -- the
24
+ relational write already committed and the graph is a recomputable mirror
25
+ (``brain graphrag build`` rebuilds it). This is the *intended* degradation:
26
+ distinct from a swallowed bug, a graph-sync hiccup must never surface as an
27
+ ingest failure.
28
+
29
+ **Connection contract.** The caller's connection is reused (no per-document
30
+ connection churn). The hook is invoked at a point where the caller's write has
31
+ already committed and no transaction is open, so ``reconcile_document`` /
32
+ ``remove_document`` open their own top-level transaction (real on an autocommit
33
+ connection, a true top-level one on an idle ``autocommit=False`` connection --
34
+ see the reconcile module's "Connection contract"). The connection must have AGE
35
+ loadable; :meth:`GraphSyncer._age_ready` runs ``LOAD 'age'`` itself, so callers
36
+ do not need :func:`brain.db.connect_age`.
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import logging
41
+ from functools import cache
42
+ from typing import Any
43
+
44
+ import psycopg
45
+
46
+ from ..config import Config
47
+ from ..db import age_extension_available, load_age
48
+ from .backends.age import AgeBackend
49
+ from .backends.base import GraphBackend
50
+ from .extract import EntityExtractor
51
+ from .reconcile import ReconcileConfig, reconcile_document, remove_document
52
+
53
+ _logger = logging.getLogger(__name__)
54
+
55
+ __all__ = ["GraphSyncer", "build_reconcile_config", "make_graph_syncer"]
56
+
57
+
58
+ @cache
59
+ def build_reconcile_config(cfg: Config) -> ReconcileConfig:
60
+ """Resolve the ONE :class:`ReconcileConfig` for a Config (cached -> shared).
61
+
62
+ ``functools.cache`` keyed on the frozen, hashable :class:`brain.config.Config`
63
+ returns the *same* :class:`ReconcileConfig` instance for repeated calls in a
64
+ process, so a build and a later delete provably reconcile the tenant
65
+ aggregate with the identical ``generic_df_ratio`` / ``tenant_id`` (spec §7
66
+ divergence-safety). ``owner_keys`` reuses ``cfg.owner_participants`` -- the
67
+ corpus owner is stripped from the graph's person roster exactly as from the
68
+ People Hub.
69
+ """
70
+ return ReconcileConfig(
71
+ tenant_id=cfg.graph_tenant_id,
72
+ cooccur_window=cfg.graph_cooccur_window,
73
+ max_entities_per_doc=cfg.graph_max_entities,
74
+ generic_df_ratio=cfg.graph_generic_df_ratio,
75
+ owner_keys=cfg.owner_participants,
76
+ sender_denylist=cfg.graph_sender_denylist,
77
+ concepts_enabled=cfg.graph_concepts,
78
+ graph_extract_stopwords=cfg.graph_extract_stopwords,
79
+ )
80
+
81
+
82
+ class GraphSyncer:
83
+ """Best-effort people-aspect graph sync for one process invocation.
84
+
85
+ Holds the single shared :class:`ReconcileConfig` + the graph backend, and
86
+ exposes :meth:`reconcile` / :meth:`remove` -- both gated, both never-raise.
87
+ Construct via :func:`make_graph_syncer` (production) or directly (tests).
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ config: ReconcileConfig,
93
+ *,
94
+ enabled: bool,
95
+ backend: GraphBackend | None = None,
96
+ extractor: EntityExtractor | None = None,
97
+ ) -> None:
98
+ self._config = config
99
+ self._enabled = enabled
100
+ self._backend: GraphBackend = (
101
+ backend if backend is not None else AgeBackend()
102
+ )
103
+ # Concept-aspect DI seam (wave G2-c). Threaded into reconcile only; when
104
+ # ``config.concepts_enabled`` is True this MUST be set (``make_graph_syncer``
105
+ # builds it from the config). ``None`` keeps the syncer person-only.
106
+ self._extractor = extractor
107
+ # Lazy one-time label/index provisioning per syncer (per invocation).
108
+ self._bootstrapped = False
109
+
110
+ @property
111
+ def config(self) -> ReconcileConfig:
112
+ """The single shared reconcile config (used by reconcile AND remove)."""
113
+ return self._config
114
+
115
+ @property
116
+ def enabled(self) -> bool:
117
+ """True iff ``BRAIN_GRAPH_ENABLED`` resolved truthy for this process."""
118
+ return self._enabled
119
+
120
+ def reconcile(self, conn: psycopg.Connection[Any], document_id: str) -> None:
121
+ """Reconcile one document into the people graph after a write.
122
+
123
+ Never raises -- a graph-sync failure is logged and swallowed so the
124
+ (already-committed) relational write is never undone.
125
+ """
126
+ if not self._enabled:
127
+ return
128
+ try:
129
+ if not self._age_ready(conn):
130
+ return
131
+ reconcile_document(
132
+ conn,
133
+ document_id,
134
+ backend=self._backend,
135
+ config=self._config,
136
+ extractor=self._extractor,
137
+ )
138
+ # Bug A — cross-document concept type-collapse. Runs AFTER
139
+ # reconcile_document has written + committed this document's
140
+ # mentions/contributions (its own top-level transaction), so a
141
+ # zero-mention source GC inside the collapse can never orphan an id
142
+ # the pending mention-insert still references. Only meaningful when
143
+ # concepts are active (the catalog is otherwise person-only, which
144
+ # this never touches); idempotent + a cheap catalog scan no-op when
145
+ # nothing is fragmented. Late import keeps the person-only path from
146
+ # pulling the extractor transport at module load.
147
+ if self._config.concepts_enabled and self._extractor is not None:
148
+ from .cross_type import collapse_cross_type_concepts
149
+
150
+ collapse_cross_type_concepts(
151
+ conn,
152
+ self._config.tenant_id,
153
+ self._backend,
154
+ config=self._config,
155
+ )
156
+ except Exception as exc: # noqa: BLE001 -- best-effort: never block a write
157
+ _logger.warning(
158
+ "graph sync (reconcile) skipped for doc %s: %s; "
159
+ "the graph is recomputable via `brain graphrag build`",
160
+ document_id,
161
+ exc,
162
+ )
163
+
164
+ def remove(self, conn: psycopg.Connection[Any], document_id: str) -> None:
165
+ """Remove one document from the people graph after a delete.
166
+
167
+ Never raises -- same best-effort discipline as :meth:`reconcile`.
168
+ """
169
+ if not self._enabled:
170
+ return
171
+ try:
172
+ if not self._age_ready(conn):
173
+ return
174
+ remove_document(
175
+ conn, document_id, backend=self._backend, config=self._config
176
+ )
177
+ except Exception as exc: # noqa: BLE001 -- best-effort: never block a delete
178
+ _logger.warning(
179
+ "graph sync (remove) skipped for doc %s: %s; "
180
+ "the graph is recomputable via `brain graphrag build`",
181
+ document_id,
182
+ exc,
183
+ )
184
+
185
+ def _age_ready(self, conn: psycopg.Connection[Any]) -> bool:
186
+ """Gate + lazily provision: True iff AGE is usable on ``conn``.
187
+
188
+ Returns ``False`` (caller skips) when the database does not ship AGE
189
+ (stock pgvector) or the extension is not yet installed/loadable. When
190
+ AGE is ready, lazily bootstraps the backend's labels + property indexes
191
+ once per syncer -- idempotent, and only attempted on an autocommit
192
+ connection (AGE catalog DDL needs it; the CLI / MCP write paths run
193
+ autocommit). On a non-autocommit connection the bootstrap is skipped and
194
+ reconcile relies on `brain init` having provisioned the graph; if it
195
+ hasn't, the reconcile attempt fails and is caught by the caller.
196
+ """
197
+ if not age_extension_available(conn):
198
+ return False
199
+ if not load_age(conn):
200
+ return False
201
+ if not self._bootstrapped and conn.autocommit:
202
+ self._backend.bootstrap(conn)
203
+ self._bootstrapped = True
204
+ return True
205
+
206
+
207
+ def make_graph_syncer(
208
+ cfg: Config,
209
+ *,
210
+ backend: GraphBackend | None = None,
211
+ extractor: EntityExtractor | None = None,
212
+ ) -> GraphSyncer:
213
+ """Build the per-invocation :class:`GraphSyncer` from a resolved Config.
214
+
215
+ The single source of truth for wiring the graph sync hook. CLI / MCP /
216
+ watcher entry points call this once and thread the returned syncer through
217
+ every write/delete path so they all share one :class:`ReconcileConfig`.
218
+
219
+ When ``BRAIN_GRAPH_CONCEPTS`` is on (``cfg.graph_concepts``) and no
220
+ ``extractor`` is injected, the concept-aspect extractor is built from the
221
+ config (:func:`brain.graph_rag.extract.make_extractor`, using
222
+ ``BRAIN_GRAPH_EXTRACT_MODEL``); tests inject a fake instead. With concepts
223
+ off the extractor stays ``None`` and the syncer is person-only.
224
+ """
225
+ syncer_extractor = extractor
226
+ if syncer_extractor is None and cfg.graph_concepts:
227
+ # Late import keeps this module import-cheap (extract pulls in the
228
+ # enrichment transport) for the common concepts-disabled path.
229
+ from .extract import make_extractor
230
+
231
+ syncer_extractor = make_extractor(cfg)
232
+ return GraphSyncer(
233
+ build_reconcile_config(cfg),
234
+ enabled=cfg.graph_enabled,
235
+ backend=backend,
236
+ extractor=syncer_extractor,
237
+ )
@@ -0,0 +1,43 @@
1
+ """Effective ``tenant_id`` resolution for GraphRAG surfaces (spec §9/§10)."""
2
+ from __future__ import annotations
3
+
4
+ from ..config import Config
5
+ from ..errors import GraphTenantError
6
+
7
+ __all__ = ["resolve_tenant"]
8
+
9
+
10
+ def resolve_tenant(cfg: Config, override: str | None = None) -> str:
11
+ """Return the effective, validated ``tenant_id`` for a graph operation.
12
+
13
+ GraphRAG is multi-tenant (spec §9 D9): every relational row, AGE
14
+ vertex/edge property, and generated query is scoped by ``tenant_id``.
15
+ Centralizing the "override-else-default, validated non-empty" rule here is
16
+ the single source of truth the G2 retrieval CLI / MCP / themes paths call,
17
+ so they cannot each re-implement it (and drift).
18
+
19
+ Precedence: a non-blank ``override`` (trimmed) wins; otherwise the config
20
+ default ``cfg.graph_tenant_id`` (trimmed, ``BRAIN_GRAPH_TENANT``, itself
21
+ defaulting to ``"default"`` for single-user local use). A ``None`` / blank /
22
+ whitespace-only override is treated as "not supplied" and falls back to the
23
+ default — matching the existing ``brain graphrag build/refresh`` ``--tenant``
24
+ precedent in ``cli._graphrag_config`` — so a stray ``--tenant ""`` never
25
+ silently scopes a query to an empty tenant.
26
+
27
+ Raises:
28
+ GraphTenantError: the resolved tenant id is empty. Every tenant-scoped
29
+ row/vertex/edge/query needs a non-empty id (the schema's
30
+ ``tenant_id TEXT NOT NULL`` invariant). With config validation this
31
+ only fires on a programmatically-constructed Config carrying a blank
32
+ ``graph_tenant_id``, but the guard keeps the invariant local and
33
+ explicit.
34
+ """
35
+ if override is not None and override.strip():
36
+ return override.strip()
37
+ resolved = cfg.graph_tenant_id.strip()
38
+ if not resolved:
39
+ raise GraphTenantError(
40
+ "effective tenant_id is empty: pass --tenant or set "
41
+ "BRAIN_GRAPH_TENANT to a non-empty value"
42
+ )
43
+ return resolved