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,501 @@
1
+ """Scope-first "themes with X" graph retrieval (spec §6b — the HEADLINE feature).
2
+
3
+ Extracted from :mod:`brain.graph_rag.retrieve` (the G2 wave-boundary file-size
4
+ split, mirroring the G1 :mod:`brain.graph_rag.aggregates` / G2-c
5
+ :mod:`brain.graph_rag.relational` extractions). The public dispatch entry
6
+ :func:`brain.graph_rag.retrieve.graph_rag_search` calls :func:`_retrieve_themes`
7
+ here when the router resolves ``mode='themes'``; the shared entity-row +
8
+ doc-result helpers live in :mod:`brain.graph_rag._retrieval_common`. This is a
9
+ pure move — no behavior change.
10
+
11
+ The algorithm (spec §6b): scope to a person ``X`` via
12
+ :meth:`~brain.graph_rag.backends.base.GraphBackend.scope_person`, compute the
13
+ **in-scope normalized lift** over ``graph_edge_contributions`` restricted to X's
14
+ documents, suppress entities that are **generic across the whole tenant** (the
15
+ corpus-wide df vs the corpus cap — matching derive-time suppression in
16
+ :func:`brain.graph_rag.aggregates._recompute_aggregates`), **exclude the seed X
17
+ and the owner**, group the scoped subgraph into ranked
18
+ :class:`~brain.graph_rag.schema.ThemeGroup`s
19
+ (:func:`brain.graph_rag.grouping.group_themes`), populate each group with its
20
+ representative X-documents + snippets, and (opt-in ``synthesize``) attach a
21
+ best-effort Ollama group summary. Cypher provides the scoping; SQL provides the
22
+ weighting/evidence; Python shapes the ``GraphContext`` (spec §6b closing note).
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from dataclasses import replace
28
+ from typing import TYPE_CHECKING, Any, Protocol
29
+
30
+ import psycopg
31
+
32
+ from ._retrieval_common import _build_doc_results, _fetch_entities
33
+ from .grouping import group_themes
34
+ from .router import THEMES_MODE
35
+ from .schema import Edge, GraphContext, GraphEntity, GraphExplanation, ThemeGroup
36
+ from .weighting import generic_df_cap, is_generic_entity, normalized_lift
37
+
38
+ if TYPE_CHECKING:
39
+ from ..search import SearchResult
40
+ from .backends.base import GraphBackend
41
+
42
+ _logger = logging.getLogger(__name__)
43
+
44
+
45
+ class _GroupSummarizer(Protocol):
46
+ """Structural type for the optional ``--synthesize`` enricher (DI seam).
47
+
48
+ The themes path depends only on this one method, so production injects a
49
+ :class:`brain.enrichment.OllamaEnricher` and tests inject a fake — neither is
50
+ imported here. ``summarize_group`` is best-effort and **never raises**
51
+ (returns ``None`` + a WARN on Ollama failure), so the themes retrieval never
52
+ becomes a hard live-Ollama dependency (spec §17b decision 7).
53
+ """
54
+
55
+ def summarize_group(
56
+ self,
57
+ *,
58
+ person: str | None,
59
+ entity_names: list[str],
60
+ doc_titles: list[str],
61
+ ) -> str | None: ...
62
+
63
+
64
+ # --------------------------------------------------------------------------- #
65
+ # Themes-with-X orchestration (spec §6b — the HEADLINE feature)
66
+ # --------------------------------------------------------------------------- #
67
+ def _retrieve_themes(
68
+ conn: psycopg.Connection[Any],
69
+ *,
70
+ person: str,
71
+ query: str,
72
+ backend: GraphBackend,
73
+ tenant_id: str,
74
+ frontier_cap: int,
75
+ min_edge_weight: float,
76
+ generic_df_ratio: float,
77
+ owner_keys: frozenset[str],
78
+ theme_limit: int,
79
+ limit: int,
80
+ session_id: str,
81
+ synthesize: bool,
82
+ enricher: _GroupSummarizer | None,
83
+ ) -> GraphContext:
84
+ """Run scope-first "themes with X" retrieval + assemble its ``GraphContext``.
85
+
86
+ Scope X (:meth:`GraphBackend.scope_person`), compute the in-scope normalized
87
+ lift over ``graph_edge_contributions`` restricted to X's documents, suppress
88
+ generic entities, exclude X + owner, group the scoped subgraph, populate each
89
+ group's representative X-docs + snippets, and optionally synthesize a summary.
90
+
91
+ Empty at any stage (no person vertex in the graph, an empty scope, no
92
+ eligible entities) flows through to an empty-but-valid context — never-raise,
93
+ mirroring the local no-seed path. ``PersonNotFound`` / ``PersonAmbiguous``
94
+ from the resolver are *not* swallowed (the CLI/MCP map them in G2-h/i).
95
+ """
96
+ # Late import: brain.queries → wiki.build_people pulls a heavier subtree;
97
+ # resolving lazily keeps brain.graph_rag import-cheap (mirrors the
98
+ # brain.search late import in _build_doc_results).
99
+ from ..queries import resolve_person_to_keys
100
+
101
+ # 1. Resolve the person string → canonical participant keys (deterministic;
102
+ # raises PersonNotFound / PersonAmbiguous for the CLI/MCP to map).
103
+ match = resolve_person_to_keys(conn, person)
104
+ person_display = match.display_name
105
+
106
+ # 2. Map the resolved person → seed person ENTITY ids, and the owner → its
107
+ # entity ids; both sets are excluded from the theme-eligible set (§17.5).
108
+ seed_entity_ids = _person_entity_ids(conn, tenant_id, match.keys)
109
+ owner_entity_ids = _person_entity_ids(
110
+ conn, tenant_id, sorted(key.lower() for key in owner_keys)
111
+ )
112
+ seed_set = set(seed_entity_ids)
113
+ owner_set = set(owner_entity_ids)
114
+
115
+ # 3. Scope: union each seed's co-mentioned entities + connecting documents.
116
+ scope_entity_uuids: set[str] = set()
117
+ scope_doc_uuids: set[str] = set()
118
+ for seed_id in seed_entity_ids:
119
+ scope = backend.scope_person(
120
+ conn, tenant_id, seed_id, frontier_cap=frontier_cap
121
+ )
122
+ scope_entity_uuids.update(scope.entity_uuids)
123
+ scope_doc_uuids.update(scope.document_uuids)
124
+ scope_doc_ids = sorted(scope_doc_uuids)
125
+
126
+ # 4. Candidate theme entities: co-mentioned, minus X (every seed) + owner.
127
+ candidate_ids = sorted(scope_entity_uuids - seed_set - owner_set)
128
+
129
+ # 5. Generic suppression uses the CORPUS-WIDE document frequency vs the corpus
130
+ # cap — matching the derive-time suppression in
131
+ # ``aggregates._recompute_aggregates`` (spec §6b step 2 / §7). The in-scope
132
+ # df (max = |X's docs|) almost never reaches the corpus-sized cap, so a
133
+ # person/concept that is generic across the whole tenant but co-occurs with
134
+ # X in only a few of X's docs would wrongly survive into X's themes. The
135
+ # in-scope df is retained ONLY as the lift normalizer in ``_in_scope_edges``
136
+ # (the ``eid in in_scope_df`` guard also ensures every eligible endpoint has
137
+ # a normalizer value there).
138
+ in_scope_df = _in_scope_df(conn, tenant_id, scope_doc_ids, candidate_ids)
139
+ corpus_df = _corpus_df(conn, tenant_id, candidate_ids)
140
+ cap = generic_df_cap(_corpus_doc_count(conn, tenant_id), generic_df_ratio)
141
+ eligible_ids = [
142
+ eid
143
+ for eid in candidate_ids
144
+ if eid in in_scope_df and not is_generic_entity(corpus_df.get(eid, 0), cap)
145
+ ]
146
+
147
+ # 6. In-scope normalized-lift edges among eligible entities → grouping.
148
+ # Thread ``in_scope_df`` (person-scoped distinct-doc count) onto each entity
149
+ # before grouping so callers can show "N docs with X" per entity (A2).
150
+ # Every eligible_id is guaranteed present in in_scope_df (the eligibility
151
+ # guard at step 5 requires ``eid in in_scope_df``), so ``.get(e.id, 0)`` is
152
+ # purely defensive and is never reached in production.
153
+ edges = _in_scope_edges(conn, tenant_id, scope_doc_ids, eligible_ids, in_scope_df)
154
+ entities = [
155
+ replace(e, scoped_doc_count=in_scope_df.get(e.id, 0))
156
+ for e in _fetch_entities(conn, tenant_id, eligible_ids)
157
+ ]
158
+ groups = group_themes(
159
+ entities, edges, min_edge_weight=min_edge_weight, theme_limit=theme_limit
160
+ )
161
+
162
+ # 7. Representative X-docs + snippets per group (and the context-level docs).
163
+ groups, doc_results = _populate_theme_docs(
164
+ conn, tenant_id, query, groups, scope_doc_ids, limit
165
+ )
166
+
167
+ # 8. Optional best-effort group synthesis (never required for retrieval).
168
+ if synthesize:
169
+ groups = _synthesize_groups(conn, groups, person_display, enricher)
170
+
171
+ explanation = GraphExplanation(
172
+ mode=THEMES_MODE,
173
+ tenant_id=tenant_id,
174
+ seed_entity_ids=seed_entity_ids,
175
+ person_keys=list(match.keys),
176
+ depth=0, # themes scopes person→docs→co-entities; no depth traversal
177
+ frontier_cap=frontier_cap,
178
+ min_edge_weight=min_edge_weight,
179
+ nodes_visited=len(eligible_ids),
180
+ edges_considered=len(edges),
181
+ generic_df_cap=cap,
182
+ matched_filters={
183
+ "person": person_display,
184
+ "person_keys": list(match.keys),
185
+ "seed_entity_count": len(seed_entity_ids),
186
+ "scope_doc_count": len(scope_doc_ids),
187
+ "scope_entity_count": len(scope_entity_uuids),
188
+ "eligible_entity_count": len(eligible_ids),
189
+ "generic_df_cap": cap,
190
+ "theme_count": len(groups),
191
+ "theme_limit": theme_limit,
192
+ "min_edge_weight": min_edge_weight,
193
+ "frontier_cap": frontier_cap,
194
+ "synthesize": synthesize,
195
+ },
196
+ )
197
+ return GraphContext(
198
+ session_id=session_id,
199
+ mode=THEMES_MODE,
200
+ query=query,
201
+ tenant_id=tenant_id,
202
+ person=person_display,
203
+ themes=groups,
204
+ entities=_theme_member_entities(groups),
205
+ docs=doc_results,
206
+ explanation=explanation,
207
+ )
208
+
209
+
210
+ def _person_entity_ids(
211
+ conn: psycopg.Connection[Any], tenant_id: str, keys: list[str]
212
+ ) -> list[str]:
213
+ """Resolve participant ``keys`` to ``person`` entity ids (tenant-scoped).
214
+
215
+ A person ``graph_entities`` row's ``canonical_key`` is the lowercased
216
+ People-Hub display name; ``keys`` (from ``resolve_person_to_keys`` / the
217
+ owner key set) carry that same lowercased form alongside emails, so the
218
+ name key matches and the email keys harmlessly miss. Ordered by id for a
219
+ deterministic seed list. An empty ``keys`` resolves to no entities.
220
+ """
221
+ if not keys:
222
+ return []
223
+ rows = conn.execute(
224
+ "SELECT id::text FROM graph_entities "
225
+ "WHERE tenant_id = %s AND entity_type = 'person' "
226
+ "AND lower(canonical_key) = ANY(%s) ORDER BY id",
227
+ (tenant_id, keys),
228
+ ).fetchall()
229
+ return [str(row[0]) for row in rows]
230
+
231
+
232
+ def _corpus_doc_count(conn: psycopg.Connection[Any], tenant_id: str) -> int:
233
+ """Tenant corpus N = distinct documents with any mention (spec §6b step 2).
234
+
235
+ Identical definition to the reconcile recompute path
236
+ (:func:`brain.graph_rag.aggregates._recompute_aggregates`) so the themes
237
+ generic cap matches the derive-time suppression cap — never recomputed
238
+ differently.
239
+ """
240
+ row = conn.execute(
241
+ "SELECT COUNT(DISTINCT document_id) FROM graph_entity_mentions "
242
+ "WHERE tenant_id = %s",
243
+ (tenant_id,),
244
+ ).fetchone()
245
+ return int(row[0]) if row is not None else 0
246
+
247
+
248
+ def _corpus_df(
249
+ conn: psycopg.Connection[Any],
250
+ tenant_id: str,
251
+ entity_ids: list[str],
252
+ ) -> dict[str, int]:
253
+ """Per-entity CORPUS-WIDE document frequency (``graph_entities.doc_count``).
254
+
255
+ The maintained tenant-wide df refreshed by
256
+ :func:`brain.graph_rag.aggregates._recompute_aggregates`, so the themes
257
+ generic-suppression filter (:func:`_retrieve_themes` step 5) uses the SAME df
258
+ scope as derive-time suppression — corpus-wide df vs the corpus cap — instead
259
+ of the in-scope df (whose max is ``|X's docs|`` and so would leave the
260
+ corpus-sized cap effectively unreachable, never suppressing a tenant-generic
261
+ entity that merely co-occurs with X). An empty entity set yields an empty map;
262
+ an entity with no catalog row is simply absent (the caller treats a miss as
263
+ df 0, i.e. not generic — defensive, never reached for catalog ids).
264
+ """
265
+ if not entity_ids:
266
+ return {}
267
+ rows = conn.execute(
268
+ "SELECT id::text, doc_count FROM graph_entities "
269
+ "WHERE tenant_id = %s AND id = ANY(%s)",
270
+ (tenant_id, entity_ids),
271
+ ).fetchall()
272
+ return {str(entity_id): int(count) for entity_id, count in rows}
273
+
274
+
275
+ def _in_scope_df(
276
+ conn: psycopg.Connection[Any],
277
+ tenant_id: str,
278
+ doc_ids: list[str],
279
+ entity_ids: list[str],
280
+ ) -> dict[str, int]:
281
+ """Per-entity document frequency WITHIN X's scoped document set.
282
+
283
+ ``df_in_scope[e]`` = distinct ``doc_ids`` (X's docs) that mention ``e``. The
284
+ normalizer + generic-suppression input for the in-scope lift (spec §6b
285
+ step 2). An empty doc/entity set yields an empty map.
286
+ """
287
+ if not doc_ids or not entity_ids:
288
+ return {}
289
+ rows = conn.execute(
290
+ "SELECT entity_id::text, COUNT(DISTINCT document_id) "
291
+ "FROM graph_entity_mentions "
292
+ "WHERE tenant_id = %s AND document_id = ANY(%s) AND entity_id = ANY(%s) "
293
+ "GROUP BY entity_id",
294
+ (tenant_id, doc_ids, entity_ids),
295
+ ).fetchall()
296
+ return {str(entity_id): int(count) for entity_id, count in rows}
297
+
298
+
299
+ def _in_scope_edges(
300
+ conn: psycopg.Connection[Any],
301
+ tenant_id: str,
302
+ doc_ids: list[str],
303
+ eligible_ids: list[str],
304
+ in_scope_df: dict[str, int],
305
+ ) -> list[Edge]:
306
+ """In-scope normalized-lift edges among eligible entities (spec §6b step 2).
307
+
308
+ For each canonical pair of eligible entities, ``co_doc`` = distinct X-docs in
309
+ which both co-occur (``graph_edge_contributions`` restricted to ``doc_ids``);
310
+ the weight is ``normalized_lift(co_doc, df_src_in_scope, df_dst_in_scope)``.
311
+ Both endpoints are constrained to ``eligible_ids`` so a suppressed/excluded
312
+ entity never forms an edge. Fewer than two eligible entities → no edges.
313
+
314
+ **Relational-mirror-drift tolerance.** ``co_doc`` (from
315
+ ``graph_edge_contributions``) and each endpoint's in-scope df (from
316
+ ``graph_entity_mentions``) come from two separate relational tables that can
317
+ fall out of sync — e.g. an edge-contribution row whose corresponding mention
318
+ row was never written. When that happens ``co_doc`` can exceed the rarer
319
+ endpoint's in-scope df, which ``normalized_lift`` rejects with a
320
+ ``WeightingError`` that would otherwise abort the ENTIRE ``themes --person``
321
+ query. To keep one drifted edge from killing the whole query, a drifted edge
322
+ is clamped to a valid weight (``co_doc → min(co_doc, min(df))``) with a WARN;
323
+ an edge missing an in-scope normalizer entirely (df 0 — no valid lift) is
324
+ skip-logged instead.
325
+ """
326
+ if not doc_ids or len(eligible_ids) < 2:
327
+ return []
328
+ rows = conn.execute(
329
+ "SELECT src_id::text, dst_id::text, COUNT(DISTINCT document_id) "
330
+ "FROM graph_edge_contributions "
331
+ "WHERE tenant_id = %s AND document_id = ANY(%s) "
332
+ "AND src_id = ANY(%s) AND dst_id = ANY(%s) "
333
+ "GROUP BY src_id, dst_id",
334
+ (tenant_id, doc_ids, eligible_ids, eligible_ids),
335
+ ).fetchall()
336
+ edges: list[Edge] = []
337
+ for src_id, dst_id, co_doc in rows:
338
+ src = str(src_id)
339
+ dst = str(dst_id)
340
+ df_src = in_scope_df.get(src, 0)
341
+ df_dst = in_scope_df.get(dst, 0)
342
+ min_df = min(df_src, df_dst)
343
+ co = int(co_doc)
344
+ if min_df < 1 or co < 1:
345
+ # Mirror drift: an endpoint has no in-scope mention row (df 0), so the
346
+ # edge has no valid normalizer. Skip it — a single drifted edge must
347
+ # never abort the whole themes query.
348
+ _logger.warning(
349
+ "themes: skipping drifted in-scope edge (%s, %s): missing "
350
+ "normalizer (df_src=%d df_dst=%d co_doc=%d) — relational mirror "
351
+ "drift between graph_edge_contributions and graph_entity_mentions",
352
+ src, dst, df_src, df_dst, co,
353
+ )
354
+ continue
355
+ if co > min_df:
356
+ # Mirror drift: the edge's in-scope co-document count exceeds the
357
+ # rarer endpoint's in-scope df. Clamp to the valid maximum so
358
+ # normalized_lift never raises (co_doc → min(co_doc, min(df))).
359
+ _logger.warning(
360
+ "themes: clamping drifted in-scope edge (%s, %s): co_doc %d "
361
+ "exceeds min in-scope df %d — relational mirror drift between "
362
+ "graph_edge_contributions and graph_entity_mentions",
363
+ src, dst, co, min_df,
364
+ )
365
+ co = min_df
366
+ weight = normalized_lift(co, df_src, df_dst)
367
+ edges.append(Edge(src_id=src, dst_id=dst, weight=weight, tenant_id=tenant_id))
368
+ return edges
369
+
370
+
371
+ def _docs_by_entity(
372
+ conn: psycopg.Connection[Any],
373
+ tenant_id: str,
374
+ doc_ids: list[str],
375
+ entity_ids: list[str],
376
+ ) -> dict[str, set[str]]:
377
+ """Map each entity id → the set of X-docs (in ``doc_ids``) mentioning it."""
378
+ if not doc_ids or not entity_ids:
379
+ return {}
380
+ rows = conn.execute(
381
+ "SELECT entity_id::text, document_id::text FROM graph_entity_mentions "
382
+ "WHERE tenant_id = %s AND document_id = ANY(%s) AND entity_id = ANY(%s)",
383
+ (tenant_id, doc_ids, entity_ids),
384
+ ).fetchall()
385
+ out: dict[str, set[str]] = {}
386
+ for entity_id, document_id in rows:
387
+ out.setdefault(str(entity_id), set()).add(str(document_id))
388
+ return out
389
+
390
+
391
+ def _populate_theme_docs(
392
+ conn: psycopg.Connection[Any],
393
+ tenant_id: str,
394
+ query: str,
395
+ groups: list[ThemeGroup],
396
+ scope_doc_ids: list[str],
397
+ limit: int,
398
+ ) -> tuple[list[ThemeGroup], list[SearchResult]]:
399
+ """Attach representative X-docs to each group + build the context docs.
400
+
401
+ A group's representative docs are X's scoped documents that mention the
402
+ group's entities, ranked by the count of the group's entities present
403
+ (DESC), then document id (ASC) — deterministic — and capped at ``limit``.
404
+ The context-level ``docs`` rank every contributing X-doc by the count of
405
+ eligible entities it mentions and reuse :func:`_build_doc_results` for the
406
+ snippet path (so themes docs match the local/hybrid snippet shape).
407
+ """
408
+ if not groups:
409
+ return [], []
410
+ member_ids = sorted({entity.id for group in groups for entity in group.entities})
411
+ docs_by_entity = _docs_by_entity(conn, tenant_id, scope_doc_ids, member_ids)
412
+
413
+ populated: list[ThemeGroup] = []
414
+ global_doc_score: dict[str, float] = {}
415
+ for group in groups:
416
+ doc_score: dict[str, int] = {}
417
+ for entity in group.entities:
418
+ for doc_id in docs_by_entity.get(entity.id, set()):
419
+ doc_score[doc_id] = doc_score.get(doc_id, 0) + 1
420
+ ranked = sorted(doc_score.items(), key=lambda kv: (-kv[1], kv[0]))[:limit]
421
+ populated.append(replace(group, doc_ids=[doc_id for doc_id, _ in ranked]))
422
+ for doc_id, count in doc_score.items():
423
+ global_doc_score[doc_id] = global_doc_score.get(doc_id, 0.0) + count
424
+
425
+ global_ranked = sorted(
426
+ global_doc_score.items(), key=lambda kv: (-kv[1], kv[0])
427
+ )[:limit]
428
+ return populated, _build_doc_results(conn, query, global_ranked)
429
+
430
+
431
+ def _synthesize_groups(
432
+ conn: psycopg.Connection[Any],
433
+ groups: list[ThemeGroup],
434
+ person_display: str,
435
+ enricher: _GroupSummarizer | None,
436
+ ) -> list[ThemeGroup]:
437
+ """Attach a best-effort Ollama summary to each group (spec §17b decision 7).
438
+
439
+ Opt-in (``--synthesize``); ``summarize_group`` is best-effort and never
440
+ raises (returns ``None`` + a WARN on Ollama failure), so retrieval succeeds
441
+ regardless. A ``synthesize=True`` call with no injected ``enricher`` is a
442
+ caller wiring gap — logged WARN, summaries left ``None`` (still never-raise).
443
+ """
444
+ if not groups:
445
+ return groups
446
+ if enricher is None:
447
+ _logger.warning(
448
+ "themes synthesize=True but no enricher injected; summaries stay None"
449
+ )
450
+ return groups
451
+ all_doc_ids = sorted({doc_id for group in groups for doc_id in group.doc_ids})
452
+ title_by_doc = _fetch_doc_titles(conn, all_doc_ids)
453
+ summarized: list[ThemeGroup] = []
454
+ for group in groups:
455
+ titles = [
456
+ title_by_doc[doc_id] for doc_id in group.doc_ids if doc_id in title_by_doc
457
+ ]
458
+ try:
459
+ summary = enricher.summarize_group(
460
+ person=person_display,
461
+ entity_names=[entity.name for entity in group.entities],
462
+ doc_titles=titles,
463
+ )
464
+ except Exception as exc: # noqa: BLE001 -- best-effort: never fail retrieval
465
+ # summarize_group already returns None on Ollama down/timeout/invalid
466
+ # (spec §17b decision 7); this guard is defence-in-depth at the
467
+ # optional-side-work boundary (mirrors GraphSyncer) so even a
468
+ # misbehaving injected enricher can never turn themes retrieval into
469
+ # a hard live-Ollama dependency.
470
+ _logger.warning(
471
+ "themes synthesize: group %d summary failed (%s); summary=None",
472
+ group.group_id,
473
+ exc,
474
+ )
475
+ summary = None
476
+ summarized.append(replace(group, summary=summary))
477
+ return summarized
478
+
479
+
480
+ def _fetch_doc_titles(
481
+ conn: psycopg.Connection[Any], doc_ids: list[str]
482
+ ) -> dict[str, str]:
483
+ """Map document id → title for the group-synthesis prompt (no body content)."""
484
+ if not doc_ids:
485
+ return {}
486
+ rows = conn.execute(
487
+ "SELECT id::text, title FROM documents WHERE id = ANY(%s)",
488
+ (doc_ids,),
489
+ ).fetchall()
490
+ return {str(row[0]): str(row[1]) for row in rows}
491
+
492
+
493
+ def _theme_member_entities(groups: list[ThemeGroup]) -> list[GraphEntity]:
494
+ """Flat, deduped, deterministically-sorted entity list across all groups."""
495
+ by_id: dict[str, GraphEntity] = {}
496
+ for group in groups:
497
+ for entity in group.entities:
498
+ by_id.setdefault(entity.id, entity)
499
+ return sorted(
500
+ by_id.values(), key=lambda e: (e.canonical_key, e.entity_type, e.id)
501
+ )