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,288 @@
1
+ """Pure heuristic auto-router for graph retrieval (spec §6d / §17b decisions 3-4).
2
+
3
+ :func:`route` is a **pure, deterministic, DB-free** function: given the query,
4
+ the requested ``mode``, an optional explicit ``person``, and a pre-fetched list
5
+ of :class:`KnownPerson` candidates, it decides which retrieval path
6
+ :func:`brain.graph_rag.retrieve.graph_rag_search` must dispatch to. Keeping it
7
+ pure means its full branch matrix is unit-testable without a database — the
8
+ caller resolves the DB-derived inputs (the known person entities) and passes
9
+ them in (dependency inversion).
10
+
11
+ The rule set (spec §17b decision 3; §17c Q6 — the G3-e flip; §17d Q1 — fuse):
12
+
13
+ 1. **Explicit modes are honored** — including an explicit ``global`` request,
14
+ which now returns :data:`GLOBAL_MODE` (community summaries shipped in G3, so
15
+ the router dispatches to them; the G2 rejection is gone — spec §17c Q6), and
16
+ an explicit ``fuse`` request (:data:`FUSE_MODE`; wave G4-c, spec §17d Q1).
17
+ ``fuse`` is honored ONLY as an explicit request — it is **never** an
18
+ auto-routed target (the auto branches stay local/themes/global).
19
+ 2. **Thematic intent** is detected by a **closed regex grammar** (not an
20
+ open-ended keyword list): the normalized query matches
21
+ ``\\bthemes?\\b|\\btopics?\\b|\\bpatterns?\\b|\\btrends?\\b|\\brecurring\\b``
22
+ **OR** ``\\bhow\\s+(has|have|did|does)\\b.{0,80}\\b(evolve|evolved|change|
23
+ changed|shift|shifted)\\b`` (case-insensitive).
24
+ 3. **Person resolution precedence:** an explicit ``person`` argument first;
25
+ otherwise a token-boundary scan of the ``known_persons`` against the query.
26
+ 4. **Query-match tie-break** among scanned persons: longest matched span →
27
+ highest ``doc_count`` → lexicographically smallest ``canonical_key``.
28
+ 5. **Branches (auto):** ``thematic AND person → themes``;
29
+ ``thematic AND no person → global`` (the real global community path now — G3-e
30
+ flipped the former G2 ``global→local`` degradation off); else → ``local``.
31
+
32
+ **G2 degradation machinery is KEEP-DORMANT (spec §17c Q6):** the
33
+ :data:`DEGRADED_FROM_GLOBAL` / :data:`DEGRADATION_REASON_G2` constants, the
34
+ :class:`RoutingDecision` ``degraded_from`` / ``degradation_reason`` fields, and
35
+ :class:`~brain.errors.GraphModeUnavailable` all **stay defined** for wire
36
+ stability, but G3 **never populates / raises** them — every path below leaves the
37
+ degradation fields ``None`` and no path raises ``GraphModeUnavailable``.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import re
42
+ from collections.abc import Sequence
43
+ from dataclasses import dataclass
44
+
45
+ __all__ = [
46
+ "AUTO_MODE",
47
+ "DEGRADATION_REASON_G2",
48
+ "DEGRADED_FROM_GLOBAL",
49
+ "FUSE_MODE",
50
+ "GLOBAL_MODE",
51
+ "LOCAL_MODE",
52
+ "THEMES_MODE",
53
+ "KnownPerson",
54
+ "RoutedPerson",
55
+ "RoutingDecision",
56
+ "route",
57
+ ]
58
+
59
+ # The retrieval-mode vocabulary (canonical home; re-exported by retrieve.py /
60
+ # the package __init__ so existing ``LOCAL_MODE`` / ``THEMES_MODE`` imports keep
61
+ # working). ``auto`` triggers this router; ``global`` is the community path the
62
+ # router now dispatches to for an explicit request AND the auto
63
+ # thematic-no-person branch (the G3-e flip; spec §17c Q6).
64
+ LOCAL_MODE = "local"
65
+ THEMES_MODE = "themes"
66
+ GLOBAL_MODE = "global"
67
+ AUTO_MODE = "auto"
68
+ # Fuse (wave G4-c; spec §17d Q1): RRF of the local-graph doc leg with the
69
+ # vector/FTS hybrid doc leg. Honored ONLY as an explicit request — the auto
70
+ # router never targets it (auto stays local/themes/global).
71
+ FUSE_MODE = "fuse"
72
+
73
+ # Degradation signals (spec §17b decision 4) — KEEP-DORMANT after the G3-e flip
74
+ # (spec §17c Q6): retained for wire stability but no longer stamped onto any
75
+ # ``GraphContext`` / ``RoutingDecision`` (the auto thematic-no-person branch now
76
+ # routes to the real GLOBAL_MODE instead of degrading to local).
77
+ DEGRADED_FROM_GLOBAL = "global"
78
+ DEGRADATION_REASON_G2 = "global_unavailable_g2"
79
+
80
+ # Closed thematic-intent grammar (spec §17b decision 3 — NOT an open keyword
81
+ # list). Compiled once at import; case-insensitive.
82
+ _THEMATIC_KEYWORDS = re.compile(
83
+ r"\bthemes?\b|\btopics?\b|\bpatterns?\b|\btrends?\b|\brecurring\b",
84
+ re.IGNORECASE,
85
+ )
86
+ _THEMATIC_EVOLUTION = re.compile(
87
+ r"\bhow\s+(has|have|did|does)\b.{0,80}\b"
88
+ r"(evolve|evolved|change|changed|shift|shifted)\b",
89
+ re.IGNORECASE,
90
+ )
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class KnownPerson:
95
+ """A candidate person entity for the router's token-boundary scan.
96
+
97
+ Pre-fetched (tenant-scoped) by the caller from ``graph_entities`` so the
98
+ router stays DB-free. ``canonical_key`` is the lowercased People-Hub display
99
+ name (the dedup key + lexicographic tie-break), ``display_name`` the entity's
100
+ stored name, and ``doc_count`` the derived mention count (the doc-count
101
+ tie-break input).
102
+ """
103
+
104
+ canonical_key: str
105
+ display_name: str
106
+ doc_count: int = 0
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class RoutedPerson:
111
+ """The person a query routed to (spec §17b decision 3).
112
+
113
+ ``source`` is ``"explicit"`` (from the ``--person`` / MCP ``person`` arg) or
114
+ ``"scanned"`` (a token-boundary match in the query). For the explicit case
115
+ ``canonical_key`` / ``doc_count`` are unknown (``None``) — only the caller's
116
+ stripped person string is carried in ``display_name``. For the scanned case
117
+ all three mirror the winning :class:`KnownPerson`.
118
+ """
119
+
120
+ display_name: str
121
+ source: str
122
+ canonical_key: str | None = None
123
+ doc_count: int | None = None
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class RoutingDecision:
128
+ """The pure router's verdict (spec §6d / §17b decisions 3-4).
129
+
130
+ ``executed_mode`` is the mode the caller must dispatch to —
131
+ :data:`LOCAL_MODE`, :data:`THEMES_MODE`, or :data:`GLOBAL_MODE` (the G3-e flip
132
+ made ``global`` dispatchable for explicit ``mode='global'`` and the auto
133
+ thematic-no-person branch). ``requested_mode`` echoes the caller's input mode.
134
+ ``is_thematic`` records the regex-grammar verdict. ``resolved_person`` is the
135
+ person the query routed to (or ``None``). ``degraded_from`` /
136
+ ``degradation_reason`` are **KEEP-DORMANT** (spec §17c Q6): defined for wire
137
+ stability but never populated by G3 — always ``None``.
138
+ """
139
+
140
+ executed_mode: str
141
+ requested_mode: str
142
+ is_thematic: bool
143
+ resolved_person: RoutedPerson | None = None
144
+ degraded_from: str | None = None
145
+ degradation_reason: str | None = None
146
+
147
+
148
+ def route(
149
+ query: str,
150
+ *,
151
+ mode: str,
152
+ person: str | None,
153
+ known_persons: Sequence[KnownPerson],
154
+ ) -> RoutingDecision:
155
+ """Decide the retrieval mode for one query (pure / deterministic).
156
+
157
+ See the module docstring for the full rule set. ``known_persons`` is only
158
+ consulted for the ``auto`` thematic-person scan; explicit modes ignore it.
159
+ Never raises :class:`~brain.errors.GraphModeUnavailable` — the G3-e flip made
160
+ ``global`` a dispatchable mode (spec §17c Q6).
161
+
162
+ Raises:
163
+ ValueError: an unrecognized ``mode`` (caller bug).
164
+ """
165
+ if mode != AUTO_MODE:
166
+ return _route_explicit(query, mode, person)
167
+ return _route_auto(query, person, known_persons)
168
+
169
+
170
+ def _route_explicit(query: str, mode: str, person: str | None) -> RoutingDecision:
171
+ """Honor an explicit (non-``auto``) mode, ``global`` / ``fuse`` included.
172
+
173
+ The G2 ``global`` rejection is gone: an explicit ``mode='global'`` now returns
174
+ a :data:`GLOBAL_MODE` decision (the caller dispatches to the community path).
175
+ An explicit ``mode='fuse'`` returns a :data:`FUSE_MODE` decision (wave G4-c,
176
+ spec §17d Q1 — the caller fuses the graph + hybrid doc legs). Local / themes
177
+ are honored unchanged; an unknown mode is still a caller bug.
178
+ """
179
+ if mode not in (LOCAL_MODE, THEMES_MODE, GLOBAL_MODE, FUSE_MODE):
180
+ raise ValueError(
181
+ f"unknown graph retrieval mode {mode!r} (expected one of "
182
+ f"{AUTO_MODE!r} / {LOCAL_MODE!r} / {THEMES_MODE!r} / "
183
+ f"{GLOBAL_MODE!r} / {FUSE_MODE!r})"
184
+ )
185
+ return RoutingDecision(
186
+ executed_mode=mode,
187
+ requested_mode=mode,
188
+ is_thematic=_is_thematic(query),
189
+ resolved_person=_explicit_person(person),
190
+ )
191
+
192
+
193
+ def _route_auto(
194
+ query: str, person: str | None, known_persons: Sequence[KnownPerson]
195
+ ) -> RoutingDecision:
196
+ """Run the heuristic branches for ``mode='auto'`` (spec dec. 3; §17c Q6 flip)."""
197
+ is_thematic = _is_thematic(query)
198
+ resolved = _explicit_person(person) or _scan_person(query, known_persons)
199
+
200
+ if is_thematic and resolved is not None:
201
+ return RoutingDecision(
202
+ executed_mode=THEMES_MODE,
203
+ requested_mode=AUTO_MODE,
204
+ is_thematic=True,
205
+ resolved_person=resolved,
206
+ )
207
+ if is_thematic:
208
+ # Thematic but no resolvable person → global (the real community path).
209
+ # G3-e flipped this off the former G2 ``global→local`` degradation: no
210
+ # degradation signals are stamped (spec §17c Q6 keeps them dormant).
211
+ return RoutingDecision(
212
+ executed_mode=GLOBAL_MODE,
213
+ requested_mode=AUTO_MODE,
214
+ is_thematic=True,
215
+ resolved_person=None,
216
+ )
217
+ return RoutingDecision(
218
+ executed_mode=LOCAL_MODE,
219
+ requested_mode=AUTO_MODE,
220
+ is_thematic=False,
221
+ resolved_person=resolved,
222
+ )
223
+
224
+
225
+ def _is_thematic(query: str) -> bool:
226
+ """``True`` iff the query matches the closed thematic-intent grammar."""
227
+ if not query:
228
+ return False
229
+ return bool(_THEMATIC_KEYWORDS.search(query) or _THEMATIC_EVOLUTION.search(query))
230
+
231
+
232
+ def _explicit_person(person: str | None) -> RoutedPerson | None:
233
+ """Wrap a non-blank explicit ``person`` arg as the resolved person."""
234
+ if person is None:
235
+ return None
236
+ stripped = person.strip()
237
+ if not stripped:
238
+ return None
239
+ return RoutedPerson(display_name=stripped, source="explicit")
240
+
241
+
242
+ def _scan_person(
243
+ query: str, known_persons: Sequence[KnownPerson]
244
+ ) -> RoutedPerson | None:
245
+ """Token-boundary scan of ``known_persons`` against the query (spec dec. 3-4).
246
+
247
+ For each known person, match its ``canonical_key`` and ``display_name``
248
+ case-insensitively at word boundaries; the person's matched span is the
249
+ longest matching key. The winner is chosen by longest matched span → highest
250
+ ``doc_count`` → lexicographically smallest ``canonical_key`` (the
251
+ ``canonical_key`` is unique per tenant, so the ordering is total and the
252
+ result deterministic). Returns ``None`` when nothing matches.
253
+ """
254
+ if not query or not known_persons:
255
+ return None
256
+ lowered = query.lower()
257
+ best: tuple[int, int, str] | None = None
258
+ winner: KnownPerson | None = None
259
+ for candidate in known_persons:
260
+ span = _best_match_span(lowered, candidate)
261
+ if span <= 0:
262
+ continue
263
+ # Order key: maximize span, then doc_count, then minimize canonical_key.
264
+ key = (-span, -candidate.doc_count, candidate.canonical_key)
265
+ if best is None or key < best:
266
+ best = key
267
+ winner = candidate
268
+ if winner is None:
269
+ return None
270
+ return RoutedPerson(
271
+ display_name=winner.display_name,
272
+ source="scanned",
273
+ canonical_key=winner.canonical_key,
274
+ doc_count=winner.doc_count,
275
+ )
276
+
277
+
278
+ def _best_match_span(lowered_query: str, candidate: KnownPerson) -> int:
279
+ """Longest word-boundary match length of a person's keys (0 = no match)."""
280
+ best = 0
281
+ for needle in (candidate.canonical_key, candidate.display_name):
282
+ normalized = (needle or "").strip().lower()
283
+ if not normalized:
284
+ continue
285
+ pattern = r"\b" + re.escape(normalized) + r"\b"
286
+ if re.search(pattern, lowered_query):
287
+ best = max(best, len(normalized))
288
+ return best
@@ -0,0 +1,320 @@
1
+ """Frozen value objects for the GraphRAG layer (no DB logic).
2
+
3
+ Two families live here:
4
+
5
+ * **Row mirrors** — :class:`GraphEntity`, :class:`EntityMention`,
6
+ :class:`EdgeContribution`, :class:`Edge` map 1:1 onto the migration-012 tables
7
+ (``graph_entities``, ``graph_entity_mentions``, ``graph_edge_contributions``,
8
+ ``graph_relationships``). The raw ``embedding`` vector is deliberately *not*
9
+ carried on :class:`GraphEntity` — like :class:`brain.queries.DocumentRow` and
10
+ :class:`brain.search.SearchResult`, these are read-side value objects, not
11
+ storage handles.
12
+ * **Retrieval value objects** — :class:`ThemeGroup`, :class:`GraphContext`,
13
+ :class:`GraphExplanation` are the wire shape returned by graph retrieval
14
+ (spec §6/§9, §4 D8). They are populated by the G2 retrieval code; the field
15
+ set here is the v1 contract and later waves may extend it additively.
16
+
17
+ All tenantized rows / queries carry ``tenant_id`` (spec §4 D9). It defaults to
18
+ ``"default"`` — the fixed tenant used by single-user local deployments — so the
19
+ local construction path is unchanged. ``ThemeGroup`` is the one exception: it is
20
+ a derived grouping over an already tenant-scoped subgraph, not a row mirror, so
21
+ it carries no ``tenant_id`` of its own.
22
+
23
+ All classes are ``frozen=True`` dataclasses. ``SearchResult`` is referenced only
24
+ under ``TYPE_CHECKING`` (with ``from __future__ import annotations``) so this
25
+ module never imports :mod:`brain.search` at runtime — keeping it free of any
26
+ import cycle with the ingest pipeline that later wires graph reconciliation in.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ from collections.abc import Mapping
31
+ from dataclasses import dataclass, field
32
+ from datetime import datetime
33
+ from typing import TYPE_CHECKING, Any
34
+
35
+ if TYPE_CHECKING:
36
+ from ..search import SearchResult
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class GraphEntity:
41
+ """An entity node — mirrors a ``graph_entities`` row.
42
+
43
+ ``entity_type`` is one of ``person``/``org``/``project``/``topic``/``tool``
44
+ (DB ``CHECK``-enforced). ``canonical_key`` is the dedup key (a resolved
45
+ person-key for people, ``lower(name)`` for concepts) and is unique per
46
+ ``(tenant_id, entity_type, canonical_key)``. ``tenant_id`` scopes the row to
47
+ one tenant (spec §4 D9); single-user local deployments use the fixed default
48
+ tenant ``"default"``. ``doc_count`` is *derived* from mentions and refreshed
49
+ by the aggregate rebuild — never authoritative on write.
50
+ """
51
+
52
+ id: str
53
+ entity_type: str
54
+ name: str
55
+ canonical_key: str
56
+ tenant_id: str = "default"
57
+ description: str | None = None
58
+ doc_count: int = 0
59
+ # Person-scoped count for the active retrieval (themes mode): distinct docs
60
+ # where this entity co-occurs with the queried person. None when not
61
+ # scope-computed (local/global/entity modes). Derived per-query, never stored.
62
+ scoped_doc_count: int | None = None
63
+ properties: dict[str, Any] = field(default_factory=dict)
64
+ created_at: datetime | None = None
65
+ updated_at: datetime | None = None
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class EntityMention:
70
+ """A per-document entity mention — mirrors a ``graph_entity_mentions`` row.
71
+
72
+ Source-of-truth row: re-ingest deletes and reinserts a document's mentions.
73
+ ``source`` records provenance — ``"people"`` for the people pipeline or
74
+ ``"extractor:<model>@<ver>"`` for the concept extractor. ``tenant_id`` scopes
75
+ the row to one tenant (part of the row's primary key; spec §4 D9).
76
+ """
77
+
78
+ entity_id: str
79
+ document_id: str
80
+ source: str
81
+ tenant_id: str = "default"
82
+ mention_count: int = 1
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class EdgeContribution:
87
+ """A per-document raw co-occurrence — mirrors ``graph_edge_contributions``.
88
+
89
+ Source-of-truth row holding the *raw* window co-occurrence count between two
90
+ entities within one document. Endpoints are canonicalized ``src_id < dst_id``
91
+ (DB ``CHECK``-enforced). No generic suppression or weighting is applied here;
92
+ those are derive/query-time concerns. ``tenant_id`` scopes the row to one
93
+ tenant (part of the row's primary key; spec §4 D9).
94
+ """
95
+
96
+ document_id: str
97
+ src_id: str
98
+ dst_id: str
99
+ tenant_id: str = "default"
100
+ cooccur_count: int = 1
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class Edge:
105
+ """A derived aggregate relationship — mirrors a ``graph_relationships`` row.
106
+
107
+ ``weight`` is the normative association metric: normalized lift in ``(0, 1]``
108
+ (recomputed from contributions; doubles as the BFS path affinity). ``co_count``
109
+ is ``SUM`` of contribution counts and ``doc_count`` the distinct-document
110
+ count. Endpoints are canonicalized ``src_id < dst_id`` (DB ``CHECK``-enforced).
111
+ ``tenant_id`` scopes the row to one tenant (part of the row's primary key;
112
+ spec §4 D9).
113
+ """
114
+
115
+ src_id: str
116
+ dst_id: str
117
+ weight: float
118
+ tenant_id: str = "default"
119
+ rel_type: str = "co_occurs"
120
+ co_count: int = 0
121
+ doc_count: int = 0
122
+ updated_at: datetime | None = None
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class CommunityMember:
127
+ """A community ↔ entity membership — mirrors ``graph_community_members``.
128
+
129
+ Wave G3 (spec §17c Q1). One row per entity in a detected community.
130
+ ``member_rank`` orders entities within the community (0-based, most-central
131
+ first) and ``member_weight`` is the entity's weighted degree inside the
132
+ community subgraph; both are DB ``CHECK``-enforced non-negative. At detection
133
+ time the owning ``community_key`` is not yet assigned (a reused key is matched
134
+ by Jaccard, a new one minted at persist), so it defaults to the empty string
135
+ until the persistence layer fills it in. ``tenant_id`` scopes the row to one
136
+ tenant (part of the row's primary key; spec §4 D9).
137
+ """
138
+
139
+ entity_id: str
140
+ member_rank: int = 0
141
+ member_weight: float = 0.0
142
+ community_key: str = ""
143
+ tenant_id: str = "default"
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class CommunityRecord:
148
+ """A detected community — mirrors a ``graph_communities`` row (wave G3).
149
+
150
+ Single-level only (``level`` pinned to 0; spec §17c Q1 / §15). ``community_key``
151
+ is the durable, stable identity preserved across rebuilds by Jaccard matching
152
+ (spec §17c Q3/Q7). ``source_graph_hash`` is the tenant-graph dirty fingerprint
153
+ (an edge hash over ordered ``graph_relationships``); ``members_hash`` is the
154
+ per-community identity hash over the sorted member entity ids. The aggregate
155
+ stats (``member_count``/``edge_count``/``total_weight``) describe the
156
+ community subgraph. The ``summary*`` fields are populated lazily/eagerly at
157
+ build/refresh by G3-c (NULL here at detection); like :class:`GraphEntity` the
158
+ raw ``summary_embedding`` vector is deliberately not carried (read-side value
159
+ object, not a storage handle). ``tenant_id`` scopes the row to one tenant
160
+ (part of the row's primary key; spec §4 D9).
161
+ """
162
+
163
+ community_key: str
164
+ source_graph_hash: str
165
+ members_hash: str
166
+ tenant_id: str = "default"
167
+ level: int = 0
168
+ build_version: str = "networkx-louvain-v1"
169
+ member_count: int = 0
170
+ edge_count: int = 0
171
+ total_weight: float = 0.0
172
+ summary: str | None = None
173
+ summary_model: str | None = None
174
+ summary_at: datetime | None = None
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class ThemeGroup:
179
+ """A cluster of related entities for the "themes with X" headline (spec §6b).
180
+
181
+ Produced by the scoped-subgraph grouping over X's documents. ``entities`` are
182
+ the group's key entities, ``doc_ids`` the representative X-documents, and
183
+ ``score`` the group's total in-scope normalized lift (the ranking metric).
184
+ ``summary`` is an optional on-demand ``summarize_group()`` Ollama synthesis
185
+ (top-K groups only, when ``--synthesize``/MCP ``synthesize=true``).
186
+ """
187
+
188
+ group_id: int
189
+ entities: list[GraphEntity] = field(default_factory=list)
190
+ doc_ids: list[str] = field(default_factory=list)
191
+ score: float = 0.0
192
+ summary: str | None = None
193
+
194
+
195
+ @dataclass(frozen=True)
196
+ class CommunityGroup:
197
+ """A detected community surfaced by global retrieval (spec §6c / §17c Q4-Q5).
198
+
199
+ The ranked unit of the **global** path (wave G3-d): each group is one
200
+ ``graph_communities`` community that surfaced from the community-level RRF
201
+ (FTS over ``summary_tsv`` fused with vector cosine over ``summary_embedding``;
202
+ :func:`brain.graph_rag.global_._retrieve_global`). Distinct from
203
+ :class:`ThemeGroup` (a derived entity cluster over a person's scoped
204
+ subgraph) — a ``CommunityGroup`` mirrors a persisted, pre-summarized
205
+ community.
206
+
207
+ ``community_key`` is the durable community identity, ``level`` the (single)
208
+ detection level (pinned 0; spec §17c Q1), ``member_count`` the full community
209
+ size, and ``score`` the fused RRF score (the ranking metric). ``summary`` is
210
+ the eager community summary (NULL when Ollama was unavailable at build —
211
+ the community then ranked on its FTS leg only). ``entities`` are the
212
+ representative member entities (top by ``member_rank``) and ``doc_ids`` the
213
+ representative documents that most mention the community's entities.
214
+ """
215
+
216
+ community_key: str
217
+ level: int = 0
218
+ member_count: int = 0
219
+ score: float = 0.0
220
+ summary: str | None = None
221
+ entities: list[GraphEntity] = field(default_factory=list)
222
+ doc_ids: list[str] = field(default_factory=list)
223
+
224
+
225
+ @dataclass(frozen=True)
226
+ class GraphExplanation:
227
+ """Per-query graph-retrieval diagnostic (spec §6a/§9; §4 D8).
228
+
229
+ The graph analogue of :class:`brain.search.SearchExplanation`. Records the
230
+ seeds, resolved scope, traversal parameters, and pruning telemetry so a
231
+ caller can see *why* a :class:`GraphContext` looks the way it does. Populated
232
+ by the G2 retrieval code; provisional fields default to safe empties so the
233
+ object is constructible before that wave lands. ``tenant_id`` records the
234
+ tenant the query was scoped to (spec §4 D9 — every graph query injects it).
235
+ """
236
+
237
+ mode: str
238
+ tenant_id: str = "default"
239
+ seed_entity_ids: list[str] = field(default_factory=list)
240
+ person_keys: list[str] = field(default_factory=list)
241
+ depth: int = 0
242
+ frontier_cap: int = 0
243
+ min_edge_weight: float = 0.0
244
+ nodes_visited: int = 0
245
+ edges_considered: int = 0
246
+ generic_df_cap: int | None = None
247
+ matched_filters: dict[str, Any] = field(default_factory=dict)
248
+
249
+
250
+ @dataclass(frozen=True)
251
+ class GraphContext:
252
+ """The distinct envelope returned by graph retrieval (spec §4 D8, §6, §9).
253
+
254
+ Themes/communities are *not* document hits, so graph retrieval returns this
255
+ shape rather than faking :class:`brain.search.SearchResult` parity. ``mode``
256
+ is the resolved retrieval mode (``local``/``themes``/``global``); ``person``
257
+ is set for scoped "themes with X" queries. ``docs`` reuses ``SearchResult``
258
+ for the document-hit portion. ``themes`` is populated for ``themes`` mode,
259
+ ``communities`` for ``global`` mode (wave G3-d; spec §17c Q5), and
260
+ ``entities`` for ``local`` mode. ``tenant_id`` records the tenant the query
261
+ was scoped to (spec §4 D9 — every graph query injects it).
262
+
263
+ Degradation signals (``requested_mode`` / ``degraded_from`` /
264
+ ``degradation_reason``) are **KEPT DORMANT** (spec §17c Q6): they are now
265
+ **always** ``None``. They date to the wave-G2 era, when an ``auto`` thematic
266
+ query with no resolvable person degraded ``global`` → ``local`` and recorded
267
+ the substitution here. The G3-e router flip made ``global`` a real, executing
268
+ mode, so that degradation no longer happens and no code path populates these
269
+ fields. They are retained on the dataclass purely for wire/JSON stability
270
+ (the MCP + CLI ``--json`` shape keeps the keys) — not removed.
271
+ """
272
+
273
+ session_id: str
274
+ mode: str
275
+ query: str
276
+ tenant_id: str = "default"
277
+ person: str | None = None
278
+ themes: list[ThemeGroup] = field(default_factory=list)
279
+ communities: list[CommunityGroup] = field(default_factory=list)
280
+ entities: list[GraphEntity] = field(default_factory=list)
281
+ docs: list[SearchResult] = field(default_factory=list)
282
+ explanation: GraphExplanation | None = None
283
+ requested_mode: str | None = None
284
+ degraded_from: str | None = None
285
+ degradation_reason: str | None = None
286
+
287
+
288
+ @dataclass(frozen=True)
289
+ class EntitySummary:
290
+ """Lightweight entity row for listing — projected from ``graph_entities``.
291
+
292
+ Returned by :func:`brain.graph_rag.relational.list_entities` for the
293
+ ``brain graphrag entities`` admin surface. Does not carry the raw
294
+ ``embedding`` vector (a storage handle, not a wire value — same convention
295
+ as :class:`GraphEntity` and :class:`CommunityRecord`).
296
+ """
297
+
298
+ entity_type: str
299
+ name: str
300
+ canonical_key: str
301
+ doc_count: int
302
+ description: str | None = None
303
+
304
+
305
+ @dataclass(frozen=True)
306
+ class GraphStats:
307
+ """At-a-glance graph overview for ``brain graphrag stats``.
308
+
309
+ Produced by :func:`brain.graph_rag.relational.graph_stats` from the
310
+ tenant's relational tables. ``counts_by_type`` maps each ``entity_type``
311
+ present in ``graph_entities`` to its row count; ``total_entities`` is their
312
+ sum. ``top_entities`` are the top-10 entities by ``doc_count`` (the same
313
+ slice ``brain graphrag entities --limit 10`` would return with sort=docs).
314
+ """
315
+
316
+ counts_by_type: Mapping[str, int]
317
+ total_entities: int
318
+ total_relationships: int
319
+ total_communities: int
320
+ top_entities: tuple[EntitySummary, ...]