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
brain/review/weekly.py ADDED
@@ -0,0 +1,413 @@
1
+ """Weekly-review orchestrator: assemble a :class:`WeeklyReport` from the DB.
2
+
3
+ This module owns *which* sections are assembled and *how* themes are ranked —
4
+ nothing about rendering or vault I/O (those live in ``render`` / ``emit``). It
5
+ reads only existing tables (no new migration): ``interactions`` / ``documents``
6
+ for activity + ingest signal (via :mod:`brain.activity`), ``krisp_action_items``
7
+ docs for open loops (via :mod:`brain.todo`), and the relational graph tables
8
+ (``graph_communities`` / ``graph_community_members`` / ``graph_edge_contributions``
9
+ / ``graph_entities`` / ``graph_entity_mentions``) for the theme clusters.
10
+
11
+ The theme leg has two paths:
12
+
13
+ * **Graph path** (``cfg.graph_enabled and not no_graph``): rank the tenant's
14
+ communities by in-window co-occurrence weight, then attach top entity names,
15
+ representative in-window doc titles, and a best-effort LLM synthesis. These
16
+ reads hit the relational source-of-truth tables (always present after the
17
+ graph migrations) — NOT Apache AGE — so the path is safe on stock pgvector and
18
+ simply yields no themes when the graph has not been built.
19
+ * **Fallback path** (graph disabled, ``--no-graph``, or zero active communities):
20
+ cluster the activity docs by their most-frequent tag. No LLM synthesis.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from datetime import date, datetime
26
+ from typing import Any
27
+
28
+ import psycopg
29
+
30
+ from ..activity import (
31
+ ActivityDoc,
32
+ IngestedDoc,
33
+ iter_activity_docs,
34
+ iter_ingested_docs,
35
+ week_bounds,
36
+ )
37
+ from ..config import Config
38
+ from ..enrichment import OllamaEnricher
39
+ from ..todo import TodoRow, iter_action_item_docs
40
+ from ..wiki.build_people import _doc_participant_keys
41
+
42
+ # Top entity names / representative doc titles attached per graph theme block.
43
+ _THEME_ENTITY_CAP = 3
44
+ _THEME_DOC_CAP = 3
45
+ # Canonical key-people count surfaced in a weekly report.
46
+ _KEY_PEOPLE_CAP = 5
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ThemeBlock:
51
+ """One synthesized theme cluster for the week.
52
+
53
+ ``key`` is the ``community_key`` (graph path) or the tag name (fallback).
54
+ ``entity_names`` are the cluster's top entities (graph) or ``[tag]``
55
+ (fallback). ``docs`` are ``(document_id, title)`` pairs for representative
56
+ in-window documents. ``synthesis`` is the best-effort LLM one-liner (graph
57
+ path only; ``None`` on the fallback path or when Ollama is unavailable).
58
+ """
59
+
60
+ key: str
61
+ entity_names: list[str]
62
+ docs: list[tuple[str, str]]
63
+ synthesis: str | None
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class WeeklyReport:
68
+ """Everything the renderer needs for one weekly review page.
69
+
70
+ ``vault_paths`` maps ``document_id`` → ``documents.vault_path`` for every
71
+ referenced doc that has one, so the renderer can emit wiki-links without
72
+ re-querying (docs without a vault path render as plain titles).
73
+ ``graph_used`` records whether the theme leg took the graph path.
74
+ """
75
+
76
+ week: str
77
+ start_date: date
78
+ end_date: date
79
+ generated_date: date
80
+ themes: list[ThemeBlock]
81
+ activity: list[ActivityDoc]
82
+ open_loops: list[TodoRow]
83
+ ingested: list[IngestedDoc]
84
+ key_people: list[str]
85
+ graph_used: bool
86
+ vault_paths: dict[str, str]
87
+
88
+
89
+ def build_weekly_report(
90
+ conn: psycopg.Connection[Any],
91
+ cfg: Config,
92
+ *,
93
+ week: str,
94
+ generated_on: date,
95
+ no_graph: bool = False,
96
+ enricher: OllamaEnricher | None = None,
97
+ ) -> WeeklyReport:
98
+ """Assemble the :class:`WeeklyReport` for ``week`` (``"YYYY-Www"``).
99
+
100
+ ``generated_on`` is the page's ``date:`` stamp (the caller passes today so
101
+ this function stays free of wall-clock reads and is deterministic under
102
+ test). ``no_graph`` forces the tag-cluster fallback. ``enricher`` enables
103
+ best-effort theme synthesis on the graph path; when ``None`` the theme
104
+ blocks carry entity/doc names without a synthesis sentence.
105
+
106
+ Raises:
107
+ ValueError: ``week`` is not a valid ``"YYYY-Www"`` string (from
108
+ :func:`brain.activity.week_bounds`).
109
+ """
110
+ after, before = week_bounds(week)
111
+
112
+ activity = iter_activity_docs(
113
+ conn, after=after, before=before, limit=cfg.review_activity_limit
114
+ )
115
+ ingested = iter_ingested_docs(
116
+ conn, after=after, before=before, limit=cfg.review_activity_limit
117
+ )
118
+ # Open loops are scoped to the TARGET week, not the last 7 days from NOW():
119
+ # ``iter_action_item_docs(since_days=...)`` is NOW()-relative (todo.py), which
120
+ # would surface the current week's loops for a past --week. Pull all open
121
+ # action items and filter by the requested window so a past-week retrospective
122
+ # shows that week's loops.
123
+ open_loops = [
124
+ row
125
+ for row in iter_action_item_docs(conn, include_closed=False)
126
+ if row.ingested_at is not None and after <= row.ingested_at <= before
127
+ ][: cfg.review_open_loop_limit]
128
+
129
+ use_graph = cfg.graph_enabled and not no_graph
130
+ themes: list[ThemeBlock] = []
131
+ if use_graph:
132
+ themes = _graph_themes(
133
+ conn,
134
+ tenant_id=cfg.graph_tenant_id,
135
+ after=after,
136
+ before=before,
137
+ theme_limit=cfg.review_theme_limit,
138
+ enricher=enricher,
139
+ )
140
+ graph_used = use_graph and bool(themes)
141
+ if not themes:
142
+ themes = _tag_cluster_themes(activity, theme_limit=cfg.review_theme_limit)
143
+
144
+ key_people = _key_people(conn, activity, cap=_KEY_PEOPLE_CAP)
145
+
146
+ vault_paths = _collect_vault_paths(conn, activity, ingested, open_loops, themes)
147
+
148
+ return WeeklyReport(
149
+ week=week,
150
+ start_date=after.date(),
151
+ end_date=before.date(),
152
+ generated_date=generated_on,
153
+ themes=themes,
154
+ activity=activity,
155
+ open_loops=open_loops,
156
+ ingested=ingested,
157
+ key_people=key_people,
158
+ graph_used=graph_used,
159
+ vault_paths=vault_paths,
160
+ )
161
+
162
+
163
+ def weekly_active_communities(
164
+ conn: psycopg.Connection[Any],
165
+ *,
166
+ tenant_id: str,
167
+ after: datetime,
168
+ before: datetime,
169
+ theme_limit: int,
170
+ ) -> list[tuple[str, int]]:
171
+ """Return ``(community_key, weekly_weight)`` for in-window-active communities.
172
+
173
+ Sums ``graph_edge_contributions.cooccur_count`` for edges whose source
174
+ document was ingested in ``[after, before]``, grouped by the community each
175
+ edge endpoint belongs to. Tenant-scoped on both the edge and the membership
176
+ join. Returns ``[]`` when the graph has no in-window activity (no crash) —
177
+ the caller then falls back to tag clusters.
178
+
179
+ The membership join matches an edge once per endpoint that belongs to a
180
+ community, so an **intra-community edge** (both endpoints in the same
181
+ community) matches that community *twice*. Summing directly would
182
+ double-count its weight. The inner ``SELECT DISTINCT`` collapses each
183
+ ``(community, edge)`` pair to one row before the ``SUM`` so an intra-community
184
+ edge contributes its weight once; a cross-community edge still attributes its
185
+ weight to each endpoint's distinct community exactly once.
186
+ """
187
+ rows = conn.execute(
188
+ """
189
+ SELECT community_key, SUM(cooccur_count) AS weekly_weight
190
+ FROM (
191
+ SELECT DISTINCT
192
+ gcm.community_key::text AS community_key,
193
+ gec.document_id,
194
+ gec.src_id,
195
+ gec.dst_id,
196
+ gec.cooccur_count
197
+ FROM graph_edge_contributions gec
198
+ JOIN graph_community_members gcm
199
+ ON gcm.tenant_id = gec.tenant_id
200
+ AND (gcm.entity_id = gec.src_id OR gcm.entity_id = gec.dst_id)
201
+ JOIN documents d ON d.id = gec.document_id
202
+ WHERE gec.tenant_id = %s
203
+ AND d.ingested_at BETWEEN %s AND %s
204
+ ) AS edge_community
205
+ GROUP BY community_key
206
+ ORDER BY weekly_weight DESC, community_key
207
+ LIMIT %s
208
+ """,
209
+ (tenant_id, after, before, theme_limit),
210
+ ).fetchall()
211
+ return [(str(r[0]), int(r[1])) for r in rows]
212
+
213
+
214
+ def _graph_themes(
215
+ conn: psycopg.Connection[Any],
216
+ *,
217
+ tenant_id: str,
218
+ after: datetime,
219
+ before: datetime,
220
+ theme_limit: int,
221
+ enricher: OllamaEnricher | None,
222
+ ) -> list[ThemeBlock]:
223
+ """Build theme blocks from the in-window-active communities (graph path)."""
224
+ blocks: list[ThemeBlock] = []
225
+ for community_key, _weight in weekly_active_communities(
226
+ conn,
227
+ tenant_id=tenant_id,
228
+ after=after,
229
+ before=before,
230
+ theme_limit=theme_limit,
231
+ ):
232
+ entity_names = _community_entity_names(
233
+ conn, tenant_id=tenant_id, community_key=community_key
234
+ )
235
+ docs = _community_window_docs(
236
+ conn,
237
+ tenant_id=tenant_id,
238
+ community_key=community_key,
239
+ after=after,
240
+ before=before,
241
+ )
242
+ synthesis = _community_summary(
243
+ conn, tenant_id=tenant_id, community_key=community_key
244
+ )
245
+ if synthesis is None and enricher is not None and entity_names:
246
+ # Best-effort — summarize_group never raises (logs + returns None
247
+ # when Ollama is unavailable).
248
+ synthesis = enricher.summarize_group(
249
+ person=None,
250
+ entity_names=entity_names,
251
+ doc_titles=[title for _id, title in docs],
252
+ )
253
+ blocks.append(
254
+ ThemeBlock(
255
+ key=community_key,
256
+ entity_names=entity_names,
257
+ docs=docs,
258
+ synthesis=synthesis,
259
+ )
260
+ )
261
+ return blocks
262
+
263
+
264
+ def _community_entity_names(
265
+ conn: psycopg.Connection[Any], *, tenant_id: str, community_key: str
266
+ ) -> list[str]:
267
+ """Top entity names for a community (by member rank then weight)."""
268
+ rows = conn.execute(
269
+ """
270
+ SELECT ge.name
271
+ FROM graph_community_members gcm
272
+ JOIN graph_entities ge
273
+ ON ge.tenant_id = gcm.tenant_id AND ge.id = gcm.entity_id
274
+ WHERE gcm.tenant_id = %s AND gcm.community_key = %s
275
+ ORDER BY gcm.member_rank, gcm.member_weight DESC, ge.name
276
+ LIMIT %s
277
+ """,
278
+ (tenant_id, community_key, _THEME_ENTITY_CAP),
279
+ ).fetchall()
280
+ return [str(r[0]) for r in rows]
281
+
282
+
283
+ def _community_window_docs(
284
+ conn: psycopg.Connection[Any],
285
+ *,
286
+ tenant_id: str,
287
+ community_key: str,
288
+ after: datetime,
289
+ before: datetime,
290
+ ) -> list[tuple[str, str]]:
291
+ """Representative ``(id, title)`` docs in-window for a community's entities."""
292
+ rows = conn.execute(
293
+ """
294
+ SELECT d.id::text, d.title
295
+ FROM documents d
296
+ WHERE d.ingested_at BETWEEN %s AND %s
297
+ AND d.id IN (
298
+ SELECT gem.document_id
299
+ FROM graph_entity_mentions gem
300
+ JOIN graph_community_members gcm
301
+ ON gcm.tenant_id = gem.tenant_id
302
+ AND gcm.entity_id = gem.entity_id
303
+ WHERE gcm.tenant_id = %s AND gcm.community_key = %s
304
+ )
305
+ ORDER BY d.ingested_at DESC, d.id
306
+ LIMIT %s
307
+ """,
308
+ (after, before, tenant_id, community_key, _THEME_DOC_CAP),
309
+ ).fetchall()
310
+ return [(str(r[0]), str(r[1])) for r in rows]
311
+
312
+
313
+ def _community_summary(
314
+ conn: psycopg.Connection[Any], *, tenant_id: str, community_key: str
315
+ ) -> str | None:
316
+ """Return the stored community summary, or ``None`` if not yet materialized."""
317
+ row = conn.execute(
318
+ "SELECT summary FROM graph_communities "
319
+ "WHERE tenant_id = %s AND community_key = %s",
320
+ (tenant_id, community_key),
321
+ ).fetchone()
322
+ if row is None or row[0] is None:
323
+ return None
324
+ summary = str(row[0]).strip()
325
+ return summary or None
326
+
327
+
328
+ def _tag_cluster_themes(
329
+ activity: list[ActivityDoc], *, theme_limit: int
330
+ ) -> list[ThemeBlock]:
331
+ """Fallback theme leg: cluster activity docs by their most-frequent tag.
332
+
333
+ Each doc is bucketed under its first tag (docs with no tags are skipped).
334
+ Buckets are ranked by document count (then tag name) and capped at
335
+ ``theme_limit``. No LLM synthesis on this path.
336
+ """
337
+ buckets: dict[str, list[tuple[str, str]]] = {}
338
+ for doc in activity:
339
+ if not doc.tags:
340
+ continue
341
+ tag = doc.tags[0]
342
+ buckets.setdefault(tag, []).append((doc.document_id, doc.title))
343
+ ranked = sorted(buckets.items(), key=lambda kv: (-len(kv[1]), kv[0]))
344
+ return [
345
+ ThemeBlock(
346
+ key=tag,
347
+ entity_names=[tag],
348
+ docs=docs[:_THEME_DOC_CAP],
349
+ synthesis=None,
350
+ )
351
+ for tag, docs in ranked[:theme_limit]
352
+ ]
353
+
354
+
355
+ def _key_people(
356
+ conn: psycopg.Connection[Any], activity: list[ActivityDoc], *, cap: int
357
+ ) -> list[str]:
358
+ """Tally participant keys across the activity docs; return the top ``cap``.
359
+
360
+ Reuses :func:`brain.wiki.build_people._doc_participant_keys` (imported, not
361
+ copy-pasted) to extract each doc's raw participant keys from its
362
+ ``metadata`` + joined ``sources.kind``. Keys are tallied by frequency
363
+ (ties broken alphabetically) and de-duplicated.
364
+ """
365
+ if not activity:
366
+ return []
367
+ doc_ids = [doc.document_id for doc in activity]
368
+ rows = conn.execute(
369
+ """
370
+ SELECT d.id::text, s.kind, d.metadata
371
+ FROM documents d
372
+ LEFT JOIN sources s ON s.id = d.source_id
373
+ WHERE d.id = ANY(%s)
374
+ """,
375
+ (doc_ids,),
376
+ ).fetchall()
377
+ counts: dict[str, int] = {}
378
+ for _doc_id, source_kind, metadata in rows:
379
+ meta: dict[str, Any] = dict(metadata) if metadata else {}
380
+ keys = _doc_participant_keys(source_kind=source_kind or "", metadata=meta)
381
+ for key in keys:
382
+ counts[key] = counts.get(key, 0) + 1
383
+ ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
384
+ return [key for key, _count in ranked[:cap]]
385
+
386
+
387
+ def _collect_vault_paths(
388
+ conn: psycopg.Connection[Any],
389
+ activity: list[ActivityDoc],
390
+ ingested: list[IngestedDoc],
391
+ open_loops: list[TodoRow],
392
+ themes: list[ThemeBlock],
393
+ ) -> dict[str, str]:
394
+ """Map ``document_id`` → ``vault_path`` for every referenced doc that has one.
395
+
396
+ A single batched lookup over the union of all referenced ids so the renderer
397
+ can emit wiki-links (``[[<vault-path>|<title>]]``) for the docs that are
398
+ mirrored to the vault, and plain titles for those that are not.
399
+ """
400
+ ids: set[str] = set()
401
+ ids.update(doc.document_id for doc in activity)
402
+ ids.update(doc.document_id for doc in ingested)
403
+ ids.update(row.document_id for row in open_loops)
404
+ for block in themes:
405
+ ids.update(doc_id for doc_id, _title in block.docs)
406
+ if not ids:
407
+ return {}
408
+ rows = conn.execute(
409
+ "SELECT id::text, vault_path FROM documents "
410
+ "WHERE id = ANY(%s) AND vault_path IS NOT NULL",
411
+ (list(ids),),
412
+ ).fetchall()
413
+ return {str(r[0]): str(r[1]) for r in rows}