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,412 @@
1
+ """Global (community-based) graph retrieval (spec §6c — wave G3-d).
2
+
3
+ The third retrieval path alongside **local** (:mod:`brain.graph_rag.retrieve`)
4
+ and **themes-with-X** (:mod:`brain.graph_rag.themes`). Where local resolves
5
+ entity seeds and themes scopes a person, **global** ranks the tenant's
6
+ pre-detected **communities** (``graph_communities``) against a free-text query
7
+ and returns the top ones with their representative entities + documents. The
8
+ public dispatch entry :func:`brain.graph_rag.retrieve.graph_rag_search` calls
9
+ :func:`_retrieve_global` here when ``mode='global'`` (the router flip to route
10
+ *auto* there is wave G3-e; G3-d only adds the path + the explicit-mode dispatch).
11
+
12
+ The ranking (spec §17c Q4): the ranked unit is the **community**, not docs or
13
+ chunks. Two signals are fused with Reciprocal Rank Fusion
14
+ (:func:`brain.rank_fusion.rrf_contribution`, ``k=60`` matching
15
+ :mod:`brain.search`):
16
+
17
+ * **FTS leg** — ``ts_rank`` over ``graph_communities.summary_tsv`` for
18
+ communities with a non-NULL ``summary`` (reusing
19
+ :func:`brain.search._build_tsquery` so the tsquery shape matches hybrid
20
+ search rather than reinventing it).
21
+ * **Vector leg** — cosine distance over ``summary_embedding`` for communities
22
+ with a non-NULL embedding. The query is embedded ``input_type="query"`` via
23
+ the injected ``embedder`` instance (spec §17c Q9). The vector leg is the
24
+ **only reason global needs an embedder**; local/themes stay embedder-free.
25
+
26
+ ``score = Σ 1/(60 + rank + 1)`` per ``community_key``; ties broken by
27
+ ``community_key`` (deterministic). The top ``limit`` communities
28
+ (``cfg.graph_community_limit`` default) become :class:`CommunityGroup`s.
29
+
30
+ Design invariants (mirroring the sibling paths):
31
+
32
+ * **Never-raise (spec §17c Q9/Q10).** No communities / no summaries → an
33
+ empty-but-valid :class:`GraphContext` (never raises). A **missing**
34
+ ``embedder`` OR a **failing** query-embedding call → a WARN + the vector
35
+ leg is skipped (FTS-only), never a hard failure — exactly the §6c "guarded
36
+ on non-null embedding" degradation.
37
+ * **Tenant-scoped (spec §4 D9).** Every read filters by ``tenant_id``.
38
+ * **Deterministic ordering.** Both legs order before truncation, the fusion
39
+ tie-breaks on ``community_key``, and the per-community entity/doc reads break
40
+ ties on stable keys, so repeated runs return byte-identical results.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import logging
45
+ import uuid
46
+ from typing import TYPE_CHECKING, Any
47
+
48
+ import psycopg
49
+
50
+ from ..rank_fusion import rrf_contribution
51
+ from ._retrieval_common import _build_doc_results, _row_to_entity
52
+ from .router import GLOBAL_MODE
53
+ from .schema import CommunityGroup, GraphContext, GraphEntity, GraphExplanation
54
+
55
+ if TYPE_CHECKING:
56
+ from ..config import Config
57
+ from ..ingest import Embedder
58
+ from ..search import SearchResult
59
+
60
+ _logger = logging.getLogger(__name__)
61
+
62
+ # RRF k constant — matches :data:`brain.search.RRF_K` (spec §17c Q4 keeps the
63
+ # global path and hybrid search on the same fusion constant via the shared
64
+ # :func:`brain.rank_fusion.rrf_contribution` helper).
65
+ _RRF_K = 60
66
+
67
+ # Per-leg candidate cap. The community universe is already bounded by the §17c Q8
68
+ # ``graph_community_max`` detection cap, but capping each ranking leg keeps the
69
+ # fusion work bounded under multi-tenant scale and the truncation deterministic
70
+ # (each leg orders before slicing).
71
+ _CANDIDATE_LIMIT = 200
72
+
73
+ # Representative member entities carried on each ``CommunityGroup`` (top by
74
+ # ``member_rank``). ``member_count`` carries the authoritative full size; this
75
+ # only caps the surfaced subset (mirrors
76
+ # :data:`brain.graph_rag.communities_summary._SUMMARY_ENTITY_LIMIT`).
77
+ _MEMBER_ENTITY_LIMIT = 20
78
+
79
+
80
+ def _retrieve_global(
81
+ conn: psycopg.Connection[Any],
82
+ cfg: Config,
83
+ query: str,
84
+ *,
85
+ tenant: str,
86
+ limit: int | None = None,
87
+ embedder: Embedder | None = None,
88
+ session_id: str | None = None,
89
+ ) -> GraphContext:
90
+ """Run global (community-based) retrieval + assemble its ``GraphContext``.
91
+
92
+ Ranks the tenant's communities by fusing an FTS leg (over ``summary_tsv``)
93
+ with a vector leg (over ``summary_embedding``, only when an embedder is
94
+ available) via RRF, takes the top ``limit`` (``cfg.graph_community_limit``
95
+ when ``None``), and populates each :class:`CommunityGroup` with its
96
+ representative member entities + documents (+ snippets via the shared
97
+ :func:`brain.graph_rag._retrieval_common._build_doc_results`).
98
+
99
+ ``embedder`` is the **pre-warmed** :class:`brain.ingest.Embedder` instance
100
+ used to embed the *query* for the vector leg (spec §17c Q9 — local/themes
101
+ never need one). Pre-warmed because the caller (CLI/MCP/fuse) constructs
102
+ or reuses ONE long-lived instance and passes it in, so global mode never
103
+ re-builds the embedder per call (perf-T4 G5). A ``None`` embedder or a
104
+ failing ``embed()`` call logs a WARN and runs **FTS-only** (never-raise).
105
+ No communities / no summaries → an empty-but-valid context. ``session_id``
106
+ is generated when omitted (a fresh ``uuid4`` hex), mirroring the MCP
107
+ ``{session_id, results}`` envelope.
108
+ """
109
+ resolved_limit = cfg.graph_community_limit if limit is None else limit
110
+ resolved_session = uuid.uuid4().hex if session_id is None else session_id
111
+
112
+ fts_keys = _fts_ranked_keys(conn, tenant=tenant, query=query)
113
+ vector_keys, vector_arm_used = _vector_ranked_keys(
114
+ conn, tenant=tenant, query=query, embedder=embedder
115
+ )
116
+
117
+ fused = _fuse_rrf(fts_keys, vector_keys)
118
+ top = fused[:resolved_limit] if resolved_limit > 0 else []
119
+
120
+ communities, docs = _build_communities(conn, tenant, query, top, resolved_limit)
121
+ entities = _dedupe_entities(communities)
122
+
123
+ explanation = GraphExplanation(
124
+ mode=GLOBAL_MODE,
125
+ tenant_id=tenant,
126
+ seed_entity_ids=[],
127
+ person_keys=[],
128
+ depth=0,
129
+ frontier_cap=0,
130
+ min_edge_weight=0.0,
131
+ nodes_visited=len(communities),
132
+ edges_considered=0,
133
+ generic_df_cap=None,
134
+ matched_filters={
135
+ "query": query,
136
+ "fts_candidate_count": len(fts_keys),
137
+ "vector_candidate_count": len(vector_keys),
138
+ "vector_arm_used": vector_arm_used,
139
+ "community_count": len(communities),
140
+ "limit": resolved_limit,
141
+ },
142
+ )
143
+ return GraphContext(
144
+ session_id=resolved_session,
145
+ mode=GLOBAL_MODE,
146
+ query=query,
147
+ tenant_id=tenant,
148
+ person=None,
149
+ themes=[],
150
+ communities=communities,
151
+ entities=entities,
152
+ docs=docs,
153
+ explanation=explanation,
154
+ )
155
+
156
+
157
+ # --------------------------------------------------------------------------- #
158
+ # Ranking legs (FTS + vector) → RRF fusion
159
+ # --------------------------------------------------------------------------- #
160
+ def _fts_ranked_keys(
161
+ conn: psycopg.Connection[Any], *, tenant: str, query: str
162
+ ) -> list[str]:
163
+ """Communities ranked by ``ts_rank`` over ``summary_tsv`` (FTS leg, §17c Q4).
164
+
165
+ Tenant-scoped, restricted to communities with a non-NULL ``summary``. Reuses
166
+ :func:`brain.search._build_tsquery` (late import — keeps :mod:`brain.graph_rag`
167
+ import-cheap, mirroring :func:`._retrieval_common._build_doc_results`) so the
168
+ compact-form tsquery expansion matches hybrid search. Orders by ``ts_rank``
169
+ DESC then ``community_key`` so the 0-indexed rank used for RRF is
170
+ deterministic; capped at :data:`_CANDIDATE_LIMIT`. An empty / pure-punctuation
171
+ query (empty tsquery) ranks nothing.
172
+ """
173
+ from ..search import _build_tsquery
174
+
175
+ tsquery = _build_tsquery(conn, query)
176
+ if not tsquery:
177
+ return []
178
+ rows = conn.execute(
179
+ "SELECT community_key::text FROM graph_communities "
180
+ "WHERE tenant_id = %s AND summary IS NOT NULL "
181
+ "AND summary_tsv @@ to_tsquery('english', %s) "
182
+ "ORDER BY ts_rank(summary_tsv, to_tsquery('english', %s)) DESC, "
183
+ "community_key "
184
+ "LIMIT %s",
185
+ (tenant, tsquery, tsquery, _CANDIDATE_LIMIT),
186
+ ).fetchall()
187
+ return [str(row[0]) for row in rows]
188
+
189
+
190
+ def _vector_ranked_keys(
191
+ conn: psycopg.Connection[Any],
192
+ *,
193
+ tenant: str,
194
+ query: str,
195
+ embedder: Embedder | None,
196
+ ) -> tuple[list[str], bool]:
197
+ """Communities ranked by ``summary_embedding`` cosine (vector leg, §17c Q4/Q9).
198
+
199
+ Returns ``(keys, attempted)``. The vector leg requires an embedder (spec
200
+ §17c Q9 — global REQUIRES one; local/themes stay embedder-free): the
201
+ injected ``embedder`` is the caller's PRE-WARMED instance (perf-T4 G5 —
202
+ the caller constructs ONCE and reuses across calls / legs), used to embed
203
+ the *query* (``input_type="query"``). A ``None`` embedder OR **any**
204
+ failure in the vector arm — embedding the query OR executing the cosine
205
+ ``<=>`` SQL (e.g. a dim mismatch when the backend was swapped without a
206
+ community rebuild, or a transient DB error) → a WARN and ``([], False)``
207
+ (FTS-only degradation; never-raise, matching §6c "guarded on non-null
208
+ embedding"). The cosine SQL is inside the guard precisely so a dim-mismatch
209
+ / DB error in the ``<=>`` execution degrades to FTS-only rather than
210
+ breaking global retrieval. ``attempted=True`` means the full vector arm ran
211
+ (embed + cosine SQL succeeded) and the ranking is usable (even if zero
212
+ communities carry a ``summary_embedding`` — the leg simply contributes
213
+ nothing).
214
+
215
+ Tenant-scoped, restricted to ``summary_embedding IS NOT NULL``; ordered by
216
+ cosine distance ascending then ``community_key`` (deterministic), capped at
217
+ :data:`_CANDIDATE_LIMIT`. Runs under the caller's autocommit connection (the
218
+ CLI/MCP search paths set ``autocommit=True``), so a failed ``<=>`` statement
219
+ leaves the connection usable for the subsequent FTS-only assembly.
220
+ """
221
+ if embedder is None:
222
+ _logger.warning(
223
+ "global retrieval: no embedder injected; vector leg skipped "
224
+ "(FTS-only)"
225
+ )
226
+ return [], False
227
+ try:
228
+ query_vec = embedder.embed([query], input_type="query")[0]
229
+ rows = conn.execute(
230
+ "SELECT community_key::text FROM graph_communities "
231
+ "WHERE tenant_id = %s AND summary_embedding IS NOT NULL "
232
+ "ORDER BY summary_embedding <=> %s::vector, community_key "
233
+ "LIMIT %s",
234
+ (tenant, query_vec, _CANDIDATE_LIMIT),
235
+ ).fetchall()
236
+ except Exception as exc: # noqa: BLE001 — never-raise: degrade to FTS-only
237
+ _logger.warning(
238
+ "global retrieval: vector leg failed (%s); vector leg skipped "
239
+ "(FTS-only)",
240
+ exc,
241
+ )
242
+ return [], False
243
+ return [str(row[0]) for row in rows], True
244
+
245
+
246
+ def _fuse_rrf(
247
+ fts_keys: list[str], vector_keys: list[str]
248
+ ) -> list[tuple[str, float]]:
249
+ """Fuse the two ranked legs with RRF (spec §17c Q4).
250
+
251
+ Each leg contributes ``1 / (60 + rank + 1)`` per ``community_key`` (0-indexed
252
+ rank, via :func:`brain.rank_fusion.rrf_contribution`); contributions
253
+ accumulate across legs. Returns ``(community_key, score)`` sorted by score
254
+ DESC then ``community_key`` ASC (the deterministic tie-break).
255
+ """
256
+ scores: dict[str, float] = {}
257
+ for rank, key in enumerate(fts_keys):
258
+ scores[key] = scores.get(key, 0.0) + rrf_contribution(rank, k=_RRF_K)
259
+ for rank, key in enumerate(vector_keys):
260
+ scores[key] = scores.get(key, 0.0) + rrf_contribution(rank, k=_RRF_K)
261
+ return sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))
262
+
263
+
264
+ # --------------------------------------------------------------------------- #
265
+ # Community population (entities + representative docs + snippets)
266
+ # --------------------------------------------------------------------------- #
267
+ def _build_communities(
268
+ conn: psycopg.Connection[Any],
269
+ tenant: str,
270
+ query: str,
271
+ ranked: list[tuple[str, float]],
272
+ limit: int,
273
+ ) -> tuple[list[CommunityGroup], list[SearchResult]]:
274
+ """Assemble ranked :class:`CommunityGroup`s + the context-level docs.
275
+
276
+ For the top communities (``ranked``, preserving fusion order), batch-load
277
+ each community's metadata (``level`` / ``member_count`` / ``summary``),
278
+ representative member entities (top by ``member_rank``), and representative
279
+ documents (those most mentioning the community's entities). The
280
+ context-level ``docs`` aggregate every contributing document's per-community
281
+ mention scores and reuse :func:`._retrieval_common._build_doc_results` for
282
+ the snippet path (so global docs match the local/themes/hybrid snippet
283
+ shape). An empty ``ranked`` returns ``([], [])`` (the never-raise empty case).
284
+ """
285
+ if not ranked:
286
+ return [], []
287
+ keys = [key for key, _score in ranked]
288
+ score_by_key = dict(ranked)
289
+
290
+ meta_by_key = _community_metadata(conn, tenant, keys)
291
+ entities_by_key = _community_entities(conn, tenant, keys)
292
+ docs_by_key = _community_doc_scores(conn, tenant, keys)
293
+
294
+ communities: list[CommunityGroup] = []
295
+ context_doc_score: dict[str, float] = {}
296
+ for key in keys: # preserve fusion ranking order
297
+ level, member_count, summary = meta_by_key.get(key, (0, 0, None))
298
+ doc_scores = docs_by_key.get(key, [])
299
+ doc_ids = [doc_id for doc_id, _count in doc_scores[:limit]]
300
+ communities.append(
301
+ CommunityGroup(
302
+ community_key=key,
303
+ level=level,
304
+ member_count=member_count,
305
+ score=score_by_key[key],
306
+ summary=summary,
307
+ entities=entities_by_key.get(key, []),
308
+ doc_ids=doc_ids,
309
+ )
310
+ )
311
+ for doc_id, count in doc_scores:
312
+ context_doc_score[doc_id] = context_doc_score.get(doc_id, 0.0) + count
313
+
314
+ context_ranked = sorted(
315
+ context_doc_score.items(), key=lambda kv: (-kv[1], kv[0])
316
+ )[:limit]
317
+ return communities, _build_doc_results(conn, query, context_ranked)
318
+
319
+
320
+ def _community_metadata(
321
+ conn: psycopg.Connection[Any], tenant: str, keys: list[str]
322
+ ) -> dict[str, tuple[int, int, str | None]]:
323
+ """Batch-load ``(level, member_count, summary)`` per community key."""
324
+ if not keys:
325
+ return {}
326
+ rows = conn.execute(
327
+ "SELECT community_key::text, level, member_count, summary "
328
+ "FROM graph_communities "
329
+ "WHERE tenant_id = %s AND community_key::text = ANY(%s)",
330
+ (tenant, keys),
331
+ ).fetchall()
332
+ return {str(row[0]): (int(row[1]), int(row[2]), row[3]) for row in rows}
333
+
334
+
335
+ def _community_entities(
336
+ conn: psycopg.Connection[Any], tenant: str, keys: list[str]
337
+ ) -> dict[str, list[GraphEntity]]:
338
+ """Batch-load each community's representative member entities (by member_rank).
339
+
340
+ Joins ``graph_community_members`` → ``graph_entities`` (tenant-scoped),
341
+ orders by ``member_rank`` ASC then ``canonical_key`` / ``id`` (deterministic),
342
+ groups by community key in Python, and caps each list at
343
+ :data:`_MEMBER_ENTITY_LIMIT`. Maps each row to a :class:`GraphEntity` via the
344
+ shared :func:`._retrieval_common._row_to_entity`.
345
+ """
346
+ if not keys:
347
+ return {}
348
+ rows = conn.execute(
349
+ "SELECT cm.community_key::text, ge.id::text, ge.entity_type, ge.name, "
350
+ "ge.canonical_key, ge.description, ge.doc_count "
351
+ "FROM graph_community_members cm "
352
+ "JOIN graph_entities ge "
353
+ " ON ge.tenant_id = cm.tenant_id AND ge.id = cm.entity_id "
354
+ "WHERE cm.tenant_id = %s AND cm.community_key::text = ANY(%s) "
355
+ "ORDER BY cm.community_key, cm.member_rank ASC, ge.canonical_key ASC, "
356
+ "ge.id ASC",
357
+ (tenant, keys),
358
+ ).fetchall()
359
+ out: dict[str, list[GraphEntity]] = {}
360
+ for row in rows:
361
+ key = str(row[0])
362
+ bucket = out.setdefault(key, [])
363
+ if len(bucket) >= _MEMBER_ENTITY_LIMIT:
364
+ continue
365
+ bucket.append(_row_to_entity(row[1:7], tenant))
366
+ return out
367
+
368
+
369
+ def _community_doc_scores(
370
+ conn: psycopg.Connection[Any], tenant: str, keys: list[str]
371
+ ) -> dict[str, list[tuple[str, float]]]:
372
+ """Batch-load each community's representative documents + mention scores.
373
+
374
+ Joins ``graph_community_members`` → ``graph_entity_mentions`` (tenant-scoped)
375
+ and counts, per community, how many of the community's entity-mention rows
376
+ hit each document. Returns ``community_key → [(document_id, count), …]``
377
+ ordered by count DESC then ``document_id`` ASC (deterministic). Mirrors the
378
+ document ranking in :func:`brain.graph_rag.communities_summary.
379
+ _representative_doc_titles` (count of community entities present), but keyed
380
+ by document id for the per-community ``doc_ids`` + the context-level fusion.
381
+ """
382
+ if not keys:
383
+ return {}
384
+ rows = conn.execute(
385
+ "SELECT cm.community_key::text, m.document_id::text, COUNT(*) AS n "
386
+ "FROM graph_community_members cm "
387
+ "JOIN graph_entity_mentions m "
388
+ " ON m.tenant_id = cm.tenant_id AND m.entity_id = cm.entity_id "
389
+ "WHERE cm.tenant_id = %s AND cm.community_key::text = ANY(%s) "
390
+ "GROUP BY cm.community_key, m.document_id "
391
+ "ORDER BY cm.community_key, n DESC, m.document_id ASC",
392
+ (tenant, keys),
393
+ ).fetchall()
394
+ out: dict[str, list[tuple[str, float]]] = {}
395
+ for row in rows:
396
+ out.setdefault(str(row[0]), []).append((str(row[1]), float(row[2])))
397
+ return out
398
+
399
+
400
+ def _dedupe_entities(communities: list[CommunityGroup]) -> list[GraphEntity]:
401
+ """Flat, deduped, deterministically-sorted entity list across communities.
402
+
403
+ Mirrors :func:`brain.graph_rag.themes._theme_member_entities` so the
404
+ context-level ``entities`` shape matches the themes path.
405
+ """
406
+ by_id: dict[str, GraphEntity] = {}
407
+ for community in communities:
408
+ for entity in community.entities:
409
+ by_id.setdefault(entity.id, entity)
410
+ return sorted(
411
+ by_id.values(), key=lambda e: (e.canonical_key, e.entity_type, e.id)
412
+ )