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,583 @@
1
+ """Curated entity alias/merge rules for the GraphRAG entity catalog."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import psycopg
9
+ import yaml
10
+
11
+ from brain.errors import GraphReconcileError
12
+ from brain.wiki._person_name import humanize_person_name
13
+
14
+ if TYPE_CHECKING:
15
+ from ..backends.base import GraphBackend
16
+ from ..reconcile import ReconcileConfig
17
+ from ..schema import GraphEntity
18
+
19
+ _VALID_TYPES = frozenset({"person", "org", "project", "topic", "tool"})
20
+
21
+ # ``graph_entity_mentions.source`` value for person mentions — a mirror of
22
+ # :data:`brain.graph_rag.reconcile.PEOPLE_MENTION_SOURCE`. Duplicated as a plain
23
+ # literal (not imported) so this module stays import-cheap and free of the
24
+ # ``reconcile`` → ``aggregates`` → ``concepts`` chain (the same late-import
25
+ # discipline the rest of the module follows). Person mentions are *presence
26
+ # flags* (always ``mention_count = 1``); concept mentions carry real counts under
27
+ # an ``"extractor:<model>@<ver>"`` source — the two aspects merge differently
28
+ # (see :func:`_repoint_mentions`).
29
+ _PEOPLE_MENTION_SOURCE = "people"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class AliasRule:
34
+ """One directed merge rule: source entity (from_type, from_key) → target (to_type, to_key)."""
35
+
36
+ from_type: str
37
+ from_key: str
38
+ to_type: str
39
+ to_key: str
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class AliasResult:
44
+ """Summary of a single ``apply_aliases`` (or ``merge_aliases``) execution."""
45
+
46
+ tenant_id: str
47
+ rules_total: int = 0
48
+ rules_applied: int = 0 # rules whose source entity existed
49
+ mentions_repointed: int = 0
50
+ contributions_repointed: int = 0
51
+ sources_orphaned: int = 0 # sources left zero-mention (deleted by refresh GC, F2)
52
+ dry_run: bool = False
53
+
54
+
55
+ def _norm(value: str) -> str:
56
+ """Normalise a key to lowercase, collapsed whitespace."""
57
+ return " ".join(str(value).lower().split())
58
+
59
+
60
+ def load_alias_rules(path: Path | None = None) -> list[AliasRule]:
61
+ """Load curated rules from *path*.
62
+
63
+ A missing file returns ``[]`` — the feature is opt-in; real rules live in a
64
+ gitignored local file (``BRAIN_GRAPH_ALIASES_PATH``).
65
+
66
+ Raises :class:`brain.errors.GraphReconcileError` on semantic violations:
67
+ invalid entity type, self-merge, duplicate source, or alias chain/cycle.
68
+ Error messages include the rule *type* and a redacted key only (F5 — never
69
+ the full real value).
70
+ """
71
+ if path is None or not path.exists():
72
+ return []
73
+ raw: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
74
+ rules: list[AliasRule] = []
75
+ for entry in raw.get("rules", []):
76
+ frm, to = entry["from"], entry["to"]
77
+ rule = AliasRule(
78
+ from_type=str(frm["type"]).lower(),
79
+ from_key=_norm(frm["key"]),
80
+ to_type=str(to["type"]).lower(),
81
+ to_key=_norm(to["key"]),
82
+ )
83
+ if rule.from_type not in _VALID_TYPES or rule.to_type not in _VALID_TYPES:
84
+ raise GraphReconcileError(
85
+ f"alias rule has invalid type: "
86
+ f"{rule.from_type}:{_redact(rule.from_key)} → "
87
+ f"{rule.to_type}:{_redact(rule.to_key)}"
88
+ )
89
+ if (rule.from_type, rule.from_key) == (rule.to_type, rule.to_key):
90
+ raise GraphReconcileError(
91
+ f"alias rule is a self-merge: {rule.from_type}:{_redact(rule.from_key)}"
92
+ )
93
+ rules.append(rule)
94
+ _validate_alias_graph(rules) # F7: reject duplicate sources / chains / cycles
95
+ return rules
96
+
97
+
98
+ def _validate_alias_graph(rules: list[AliasRule]) -> None:
99
+ """Reject an ill-formed alias graph (F7).
100
+
101
+ A source ``(type, key)`` may appear at most once (no ambiguous duplicate
102
+ targets), and a source may not also be some other rule's target (no
103
+ transitive chains/cycles like A→B→C or A→B→A), because the merge is
104
+ single-pass and order-independent.
105
+
106
+ Error messages name type + a redacted key only (F5), never the full mapping.
107
+ """
108
+ targets = {(r.to_type, r.to_key) for r in rules}
109
+ seen: set[tuple[str, str]] = set()
110
+ for r in rules:
111
+ src = (r.from_type, r.from_key)
112
+ if src in seen:
113
+ raise GraphReconcileError(
114
+ f"duplicate alias source: {r.from_type}:{_redact(r.from_key)}"
115
+ )
116
+ seen.add(src)
117
+ if src in targets:
118
+ raise GraphReconcileError(
119
+ f"alias chain/cycle: {r.from_type}:{_redact(r.from_key)} "
120
+ "is both a source and a target"
121
+ )
122
+
123
+
124
+ def _redact(key: str) -> str:
125
+ """Return a safe, non-identifying prefix of *key* for error/log messages (F5)."""
126
+ return key[:2] + "…" if len(key) > 2 else "…"
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # C2 — apply_aliases: FK re-point only (F2: never DELETE graph_entities rows)
131
+ # ---------------------------------------------------------------------------
132
+
133
+
134
+ class _Rollback(Exception): # noqa: N818 — internal control-flow marker, not user-facing
135
+ """Internal control-flow exception used to unwind a dry-run savepoint.
136
+
137
+ Raised at the end of a successful per-rule transaction when ``dry_run`` is
138
+ true so that ``psycopg``'s context-managed ``SAVEPOINT`` rolls the row
139
+ changes back without surfacing as a real error to the caller.
140
+ """
141
+
142
+
143
+ def apply_aliases(
144
+ conn: psycopg.Connection[Any],
145
+ tenant_id: str,
146
+ rules: list[AliasRule],
147
+ *,
148
+ dry_run: bool = False,
149
+ ) -> AliasResult:
150
+ """Re-point each rule's source mentions+contributions onto its target.
151
+
152
+ Leaves the source ``graph_entities`` row in place (zero-mention). The caller
153
+ is then responsible for (1) upserting target AGE vertices and
154
+ (2) running ``refresh_aggregates`` — whose existing orphan-GC step deletes
155
+ the zero-mention source relationally **and** detach-deletes its AGE vertex
156
+ (F2). This function intentionally never touches ``graph_entities`` rows,
157
+ ``graph_relationships``, ``doc_count``, or the AGE graph.
158
+
159
+ Per rule the work runs inside a ``with conn.transaction()`` SAVEPOINT so
160
+ failure rolls back only that rule. ``dry_run=True`` rolls every savepoint
161
+ back after counting; the returned summary still reports what *would* move.
162
+
163
+ Idempotent: an absent source ``(from_type, from_key)`` is a silent no-op
164
+ (does not increment ``rules_applied``). ``rules_total`` always reflects the
165
+ input count.
166
+
167
+ SQL is parameterized throughout — entity ids never concatenate into a
168
+ string. Catches only the internal :class:`_Rollback` sentinel; real
169
+ ``psycopg`` errors propagate so callers see actionable failures.
170
+ """
171
+ mentions_repointed = 0
172
+ contributions_repointed = 0
173
+ applied = 0
174
+ orphaned = 0
175
+
176
+ for rule in rules:
177
+ src_id = _entity_id_by_key(conn, tenant_id, rule.from_type, rule.from_key)
178
+ if src_id is None:
179
+ # F2 idempotency: source absent (already merged / never existed) — skip.
180
+ continue
181
+ try:
182
+ with conn.transaction(): # SAVEPOINT
183
+ dst_id = _upsert_entity(conn, tenant_id, rule.to_type, rule.to_key)
184
+ mentions_repointed += _repoint_mentions(
185
+ conn, tenant_id, src_id, dst_id
186
+ )
187
+ contributions_repointed += _repoint_contributions(
188
+ conn, tenant_id, src_id, dst_id
189
+ )
190
+ applied += 1
191
+ orphaned += 1 # source is now zero-mention (GC handled by caller)
192
+ if dry_run:
193
+ raise _Rollback
194
+ except _Rollback:
195
+ # Counters above were tallied before the rollback unwound the row
196
+ # changes, so the dry-run summary still reports the would-be moves.
197
+ pass
198
+
199
+ return AliasResult(
200
+ tenant_id=tenant_id,
201
+ rules_total=len(rules),
202
+ rules_applied=applied,
203
+ mentions_repointed=mentions_repointed,
204
+ contributions_repointed=contributions_repointed,
205
+ sources_orphaned=orphaned,
206
+ dry_run=dry_run,
207
+ )
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # SQL helpers — all parameterized (NEVER concat ids/keys into SQL strings).
212
+ # ---------------------------------------------------------------------------
213
+
214
+
215
+ def _entity_id_by_key(
216
+ conn: psycopg.Connection[Any],
217
+ tenant_id: str,
218
+ entity_type: str,
219
+ canonical_key: str,
220
+ ) -> str | None:
221
+ """Return the durable entity id for ``(tenant, type, key)`` or ``None``."""
222
+ row = conn.execute(
223
+ "SELECT id::text FROM graph_entities "
224
+ "WHERE tenant_id = %s AND entity_type = %s AND canonical_key = %s",
225
+ (tenant_id, entity_type, canonical_key),
226
+ ).fetchone()
227
+ return None if row is None else str(row[0])
228
+
229
+
230
+ def _upsert_entity(
231
+ conn: psycopg.Connection[Any],
232
+ tenant_id: str,
233
+ entity_type: str,
234
+ canonical_key: str,
235
+ ) -> str:
236
+ """Find-or-create the target entity and return its id::text.
237
+
238
+ Uses the migration-012 UNIQUE ``(tenant_id, entity_type, canonical_key)``
239
+ constraint so a re-merge re-uses the existing row. ``name`` is set to the
240
+ humanized canonical key on every call — reuses
241
+ :func:`brain.wiki._person_name.humanize_person_name` so the alias surface
242
+ matches the people-resolver display-name shape (DRY).
243
+ """
244
+ row = conn.execute(
245
+ """
246
+ INSERT INTO graph_entities (tenant_id, entity_type, name, canonical_key)
247
+ VALUES (%s, %s, %s, %s)
248
+ ON CONFLICT (tenant_id, entity_type, canonical_key) DO UPDATE SET
249
+ name = EXCLUDED.name,
250
+ updated_at = NOW()
251
+ RETURNING id::text
252
+ """,
253
+ (tenant_id, entity_type, humanize_person_name(canonical_key), canonical_key),
254
+ ).fetchone()
255
+ # RETURNING on INSERT ... ON CONFLICT DO UPDATE always yields one row.
256
+ assert row is not None
257
+ return str(row[0])
258
+
259
+
260
+ def _repoint_mentions(
261
+ conn: psycopg.Connection[Any],
262
+ tenant_id: str,
263
+ src_id: str,
264
+ dst_id: str,
265
+ ) -> int:
266
+ """Move every source-entity mention onto *dst_id*; return rows moved.
267
+
268
+ Two-step: INSERT the source rows under ``dst_id`` (collapsing the PK
269
+ ``(tenant_id, entity_id, document_id)`` on conflict), then DELETE the old
270
+ source-keyed rows so the source ends up zero-mention. Returns the number of
271
+ source rows that existed pre-move (= the number routed to the target).
272
+
273
+ The on-conflict collapse is **aspect-aware** because ``mention_count`` means
274
+ different things per aspect. Person mentions (``source = 'people'``) are
275
+ *presence flags* — always ``1`` — so summing two ``1``s into ``2`` when both
276
+ the source and target already mention the same document would fabricate a
277
+ count the reconcile pipeline never writes. For those we keep presence via
278
+ ``GREATEST`` (``1``). Concept mentions carry real per-document counts, so
279
+ they still *sum*. The determinant is the row's ``source`` (not entity type):
280
+ if either the existing target row or the incoming source row is a
281
+ people-presence mention, clamp; otherwise sum.
282
+ """
283
+ moved_row = conn.execute(
284
+ """
285
+ WITH moved AS (
286
+ INSERT INTO graph_entity_mentions
287
+ (tenant_id, entity_id, document_id, mention_count, source)
288
+ SELECT tenant_id, %(dst)s, document_id, mention_count, source
289
+ FROM graph_entity_mentions
290
+ WHERE tenant_id = %(tenant)s AND entity_id = %(src)s
291
+ ON CONFLICT (tenant_id, entity_id, document_id)
292
+ DO UPDATE SET
293
+ mention_count = CASE
294
+ WHEN graph_entity_mentions.source = %(people)s
295
+ OR EXCLUDED.source = %(people)s
296
+ THEN GREATEST(
297
+ graph_entity_mentions.mention_count,
298
+ EXCLUDED.mention_count
299
+ )
300
+ ELSE graph_entity_mentions.mention_count
301
+ + EXCLUDED.mention_count
302
+ END
303
+ RETURNING 1
304
+ )
305
+ SELECT count(*) FROM moved
306
+ """,
307
+ {
308
+ "dst": dst_id,
309
+ "tenant": tenant_id,
310
+ "src": src_id,
311
+ "people": _PEOPLE_MENTION_SOURCE,
312
+ },
313
+ ).fetchone()
314
+ assert moved_row is not None
315
+ moved = int(moved_row[0])
316
+ conn.execute(
317
+ "DELETE FROM graph_entity_mentions WHERE tenant_id = %s AND entity_id = %s",
318
+ (tenant_id, src_id),
319
+ )
320
+ return moved
321
+
322
+
323
+ def _repoint_contributions(
324
+ conn: psycopg.Connection[Any],
325
+ tenant_id: str,
326
+ src_id: str,
327
+ dst_id: str,
328
+ ) -> int:
329
+ """Move every source-touching contribution edge onto *dst_id*.
330
+
331
+ The ``graph_edge_contributions`` PK is
332
+ ``(tenant_id, document_id, src_id, dst_id)`` with CHECK ``src_id < dst_id``,
333
+ so naively swapping the endpoint id breaks the canonical ordering. We
334
+ therefore re-canonicalize via ``LEAST/GREATEST`` after substituting, then:
335
+
336
+ * **drop self-edges** (``LEAST == GREATEST``) — re-pointing produces a
337
+ self-edge when the variant already co-occurred with the target in the
338
+ same document.
339
+ * collapse PK conflicts **aspect-aware** — like :func:`_repoint_mentions`,
340
+ person-person co-occurrence edges are *presence flags* (the people
341
+ pipeline emits ``cooccur_count = 1`` for every same-doc pair), so summing
342
+ two ``1``s when the source and target both co-occurred with a third person
343
+ in the same document would fabricate a weight. ``graph_edge_contributions``
344
+ carries no ``source`` column, so the aspect is derived from the endpoints'
345
+ types: a conflict whose two endpoints are BOTH ``person`` keeps presence
346
+ via ``GREATEST``; every other edge (concept-concept, or a cross-type merge)
347
+ still *sums*.
348
+ * delete the old source-touching rows so the source is fully detached at
349
+ the contributions table too.
350
+
351
+ Returns the number of source-touching rows that existed pre-move (= the
352
+ number considered for the target).
353
+ """
354
+ moved_row = conn.execute(
355
+ """
356
+ WITH source_rows AS (
357
+ SELECT
358
+ tenant_id,
359
+ document_id,
360
+ LEAST(
361
+ CASE WHEN src_id = %(src)s THEN %(dst)s::uuid ELSE src_id END,
362
+ CASE WHEN dst_id = %(src)s THEN %(dst)s::uuid ELSE dst_id END
363
+ ) AS new_src,
364
+ GREATEST(
365
+ CASE WHEN src_id = %(src)s THEN %(dst)s::uuid ELSE src_id END,
366
+ CASE WHEN dst_id = %(src)s THEN %(dst)s::uuid ELSE dst_id END
367
+ ) AS new_dst,
368
+ cooccur_count
369
+ FROM graph_edge_contributions
370
+ WHERE tenant_id = %(tenant)s
371
+ AND (src_id = %(src)s OR dst_id = %(src)s)
372
+ ),
373
+ rewritten AS (
374
+ SELECT tenant_id, document_id, new_src, new_dst, cooccur_count
375
+ FROM source_rows
376
+ WHERE new_src <> new_dst -- drop self-edges
377
+ ),
378
+ upserted AS (
379
+ INSERT INTO graph_edge_contributions
380
+ (tenant_id, document_id, src_id, dst_id, cooccur_count)
381
+ SELECT tenant_id, document_id, new_src, new_dst, cooccur_count
382
+ FROM rewritten
383
+ ON CONFLICT (tenant_id, document_id, src_id, dst_id)
384
+ DO UPDATE SET
385
+ cooccur_count = CASE
386
+ WHEN (
387
+ SELECT bool_and(ge.entity_type = 'person')
388
+ FROM graph_entities ge
389
+ WHERE ge.tenant_id = graph_edge_contributions.tenant_id
390
+ AND ge.id IN (
391
+ graph_edge_contributions.src_id,
392
+ graph_edge_contributions.dst_id
393
+ )
394
+ )
395
+ THEN GREATEST(
396
+ graph_edge_contributions.cooccur_count,
397
+ EXCLUDED.cooccur_count
398
+ )
399
+ ELSE graph_edge_contributions.cooccur_count
400
+ + EXCLUDED.cooccur_count
401
+ END
402
+ RETURNING 1
403
+ )
404
+ SELECT (SELECT count(*) FROM source_rows)
405
+ """,
406
+ {"tenant": tenant_id, "src": src_id, "dst": dst_id},
407
+ ).fetchone()
408
+ assert moved_row is not None
409
+ moved = int(moved_row[0])
410
+ conn.execute(
411
+ "DELETE FROM graph_edge_contributions "
412
+ "WHERE tenant_id = %s AND (src_id = %s OR dst_id = %s)",
413
+ (tenant_id, src_id, src_id),
414
+ )
415
+ return moved
416
+
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # C3 — merge_aliases: atomic orchestrator (F2)
420
+ #
421
+ # Wraps the full apply → upsert AGE targets → refresh aggregates flow in ONE
422
+ # transaction so the relational re-point, the AGE vertex provisioning, and the
423
+ # GC + AGE-detach + edge rebuild commit or roll back together. ``dry_run``
424
+ # rolls everything back via the internal :class:`_Rollback` sentinel while
425
+ # still returning the counters that ``apply_aliases`` tallied.
426
+ # ---------------------------------------------------------------------------
427
+
428
+
429
+ def merge_aliases(
430
+ conn: psycopg.Connection[Any],
431
+ tenant_id: str,
432
+ rules: list[AliasRule],
433
+ backend: GraphBackend,
434
+ *,
435
+ dry_run: bool = False,
436
+ config: ReconcileConfig | None = None,
437
+ ) -> AliasResult:
438
+ """Apply curated alias rules atomically, then refresh tenant aggregates (F2).
439
+
440
+ The single corpus-level entry point used by ``brain graphrag aliases apply``,
441
+ the ``build`` / ``refresh`` CLI wiring, and the MCP twin
442
+ ``brain_graphrag_aliases_apply``. Orchestrates the three steps that together
443
+ leave the relational source-of-truth and the AGE mirror consistent:
444
+
445
+ 1. :func:`apply_aliases` re-points every source entity's mentions +
446
+ contributions onto its rule target, leaving the source ``graph_entities``
447
+ row in place (zero-mention; F2 — never DELETEd here).
448
+ 2. ``backend.upsert_entities`` MERGEs the rule targets' AGE vertices so
449
+ newly-created targets exist before
450
+ :meth:`~brain.graph_rag.aggregates.refresh_aggregates`'s
451
+ ``refresh_cooccur_edges`` looks them up (which raises on a missing
452
+ vertex; F2).
453
+ 3. :func:`~brain.graph_rag.aggregates.refresh_aggregates` rebuilds the
454
+ tenant's derived ``graph_relationships`` from the contributions,
455
+ GCs the now-zero-mention sources relationally **and** ``detach delete``
456
+ their AGE vertices, and rematerializes the AGE ``CO_OCCURS`` edges.
457
+
458
+ Steps 1-3 run inside ONE ``with conn.transaction()`` so a failure at step 2
459
+ or 3 rolls back the re-point at step 1 — the graph never lands in a
460
+ half-merged state. ``dry_run=True`` runs step 1 only and raises
461
+ :class:`_Rollback` to unwind the transaction without persisting; the
462
+ returned :class:`AliasResult` still reports the would-be moves and has
463
+ ``dry_run=True``.
464
+
465
+ Empty ``rules`` short-circuits before opening a transaction and returns a
466
+ zero-valued result (the wiring sites can call this unconditionally).
467
+
468
+ ``config`` is an optional :class:`ReconcileConfig` forwarded to
469
+ ``refresh_aggregates`` so the caller's ``generic_df_ratio`` /
470
+ ``suppress_ver`` etc. apply to the post-merge edge recompute. When ``None``
471
+ the default :class:`ReconcileConfig` is used (its ``tenant_id`` is replaced
472
+ with the caller's). When supplied, its ``tenant_id`` MUST match ``tenant_id``
473
+ so the apply step (using ``tenant_id``) and the refresh step (using
474
+ ``config.tenant_id``) can never diverge.
475
+ """
476
+ if not rules:
477
+ return AliasResult(tenant_id=tenant_id, rules_total=0, dry_run=dry_run)
478
+
479
+ # Late imports keep this module import-cheap (avoid pulling reconcile +
480
+ # aggregates + backends at module-load time) and break the import cycle
481
+ # that would otherwise form between aliases.py and aggregates.py (which is
482
+ # allowed to depend on aliases at a later G-wave but not at module-load).
483
+ from ..aggregates import refresh_aggregates # noqa: PLC0415 — break cycle
484
+ from ..reconcile import ReconcileConfig # noqa: PLC0415 — break cycle
485
+
486
+ refresh_config: ReconcileConfig
487
+ if config is None:
488
+ refresh_config = ReconcileConfig(tenant_id=tenant_id)
489
+ elif config.tenant_id != tenant_id:
490
+ raise GraphReconcileError(
491
+ "merge_aliases: config.tenant_id "
492
+ f"({config.tenant_id!r}) does not match tenant_id ({tenant_id!r})"
493
+ )
494
+ else:
495
+ refresh_config = config
496
+
497
+ captured: dict[str, AliasResult] = {}
498
+ try:
499
+ with conn.transaction():
500
+ res = apply_aliases(conn, tenant_id, rules, dry_run=False)
501
+ if dry_run:
502
+ # Record the counters BEFORE unwinding so the dry-run summary
503
+ # still reports what WOULD have moved. The _Rollback raise
504
+ # unwinds the savepoint without surfacing as a real error.
505
+ captured["res"] = AliasResult(
506
+ tenant_id=res.tenant_id,
507
+ rules_total=res.rules_total,
508
+ rules_applied=res.rules_applied,
509
+ mentions_repointed=res.mentions_repointed,
510
+ contributions_repointed=res.contributions_repointed,
511
+ sources_orphaned=res.sources_orphaned,
512
+ dry_run=True,
513
+ )
514
+ raise _Rollback
515
+ # Gate the AGE upsert + corpus refresh on `rules_applied > 0`:
516
+ # when no rule's source entity existed in this corpus (a config
517
+ # carried over from another brain, or an already-merged corpus),
518
+ # `apply_aliases` re-pointed NOTHING — the derived layers are
519
+ # already consistent, so the post-apply target upsert + the
520
+ # whole-tenant `refresh_aggregates` (with its O(R) CO_OCCURS
521
+ # rematerialization) are pure waste. They also create the only
522
+ # branch where the find-or-create `_upsert_entity` from a no-op
523
+ # rule could materialize an unused target vertex in AGE.
524
+ if res.rules_applied > 0:
525
+ # F2: ensure every rule TARGET has an AGE vertex before
526
+ # refresh_cooccur_edges looks it up. A target that was created
527
+ # by apply_aliases (find-or-create via _upsert_entity) has a
528
+ # relational row but no AGE vertex yet; without this upsert
529
+ # the post-merge CO_OCCURS rebuild would raise
530
+ # GraphBackendError on the first contribution touching the
531
+ # new target.
532
+ target_pairs = list({(r.to_type, r.to_key) for r in rules})
533
+ targets = _fetch_entities_by_keys(conn, tenant_id, target_pairs)
534
+ if targets:
535
+ backend.upsert_entities(conn, tenant_id, targets)
536
+ # Refresh: recompute graph_relationships from contributions,
537
+ # GC the now-zero-mention source entities + DETACH DELETE
538
+ # their AGE vertices, rematerialize CO_OCCURS. Runs inside
539
+ # this outer transaction (nested as a SAVEPOINT) so the
540
+ # whole flow commits or rolls back together.
541
+ refresh_aggregates(conn, backend=backend, config=refresh_config)
542
+ captured["res"] = res
543
+ except _Rollback:
544
+ # Counters were tallied before the rollback unwound the row changes,
545
+ # so the dry-run summary still reports the would-be moves.
546
+ pass
547
+ return captured["res"]
548
+
549
+
550
+ def _fetch_entities_by_keys(
551
+ conn: psycopg.Connection[Any],
552
+ tenant_id: str,
553
+ type_key_pairs: list[tuple[str, str]],
554
+ ) -> list[GraphEntity]:
555
+ """Resolve ``(entity_type, canonical_key)`` pairs to :class:`GraphEntity` rows.
556
+
557
+ De-duplicates the input pairs (multiple alias rules can share a target),
558
+ then runs ONE tenant-scoped ``SELECT`` over ``graph_entities`` and maps each
559
+ row via :func:`brain.graph_rag._retrieval_common._row_to_entity` so the
560
+ :class:`GraphEntity` shape stays in lockstep with the rest of the read path
561
+ (DRY — G2 split). Pairs that resolve to no row are silently dropped
562
+ (nothing to AGE-upsert when nothing was created). SQL is parameterized.
563
+ """
564
+ if not type_key_pairs:
565
+ return []
566
+ # Late import keeps this module import-cheap and breaks the cycle with
567
+ # retrieve.py / _retrieval_common (which imports schema, which we already
568
+ # have — but keeping it lazy mirrors the other late imports in this file).
569
+ from .._retrieval_common import _row_to_entity # noqa: PLC0415
570
+
571
+ deduped = sorted(set(type_key_pairs))
572
+ types = [t for t, _ in deduped]
573
+ keys = [k for _, k in deduped]
574
+ rows = conn.execute(
575
+ "SELECT id::text, entity_type, name, canonical_key, description, doc_count "
576
+ "FROM graph_entities "
577
+ "WHERE tenant_id = %s "
578
+ "AND (entity_type, canonical_key) IN ("
579
+ " SELECT unnest(%s::text[]), unnest(%s::text[])"
580
+ ")",
581
+ (tenant_id, types, keys),
582
+ ).fetchall()
583
+ return [_row_to_entity(row, tenant_id) for row in rows]
@@ -0,0 +1,10 @@
1
+ """GraphRAG storage/traversal backends (spec §4 D10, §8).
2
+
3
+ :class:`GraphBackend` is the narrow Protocol; :class:`AgeBackend` is the default
4
+ Apache AGE implementation. A Neo4j/Memgraph kill-switch backend (spec §16) would
5
+ land here too, conforming to the same Protocol.
6
+ """
7
+ from .age import AgeBackend
8
+ from .base import GraphBackend, PersonScope, TraversalHit
9
+
10
+ __all__ = ["AgeBackend", "GraphBackend", "PersonScope", "TraversalHit"]