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,526 @@
1
+ """Entity-centric (local) graph retrieval + the auto-router dispatch (``graph_rag_search``).
2
+
3
+ This module owns the **public dispatch entry** :func:`graph_rag_search`, the
4
+ **local** retrieval path, and seed resolution. The **themes-with-X** path (spec
5
+ §6b) lives in the sibling :mod:`brain.graph_rag.themes`, and the entity-row +
6
+ doc-result helpers shared by both paths live in
7
+ :mod:`brain.graph_rag._retrieval_common` — the G2 wave-boundary file-size split
8
+ (mirroring the G1 :mod:`brain.graph_rag.aggregates` / G2-c
9
+ :mod:`brain.graph_rag.relational` extractions). Behavior is unchanged by the
10
+ split; ``graph_rag_search`` and the mode constants keep their import paths.
11
+
12
+ Two retrieval paths share ``GraphContext`` assembly + snippet reuse:
13
+
14
+ * **Local** (spec §6a; wave G2-d): resolve a free-text query to one or more seed
15
+ ``Entity`` vertices in the relational catalog, run a bounded ``CO_OCCURS``
16
+ traversal from each seed via the injected
17
+ :class:`~brain.graph_rag.backends.base.GraphBackend`, map the reached entities
18
+ back to documents through the tenant-scoped ``graph_entity_mentions`` mirror,
19
+ rank those documents deterministically, and attach a best snippet per document
20
+ by reusing the existing FTS snippet path (:mod:`brain.search`).
21
+ * **Themes with X** (spec §6b; wave G2-f — the HEADLINE): implemented in
22
+ :mod:`brain.graph_rag.themes` and dispatched here. It scopes to a person ``X``
23
+ via :meth:`~brain.graph_rag.backends.base.GraphBackend.scope_person`, computes
24
+ the **in-scope normalized lift**, suppresses generic entities, excludes the
25
+ seed X + the owner, groups the scoped subgraph
26
+ (:func:`brain.graph_rag.grouping.group_themes`), and returns ranked
27
+ :class:`~brain.graph_rag.schema.ThemeGroup`s.
28
+
29
+ Scope (spec §12, waves G2-d/G2-f/G2-g/G3-e/G4-c): the LOCAL path + the
30
+ **auto-router** (wave G2-g; the pure :func:`brain.graph_rag.router.route`) live
31
+ here; ``route`` dispatches ``mode='auto'`` to local, themes, or — after the
32
+ **G3-e flip** (spec §17c Q6) — **global**: a thematic query with no resolvable
33
+ person now routes to the real community path
34
+ (:func:`brain.graph_rag.global_._retrieve_global`), not the former G2
35
+ ``global→local`` degradation. An **explicit** ``mode='global'`` dispatches to
36
+ that same global path. An **explicit** ``mode='fuse'`` (wave G4-c; spec §17d Q1)
37
+ dispatches to :func:`brain.graph_rag.fuse._retrieve_fuse`, which RRF-merges the
38
+ local doc leg with the vector/FTS hybrid leg; ``fuse`` is explicit-only — the
39
+ auto router never targets it. The G2 degradation signals (``requested_mode`` /
40
+ ``degraded_from`` / ``degradation_reason`` on the :class:`GraphContext`) are
41
+ KEEP-DORMANT — defined but never populated.
42
+
43
+ Design invariants (mirroring the rest of the GraphRAG layer):
44
+
45
+ * **Tenant-scoped (spec §4 D9).** ``tenant_id`` is resolved once via
46
+ :func:`brain.graph_rag.tenancy.resolve_tenant` and injected into every query:
47
+ seed resolution, the backend traversal, and the mention→document mapping all
48
+ filter by it, so a second tenant's entities/docs can never leak into a result.
49
+ (``documents`` is not tenantized in G0, but the doc id set is derived solely
50
+ from tenant-scoped ``graph_entity_mentions`` rows, so it is inherently scoped.)
51
+ * **Caps injected (spec §6/§10).** ``depth`` / ``frontier_cap`` /
52
+ ``min_edge_weight`` default from :class:`~brain.config.Config`
53
+ (``graph_depth`` / ``graph_frontier_cap`` / ``graph_min_edge_weight``) and are
54
+ overridable per call.
55
+ * **Deterministic ordering.** Seed resolution, reached-entity merge, document
56
+ ranking, and snippet selection all break ties on a stable key so repeated runs
57
+ return byte-identical results.
58
+ * **Never-raise on empty.** An unresolvable query (no matching seed entity)
59
+ returns an empty-but-valid ``GraphContext`` rather than raising. A backend
60
+ cap-exceed (:class:`~brain.errors.GraphBackendError`) is *not* swallowed — the
61
+ backend's complete-or-loud-failure contract is respected (spec §6a).
62
+ """
63
+ from __future__ import annotations
64
+
65
+ import uuid
66
+ from typing import TYPE_CHECKING, Any
67
+
68
+ import psycopg
69
+
70
+ from ._retrieval_common import _build_doc_results, _fetch_entities, _row_to_entity
71
+ from .fuse import _retrieve_fuse
72
+ from .global_ import _retrieve_global
73
+ from .router import (
74
+ AUTO_MODE,
75
+ FUSE_MODE,
76
+ GLOBAL_MODE,
77
+ LOCAL_MODE,
78
+ THEMES_MODE,
79
+ KnownPerson,
80
+ route,
81
+ )
82
+ from .schema import GraphContext, GraphEntity, GraphExplanation
83
+ from .tenancy import resolve_tenant
84
+ from .themes import _GroupSummarizer, _retrieve_themes
85
+
86
+ if TYPE_CHECKING:
87
+ from ..config import Config
88
+ from ..ingest import Embedder
89
+ from .backends.base import GraphBackend
90
+
91
+ # ``LOCAL_MODE`` / ``THEMES_MODE`` (and ``AUTO_MODE`` / ``GLOBAL_MODE``) are
92
+ # defined in :mod:`brain.graph_rag.router` (the mode vocabulary's canonical home)
93
+ # and re-exported here so existing ``from brain.graph_rag import LOCAL_MODE``
94
+ # imports keep working.
95
+ __all__ = [
96
+ "AUTO_MODE",
97
+ "FUSE_MODE",
98
+ "GLOBAL_MODE",
99
+ "LOCAL_MODE",
100
+ "THEMES_MODE",
101
+ "graph_rag_search",
102
+ ]
103
+
104
+ # Upper bound on seed entities resolved from one query. Exact matches are
105
+ # naturally few; a substring query (e.g. a short prefix) can match many, so the
106
+ # winning tier is capped — by rank — to keep the per-seed traversal fan-out
107
+ # bounded. Deterministic: the cap slices an already-ordered candidate list.
108
+ _MAX_SEED_ENTITIES = 10
109
+
110
+ # Default number of ranked documents returned when the caller passes no ``limit``.
111
+ _DEFAULT_DOC_LIMIT = 10
112
+
113
+ # Document-ranking weight assigned to a *seed* entity's mentions. Seeds are the
114
+ # query's direct subject, so a document mentioning a seed always outranks one
115
+ # reached only through a co-occurrence neighbour (whose weight is its path
116
+ # affinity ∈ (0, 1]).
117
+ _SEED_WEIGHT = 1.0
118
+
119
+
120
+ def graph_rag_search(
121
+ conn: psycopg.Connection[Any],
122
+ cfg: Config,
123
+ query: str,
124
+ *,
125
+ backend: GraphBackend,
126
+ tenant: str | None = None,
127
+ depth: int | None = None,
128
+ frontier_cap: int | None = None,
129
+ min_edge_weight: float | None = None,
130
+ limit: int | None = None,
131
+ mode: str = LOCAL_MODE,
132
+ session_id: str | None = None,
133
+ person: str | None = None,
134
+ theme_limit: int | None = None,
135
+ synthesize: bool = False,
136
+ enricher: _GroupSummarizer | None = None,
137
+ embedder: Embedder | None = None,
138
+ ) -> GraphContext:
139
+ """Graph retrieval (spec §6; ``local`` G2-d, ``themes`` G2-f, router G2-g).
140
+
141
+ ``mode='auto'`` (the router default for the CLI/MCP surfaces) runs the pure
142
+ heuristic :func:`brain.graph_rag.router.route`: a **thematic** query (closed
143
+ regex grammar) with a resolvable person (explicit ``person`` first, else a
144
+ token-boundary scan of the tenant's known person entities) dispatches to
145
+ ``themes``; a thematic query with **no** resolvable person dispatches to the
146
+ real ``global`` community path (the **G3-e flip**, spec §17c Q6 — no longer
147
+ the G2 ``global→local`` degradation, so no degradation signals are stamped);
148
+ a non-thematic query dispatches to ``local``.
149
+
150
+ ``mode='global'`` (explicit) and the auto thematic-no-person branch both
151
+ dispatch to :func:`brain.graph_rag.global_._retrieve_global` (wave G3-d/G3-e;
152
+ spec §6c) via one shared call site: it ranks the tenant's pre-detected
153
+ **communities** (``graph_communities``) via RRF over an FTS leg
154
+ (``summary_tsv``) fused with a vector leg (``summary_embedding``), and returns
155
+ a :class:`GraphContext` with ranked ``communities`` (each a
156
+ :class:`CommunityGroup`) + representative ``docs``. The community count
157
+ defaults from ``cfg.graph_community_limit``. The vector leg needs an embedder:
158
+ the caller passes a PRE-WARMED ``embedder`` instance (perf-T4 G5 — the only
159
+ path that uses it; local/themes stay embedder-free) used to embed the query;
160
+ a missing/failed embed logs a WARN and runs FTS-only (never-raise; spec §17c
161
+ Q9).
162
+
163
+ ``mode='fuse'`` (explicit only — never an auto target; wave G4-c, spec §17d
164
+ Q1) dispatches to :func:`brain.graph_rag.fuse._retrieve_fuse`: it runs the
165
+ ``local`` doc leg + the vector/FTS :func:`brain.search.hybrid_search` leg and
166
+ RRF-merges their **document-id rankings** (``k=60``, the same constant as the
167
+ other rankers), returning a :class:`GraphContext` (``mode='fuse'``) whose
168
+ ``docs`` are the fused :class:`~brain.search.SearchResult`s (wire-stable) with
169
+ per-doc leg provenance in ``explanation.matched_filters['fuse_doc_provenance']``.
170
+ The pre-warmed ``embedder`` instance feeds the hybrid leg's vector arm; a
171
+ missing/failed embedder degrades it to FTS-only and a fully-dead hybrid leg
172
+ degrades fuse to graph-only (never-raise). Hybrid search's ranking is
173
+ consumed unchanged (spec §4 D7).
174
+
175
+ ``mode='local'`` resolves ``query`` to seed entities, traverses
176
+ ``CO_OCCURS`` from each seed via ``backend``, maps the reached entities to
177
+ documents, and returns a :class:`GraphContext` with the seed + reached
178
+ ``entities`` and ranked ``docs`` (each a :class:`brain.search.SearchResult`
179
+ with a best snippet).
180
+
181
+ ``mode='themes'`` is the scope-first "themes in my conversations with X"
182
+ headline (spec §6b; implemented in :mod:`brain.graph_rag.themes`): it
183
+ **requires** ``person`` (the ``X`` to scope to), scopes via
184
+ :meth:`GraphBackend.scope_person`, computes the in-scope normalized lift,
185
+ suppresses generic entities, **excludes X and the owner**, groups the scoped
186
+ subgraph, and returns ranked ``themes`` (each a :class:`ThemeGroup` with
187
+ representative X-docs + snippets). ``theme_limit`` defaults from
188
+ ``cfg.graph_theme_limit``. ``synthesize=True`` attaches a best-effort
189
+ per-group Ollama summary via the injected ``enricher`` (default OFF; never
190
+ required for retrieval — a missing/failed Ollama yields ``summary=None`` + a
191
+ WARN, spec §17b decision 7).
192
+
193
+ Caps (``depth`` / ``frontier_cap`` / ``min_edge_weight``) default from
194
+ ``cfg`` and are overridable; ``limit`` caps the returned documents
195
+ (default :data:`_DEFAULT_DOC_LIMIT`). ``tenant`` overrides the configured
196
+ tenant (resolved via :func:`brain.graph_rag.tenancy.resolve_tenant`).
197
+ ``session_id`` is generated when omitted (a fresh ``uuid4`` hex), mirroring
198
+ the MCP ``{session_id, results}`` envelope.
199
+
200
+ An unresolvable query / empty scope returns an empty-but-valid
201
+ ``GraphContext`` (never raises). A traversal/scope that exceeds the backend's
202
+ safe bound surfaces as :class:`~brain.errors.GraphBackendError`
203
+ (complete-or-loud-failure, not silent truncation).
204
+
205
+ Raises:
206
+ ValueError: ``mode='themes'`` without a resolvable ``person``, or an
207
+ unrecognized ``mode`` (caller bug, surfaced by the router).
208
+ PersonNotFound / PersonAmbiguous: ``person`` does not resolve to exactly
209
+ one directory person (themes mode; surfaced for the CLI/MCP to map).
210
+ GraphTenantError: the effective tenant id resolves empty (caller bug).
211
+ """
212
+ tenant_id = resolve_tenant(cfg, tenant)
213
+ resolved_depth = cfg.graph_depth if depth is None else depth
214
+ resolved_frontier = cfg.graph_frontier_cap if frontier_cap is None else frontier_cap
215
+ resolved_min_weight = (
216
+ cfg.graph_min_edge_weight if min_edge_weight is None else min_edge_weight
217
+ )
218
+ resolved_limit = _DEFAULT_DOC_LIMIT if limit is None else limit
219
+ resolved_session = uuid.uuid4().hex if session_id is None else session_id
220
+
221
+ # Route. ``known_persons`` (the token-boundary-scan candidates) are only
222
+ # needed for the auto path; explicit modes ignore them, so skip the DB read.
223
+ # The router honors explicit modes (incl. ``global`` after the G3-e flip) and
224
+ # decides the auto branches purely; the DB-derived person list is injected so
225
+ # the router itself stays DB-free (dependency inversion).
226
+ known_persons = (
227
+ _fetch_known_persons(conn, tenant_id) if mode == AUTO_MODE else []
228
+ )
229
+ decision = route(query, mode=mode, person=person, known_persons=known_persons)
230
+
231
+ # Global (community-based) path (spec §6c; waves G3-d/G3-e). An EXPLICIT
232
+ # ``mode='global'`` AND the auto thematic-no-person branch both resolve to
233
+ # GLOBAL_MODE and share this single dispatch to :func:`_retrieve_global` —
234
+ # the only path that uses ``embedder`` (spec §17c Q9; local/themes stay
235
+ # embedder-free). The community limit defaults from
236
+ # ``cfg.graph_community_limit`` (resolved inside ``_retrieve_global``), not
237
+ # the doc ``limit``. The G3-e flip routes BOTH global cases here; the G2
238
+ # ``global→local`` degradation is gone (spec §17c Q6).
239
+ if decision.executed_mode == GLOBAL_MODE:
240
+ return _retrieve_global(
241
+ conn,
242
+ cfg,
243
+ query,
244
+ tenant=tenant_id,
245
+ limit=limit,
246
+ embedder=embedder,
247
+ session_id=resolved_session,
248
+ )
249
+
250
+ # Fuse (graph ⊕ hybrid doc-leg RRF) path (spec §17d Q1; wave G4-c). Honored
251
+ # ONLY for an EXPLICIT ``mode='fuse'`` — the auto router never targets it
252
+ # (auto stays local/themes/global). Runs the local doc leg + the hybrid leg
253
+ # and RRF-merges their doc-id rankings; the pre-warmed ``embedder`` feeds the
254
+ # hybrid leg's vector arm (a missing/failed embedder degrades it to FTS-only
255
+ # — never-raise). Local/themes/global/auto are unchanged.
256
+ if decision.executed_mode == FUSE_MODE:
257
+ return _retrieve_fuse(
258
+ conn,
259
+ cfg,
260
+ query,
261
+ backend=backend,
262
+ tenant=tenant_id,
263
+ depth=resolved_depth,
264
+ frontier_cap=resolved_frontier,
265
+ min_edge_weight=resolved_min_weight,
266
+ limit=resolved_limit,
267
+ embedder=embedder,
268
+ session_id=resolved_session,
269
+ )
270
+
271
+ if decision.executed_mode == THEMES_MODE:
272
+ resolved_person = (
273
+ decision.resolved_person.display_name
274
+ if decision.resolved_person is not None
275
+ else None
276
+ )
277
+ if resolved_person is None or not resolved_person.strip():
278
+ raise ValueError(
279
+ "mode='themes' requires a non-empty 'person' (the X to scope to)"
280
+ )
281
+ resolved_theme_limit = (
282
+ cfg.graph_theme_limit if theme_limit is None else theme_limit
283
+ )
284
+ return _retrieve_themes(
285
+ conn,
286
+ person=resolved_person,
287
+ query=query,
288
+ backend=backend,
289
+ tenant_id=tenant_id,
290
+ frontier_cap=resolved_frontier,
291
+ min_edge_weight=resolved_min_weight,
292
+ generic_df_ratio=cfg.graph_generic_df_ratio,
293
+ owner_keys=cfg.owner_participants,
294
+ theme_limit=resolved_theme_limit,
295
+ limit=resolved_limit,
296
+ session_id=resolved_session,
297
+ synthesize=synthesize,
298
+ enricher=enricher,
299
+ )
300
+
301
+ # Local path (explicit ``local`` OR the auto non-thematic branch). After the
302
+ # G3-e flip the thematic-no-person case routes to ``global`` above, so no
303
+ # path reaching here ever degrades — the G2 degradation signals stay dormant
304
+ # (``None``) on the returned context (spec §17c Q6).
305
+ return _retrieve_local(
306
+ conn,
307
+ query,
308
+ backend=backend,
309
+ tenant_id=tenant_id,
310
+ depth=resolved_depth,
311
+ frontier_cap=resolved_frontier,
312
+ min_edge_weight=resolved_min_weight,
313
+ limit=resolved_limit,
314
+ session_id=resolved_session,
315
+ )
316
+
317
+
318
+ # --------------------------------------------------------------------------- #
319
+ # Local-path orchestration (the single seam G2-f/G2-g compose over)
320
+ # --------------------------------------------------------------------------- #
321
+ def _retrieve_local(
322
+ conn: psycopg.Connection[Any],
323
+ query: str,
324
+ *,
325
+ backend: GraphBackend,
326
+ tenant_id: str,
327
+ depth: int,
328
+ frontier_cap: int,
329
+ min_edge_weight: float,
330
+ limit: int,
331
+ session_id: str,
332
+ ) -> GraphContext:
333
+ """Run the resolved local retrieval and assemble its ``GraphContext``.
334
+
335
+ Single code path for the populated and the empty (no-seed) cases: when no
336
+ seed resolves, the traversal loop and every downstream query are skipped on
337
+ empty inputs, yielding an empty-but-valid context (never-raise).
338
+ """
339
+ seeds = _resolve_seeds(conn, tenant_id, query)
340
+ seed_ids = {seed.id for seed in seeds}
341
+
342
+ # Traverse from each seed; keep the best (highest-affinity) path per reached
343
+ # entity across all seeds. The seed set itself is excluded (a seed reached
344
+ # from another seed stays a seed, never a neighbour).
345
+ reached: dict[str, float] = {}
346
+ for seed in seeds:
347
+ hits = backend.traverse(
348
+ conn,
349
+ tenant_id,
350
+ seed.id,
351
+ depth=depth,
352
+ frontier_cap=frontier_cap,
353
+ min_edge_weight=min_edge_weight,
354
+ )
355
+ for hit in hits:
356
+ if hit.entity_uuid in seed_ids:
357
+ continue
358
+ prior = reached.get(hit.entity_uuid)
359
+ if prior is None or hit.affinity > prior:
360
+ reached[hit.entity_uuid] = hit.affinity
361
+
362
+ reached_entities = _fetch_entities(conn, tenant_id, list(reached))
363
+ ordered_reached = sorted(
364
+ reached_entities,
365
+ key=lambda e: (-reached[e.id], e.canonical_key, e.id),
366
+ )
367
+ entities = [*seeds, *ordered_reached]
368
+
369
+ # Document scoring: a doc accumulates the weight of every seed (1.0) /
370
+ # reached (path affinity) entity it mentions, so docs co-mentioning multiple
371
+ # relevant entities rank highest. Ties break on document_id for determinism.
372
+ weights: dict[str, float] = {seed.id: _SEED_WEIGHT for seed in seeds}
373
+ for entity_uuid, affinity in reached.items():
374
+ weights.setdefault(entity_uuid, affinity)
375
+ ranked = _rank_documents(conn, tenant_id, weights, limit)
376
+ docs = _build_doc_results(conn, query, ranked)
377
+
378
+ explanation = GraphExplanation(
379
+ mode=LOCAL_MODE,
380
+ tenant_id=tenant_id,
381
+ seed_entity_ids=[seed.id for seed in seeds],
382
+ person_keys=[],
383
+ depth=depth,
384
+ frontier_cap=frontier_cap,
385
+ min_edge_weight=min_edge_weight,
386
+ nodes_visited=len(seeds) + len(reached),
387
+ # The AGE backend's TraversalHit surfaces reached entities + path affinity
388
+ # but not a per-edge visit count, so edges_considered stays 0 in G2-d.
389
+ edges_considered=0,
390
+ generic_df_cap=None,
391
+ matched_filters={
392
+ "seed_query": query,
393
+ "seed_count": len(seeds),
394
+ "reached_count": len(reached),
395
+ "depth": depth,
396
+ "frontier_cap": frontier_cap,
397
+ "min_edge_weight": min_edge_weight,
398
+ "limit": limit,
399
+ },
400
+ )
401
+ return GraphContext(
402
+ session_id=session_id,
403
+ mode=LOCAL_MODE,
404
+ query=query,
405
+ tenant_id=tenant_id,
406
+ person=None,
407
+ themes=[],
408
+ entities=entities,
409
+ docs=docs,
410
+ explanation=explanation,
411
+ )
412
+
413
+
414
+ # --------------------------------------------------------------------------- #
415
+ # Seed resolution (relational catalog; tenant-scoped; deterministic)
416
+ # --------------------------------------------------------------------------- #
417
+ def _resolve_seeds(
418
+ conn: psycopg.Connection[Any], tenant_id: str, query: str
419
+ ) -> list[GraphEntity]:
420
+ """Resolve a query to seed ``graph_entities`` rows (spec §6a seed step).
421
+
422
+ Tenant-scoped. Exact ``canonical_key``/``name`` matches (case-insensitive)
423
+ win; only when none exist does it fall back to a case-insensitive substring
424
+ match. Either tier is ordered by ``doc_count`` DESC then ``canonical_key`` /
425
+ ``entity_type`` ASC and capped at :data:`_MAX_SEED_ENTITIES`, so the seed set
426
+ is deterministic. An empty / whitespace-only query resolves to no seeds.
427
+
428
+ Vector pre-match (spec §6a "or vector pre-match") is intentionally NOT used
429
+ in G2-d — exact+substring covers entity-name queries and keeps the path
430
+ free of an embedder dependency (YAGNI; can be added later behind the same
431
+ function).
432
+ """
433
+ needle = query.strip().lower()
434
+ if not needle:
435
+ return []
436
+
437
+ columns = "id::text, entity_type, name, canonical_key, description, doc_count"
438
+ exact_rows = conn.execute(
439
+ f"SELECT {columns} FROM graph_entities "
440
+ "WHERE tenant_id = %s AND (lower(canonical_key) = %s OR lower(name) = %s) "
441
+ "ORDER BY doc_count DESC, canonical_key ASC, entity_type ASC",
442
+ (tenant_id, needle, needle),
443
+ ).fetchall()
444
+ rows = exact_rows
445
+ if not rows:
446
+ pattern = _like_contains(needle)
447
+ rows = conn.execute(
448
+ f"SELECT {columns} FROM graph_entities "
449
+ "WHERE tenant_id = %s "
450
+ "AND (lower(name) LIKE %s OR lower(canonical_key) LIKE %s) "
451
+ "ORDER BY doc_count DESC, canonical_key ASC, entity_type ASC",
452
+ (tenant_id, pattern, pattern),
453
+ ).fetchall()
454
+ return [_row_to_entity(row, tenant_id) for row in rows[:_MAX_SEED_ENTITIES]]
455
+
456
+
457
+ def _like_contains(needle: str) -> str:
458
+ """Build a case-insensitive ``LIKE`` contains-pattern, escaping wildcards.
459
+
460
+ Postgres ``LIKE`` defaults to backslash as the escape character, so a
461
+ literal ``%`` / ``_`` / ``\\`` in the (already lower-cased) needle is
462
+ backslash-escaped before being wrapped in ``%...%``.
463
+ """
464
+ escaped = needle.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
465
+ return f"%{escaped}%"
466
+
467
+
468
+ def _fetch_known_persons(
469
+ conn: psycopg.Connection[Any], tenant_id: str
470
+ ) -> list[KnownPerson]:
471
+ """Fetch tenant-scoped known person entities for the router's name scan.
472
+
473
+ Returns every ``person`` ``graph_entities`` row's
474
+ ``(canonical_key, display_name, doc_count)`` so the pure
475
+ :func:`brain.graph_rag.router.route` can token-boundary-scan the query
476
+ without any DB access (dependency inversion — the DB-derived candidate list
477
+ is injected). Ordered by ``canonical_key`` for a reproducible input; the
478
+ router's tie-break is order-independent, but a stable order keeps the call
479
+ deterministic regardless of physical row order.
480
+ """
481
+ rows = conn.execute(
482
+ "SELECT canonical_key, name, doc_count FROM graph_entities "
483
+ "WHERE tenant_id = %s AND entity_type = 'person' "
484
+ "ORDER BY canonical_key",
485
+ (tenant_id,),
486
+ ).fetchall()
487
+ return [
488
+ KnownPerson(
489
+ canonical_key=str(row[0]),
490
+ display_name=str(row[1]),
491
+ doc_count=int(row[2]),
492
+ )
493
+ for row in rows
494
+ ]
495
+
496
+
497
+ # --------------------------------------------------------------------------- #
498
+ # Reached entities → ranked documents (tenant-scoped mention mirror)
499
+ # --------------------------------------------------------------------------- #
500
+ def _rank_documents(
501
+ conn: psycopg.Connection[Any],
502
+ tenant_id: str,
503
+ weights: dict[str, float],
504
+ limit: int,
505
+ ) -> list[tuple[str, float]]:
506
+ """Map weighted entities to documents and rank them (spec §6a doc step).
507
+
508
+ Reads the tenant-scoped ``graph_entity_mentions`` mirror for the seed +
509
+ reached entity ids and sums each document's entity weights. Returns the top
510
+ ``limit`` ``(document_id, score)`` pairs ordered by score DESC, document_id
511
+ ASC (deterministic). An empty entity/weight set returns no documents.
512
+ """
513
+ if not weights or limit <= 0:
514
+ return []
515
+ rows = conn.execute(
516
+ "SELECT entity_id::text, document_id::text FROM graph_entity_mentions "
517
+ "WHERE tenant_id = %s AND entity_id = ANY(%s)",
518
+ (tenant_id, list(weights)),
519
+ ).fetchall()
520
+ doc_scores: dict[str, float] = {}
521
+ for entity_id, document_id in rows:
522
+ doc_scores[str(document_id)] = doc_scores.get(str(document_id), 0.0) + weights[
523
+ str(entity_id)
524
+ ]
525
+ ranked = sorted(doc_scores.items(), key=lambda kv: (-kv[1], kv[0]))
526
+ return ranked[:limit]