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,436 @@
1
+ """SQL helpers for ``brain review scan`` (Plan 03 — contradiction + staleness).
2
+
3
+ One reason to change: the shape of ``elicitation_gaps`` or the GraphRAG tables
4
+ this module reads (``graph_entities`` / ``graph_entity_mentions`` /
5
+ ``documents`` / ``chunks``). All queries are parameterized and tenant-scoped
6
+ where a tenant column exists. The pure scan logic lives in :mod:`brain.review.scans`.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ import psycopg
15
+
16
+ # Review-finding signal kinds owned by this surface. ``brain review list`` /
17
+ # ``dismiss`` only ever touch these two; the other ``elicitation_gaps`` kinds
18
+ # (delta / orphan / contradiction-from-elicit / user_flagged) belong to
19
+ # ``brain elicit``.
20
+ REVIEW_SIGNAL_KINDS: tuple[str, ...] = ("contradiction", "stale")
21
+
22
+ # Document content types that are historical records, not living claims — never
23
+ # eligible as staleness candidates (a transcript of a past meeting does not go
24
+ # "stale" the way a compensation-range note does).
25
+ _STALE_EXCLUDED_CONTENT_TYPES: tuple[str, ...] = ("transcript", "krisp_action_items")
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class EntityCandidate:
30
+ """One entity surviving the conflict-scan graph prefilter (Step 1)."""
31
+
32
+ canonical_key: str
33
+ name: str
34
+ entity_type: str
35
+ doc_ids: list[str]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class StaleCandidate:
40
+ """One aged document eligible for the staleness scan (Step 1)."""
41
+
42
+ doc_id: str
43
+ title: str
44
+ age_days: int
45
+ content_type: str
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class SupersedingDoc:
50
+ """A newer document sharing an entity with a stale candidate (Step 2)."""
51
+
52
+ doc_id: str
53
+ title: str
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class QueueRow:
58
+ """One row of the ``brain review list`` queue read-back."""
59
+
60
+ id: str
61
+ signal_kind: str
62
+ target_type: str
63
+ target_id: str
64
+ score: float
65
+ evidence_ids: list[str]
66
+ rationale: str
67
+ status: str
68
+
69
+
70
+ def iter_entities_for_conflict_scan(
71
+ conn: psycopg.Connection[Any],
72
+ *,
73
+ tenant_id: str,
74
+ min_docs: int,
75
+ limit: int,
76
+ ) -> list[EntityCandidate]:
77
+ """Graph prefilter: entities mentioned in >= ``min_docs`` summarized docs.
78
+
79
+ Only non-draft documents with a non-null ``summary`` count toward the
80
+ threshold (the summary is the evidence text fed to the LLM). Ordered by
81
+ distinct-doc count DESC and capped at ``limit`` so the LLM-call budget is
82
+ bounded. Pure SQL — no LLM, no embedder.
83
+ """
84
+ rows = conn.execute(
85
+ """
86
+ SELECT ge.canonical_key, ge.name, ge.entity_type,
87
+ array_agg(DISTINCT gem.document_id::text) AS doc_ids
88
+ FROM graph_entity_mentions gem
89
+ JOIN graph_entities ge
90
+ ON ge.id = gem.entity_id AND ge.tenant_id = gem.tenant_id
91
+ JOIN documents d ON d.id = gem.document_id
92
+ WHERE gem.tenant_id = %s
93
+ AND d.summary IS NOT NULL
94
+ AND d.draft IS NOT TRUE
95
+ GROUP BY ge.id, ge.canonical_key, ge.name, ge.entity_type
96
+ HAVING count(DISTINCT gem.document_id) >= %s
97
+ ORDER BY count(DISTINCT gem.document_id) DESC
98
+ LIMIT %s
99
+ """,
100
+ (tenant_id, min_docs, limit),
101
+ ).fetchall()
102
+ return [
103
+ EntityCandidate(
104
+ canonical_key=ckey,
105
+ name=name,
106
+ entity_type=etype,
107
+ doc_ids=list(doc_ids),
108
+ )
109
+ for (ckey, name, etype, doc_ids) in rows
110
+ ]
111
+
112
+
113
+ def count_conflict_docs_missing_summary(
114
+ conn: psycopg.Connection[Any], *, tenant_id: str
115
+ ) -> int:
116
+ """Count non-draft docs referenced by an entity that still lack a summary.
117
+
118
+ Surfaced by ``brain review scan --conflicts --dry-run`` to nudge the user
119
+ toward ``brain enrich --backfill`` — these documents are silently excluded
120
+ from the graph prefilter above.
121
+ """
122
+ row = conn.execute(
123
+ """
124
+ SELECT count(DISTINCT d.id)
125
+ FROM graph_entity_mentions gem
126
+ JOIN documents d ON d.id = gem.document_id
127
+ WHERE gem.tenant_id = %s
128
+ AND d.draft IS NOT TRUE
129
+ AND d.summary IS NULL
130
+ """,
131
+ (tenant_id,),
132
+ ).fetchone()
133
+ return int(row[0]) if row else 0
134
+
135
+
136
+ def iter_docs_for_staleness_scan(
137
+ conn: psycopg.Connection[Any],
138
+ *,
139
+ tenant_id: str,
140
+ stale_age_days: int,
141
+ limit: int,
142
+ ) -> list[StaleCandidate]:
143
+ """Age candidates: non-draft, summarized, non-transcript docs older than N days.
144
+
145
+ ``documents`` has no tenant column, so the scan is tenant-scoped via an
146
+ ``EXISTS`` on ``graph_entity_mentions``: only docs that participate in this
147
+ tenant's entity graph are candidates. That is exactly the set that could
148
+ ever yield a finding — the superseding-doc lookup (Step 2) requires a
149
+ tenant-shared entity — so the ``EXISTS`` both enforces tenant isolation and
150
+ skips docs that can never supersede. Oldest first; capped at ``limit``.
151
+ """
152
+ rows = conn.execute(
153
+ """
154
+ SELECT d.id::text, d.title,
155
+ (now()::date - d.ingested_at::date) AS age_days,
156
+ d.content_type
157
+ FROM documents d
158
+ WHERE d.ingested_at < now() - make_interval(days => %s)
159
+ AND d.content_type <> ALL(%s)
160
+ AND d.draft IS NOT TRUE
161
+ AND d.summary IS NOT NULL
162
+ AND EXISTS (
163
+ SELECT 1 FROM graph_entity_mentions gem
164
+ WHERE gem.document_id = d.id AND gem.tenant_id = %s
165
+ )
166
+ ORDER BY d.ingested_at ASC
167
+ LIMIT %s
168
+ """,
169
+ (stale_age_days, list(_STALE_EXCLUDED_CONTENT_TYPES), tenant_id, limit),
170
+ ).fetchall()
171
+ return [
172
+ StaleCandidate(
173
+ doc_id=doc_id,
174
+ title=title,
175
+ age_days=int(age_days),
176
+ content_type=content_type,
177
+ )
178
+ for (doc_id, title, age_days, content_type) in rows
179
+ ]
180
+
181
+
182
+ def count_stale_docs_missing_summary(
183
+ conn: psycopg.Connection[Any], *, tenant_id: str, stale_age_days: int
184
+ ) -> int:
185
+ """Count aged non-draft, non-transcript docs that still lack a summary.
186
+
187
+ Tenant-scoped via the same ``graph_entity_mentions`` ``EXISTS`` as
188
+ :func:`iter_docs_for_staleness_scan`, so the
189
+ ``brain review scan --stale --dry-run`` nudge counts only docs in the active
190
+ tenant's graph that never enter the staleness pipeline for want of a summary.
191
+ """
192
+ row = conn.execute(
193
+ """
194
+ SELECT count(*)
195
+ FROM documents d
196
+ WHERE d.ingested_at < now() - make_interval(days => %s)
197
+ AND d.content_type <> ALL(%s)
198
+ AND d.draft IS NOT TRUE
199
+ AND d.summary IS NULL
200
+ AND EXISTS (
201
+ SELECT 1 FROM graph_entity_mentions gem
202
+ WHERE gem.document_id = d.id AND gem.tenant_id = %s
203
+ )
204
+ """,
205
+ (stale_age_days, list(_STALE_EXCLUDED_CONTENT_TYPES), tenant_id),
206
+ ).fetchone()
207
+ return int(row[0]) if row else 0
208
+
209
+
210
+ def fetch_superseding_docs(
211
+ conn: psycopg.Connection[Any],
212
+ *,
213
+ tenant_id: str,
214
+ doc_id: str,
215
+ window_days: int,
216
+ ) -> list[SupersedingDoc]:
217
+ """Newer non-draft docs sharing >= 1 entity with ``doc_id``, ingested recently.
218
+
219
+ A superseding candidate must (a) share at least one graph entity with the
220
+ stale doc, (b) be ingested within ``window_days`` of now, and (c) not be the
221
+ stale doc itself. Tenant-scoped via ``graph_entity_mentions``.
222
+ """
223
+ rows = conn.execute(
224
+ """
225
+ SELECT DISTINCT d2.id::text, d2.title
226
+ FROM graph_entity_mentions m1
227
+ JOIN graph_entity_mentions m2
228
+ ON m2.tenant_id = m1.tenant_id AND m2.entity_id = m1.entity_id
229
+ JOIN documents d2 ON d2.id = m2.document_id
230
+ WHERE m1.tenant_id = %s
231
+ AND m1.document_id = %s
232
+ AND m2.document_id <> %s
233
+ AND d2.ingested_at >= now() - make_interval(days => %s)
234
+ AND d2.draft IS NOT TRUE
235
+ """,
236
+ (tenant_id, doc_id, doc_id, window_days),
237
+ ).fetchall()
238
+ return [SupersedingDoc(doc_id=did, title=title) for (did, title) in rows]
239
+
240
+
241
+ def fetch_best_chunk_embeddings(
242
+ conn: psycopg.Connection[Any], *, document_ids: Sequence[str]
243
+ ) -> dict[str, list[float]]:
244
+ """Map each document id to its lead-chunk embedding (``chunk_index`` 0).
245
+
246
+ ``chunks`` has no ``created_at`` column, so the lead chunk is selected by
247
+ ``ORDER BY document_id, chunk_index`` under ``DISTINCT ON (document_id)``.
248
+ Documents whose chunks are all NULL-embedded are absent from the result.
249
+ Vectors are returned as plain ``list[float]`` (the ``pgvector`` adapter
250
+ yields a numpy array; we copy it out so the pure-Python cosine never depends
251
+ on numpy).
252
+ """
253
+ if not document_ids:
254
+ return {}
255
+ rows = conn.execute(
256
+ """
257
+ SELECT DISTINCT ON (document_id) document_id::text, embedding
258
+ FROM chunks
259
+ WHERE document_id = ANY(%s::uuid[])
260
+ AND embedding IS NOT NULL
261
+ ORDER BY document_id, chunk_index
262
+ """,
263
+ (list(document_ids),),
264
+ ).fetchall()
265
+ return {doc_id: [float(x) for x in vec] for (doc_id, vec) in rows}
266
+
267
+
268
+ def fetch_doc_summaries(
269
+ conn: psycopg.Connection[Any], *, document_ids: Sequence[str]
270
+ ) -> dict[str, str]:
271
+ """Map each document id to its non-null ``summary`` text.
272
+
273
+ Documents with a NULL summary are omitted, so a caller can treat "absent"
274
+ and "no summary" identically. Used to assemble the LLM evidence pair.
275
+ """
276
+ if not document_ids:
277
+ return {}
278
+ rows = conn.execute(
279
+ """
280
+ SELECT id::text, summary
281
+ FROM documents
282
+ WHERE id = ANY(%s::uuid[]) AND summary IS NOT NULL
283
+ """,
284
+ (list(document_ids),),
285
+ ).fetchall()
286
+ return {doc_id: summary for (doc_id, summary) in rows}
287
+
288
+
289
+ def existing_finding_statuses(
290
+ conn: psycopg.Connection[Any], *, tenant_id: str, signal_kind: str
291
+ ) -> dict[str, str]:
292
+ """Map ``target_id`` -> ``status`` for every non-resolved finding of a kind.
293
+
294
+ Backs the idempotency check (Step 2 of both scans): a target already
295
+ surfaced / snoozed / dismissed is skipped (never re-adjudicated); only a
296
+ ``resolved`` target (absent here) or a brand-new target is rescanned. The
297
+ partial unique index on ``elicitation_gaps`` guarantees at most one
298
+ non-resolved row per ``(tenant_id, signal_kind, target_id)``.
299
+ """
300
+ rows = conn.execute(
301
+ """
302
+ SELECT target_id, status
303
+ FROM elicitation_gaps
304
+ WHERE tenant_id = %s AND signal_kind = %s AND status <> 'resolved'
305
+ """,
306
+ (tenant_id, signal_kind),
307
+ ).fetchall()
308
+ return {target_id: status for (target_id, status) in rows}
309
+
310
+
311
+ def upsert_review_finding(
312
+ conn: psycopg.Connection[Any],
313
+ *,
314
+ tenant_id: str,
315
+ signal_kind: str,
316
+ target_type: str,
317
+ target_id: str,
318
+ score: float,
319
+ evidence_ids: Sequence[str],
320
+ rationale: str,
321
+ ) -> None:
322
+ """Upsert one finding, never overwriting a user-dismissed row.
323
+
324
+ The partial unique index ``WHERE status <> 'resolved'`` covers dismissed
325
+ rows, so a naive upsert would clobber them. The ``DO UPDATE ... WHERE
326
+ elicitation_gaps.status IN ('surfaced','snoozed')`` guard detects the
327
+ conflict (dismissed != resolved → in the index) but skips the UPDATE for a
328
+ dismissed row, leaving the user's dismissal intact.
329
+ """
330
+ conn.execute(
331
+ """
332
+ INSERT INTO elicitation_gaps
333
+ (tenant_id, signal_kind, target_type, target_id,
334
+ score, evidence_ids, rationale)
335
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
336
+ ON CONFLICT (tenant_id, signal_kind, target_id) WHERE status <> 'resolved'
337
+ DO UPDATE SET
338
+ score = EXCLUDED.score,
339
+ evidence_ids = EXCLUDED.evidence_ids,
340
+ rationale = EXCLUDED.rationale,
341
+ updated_at = now()
342
+ WHERE elicitation_gaps.status IN ('surfaced', 'snoozed')
343
+ """,
344
+ (
345
+ tenant_id,
346
+ signal_kind,
347
+ target_type,
348
+ target_id,
349
+ score,
350
+ list(evidence_ids),
351
+ rationale,
352
+ ),
353
+ )
354
+
355
+
356
+ def list_review_queue(
357
+ conn: psycopg.Connection[Any],
358
+ *,
359
+ tenant_id: str,
360
+ signal_kinds: Sequence[str],
361
+ limit: int,
362
+ ) -> list[QueueRow]:
363
+ """Read the open review queue (surfaced / snoozed) for the given kinds.
364
+
365
+ Scoped to ``signal_kinds`` (a subset of :data:`REVIEW_SIGNAL_KINDS`) so the
366
+ review queue stays independent of the ``brain elicit`` gap queue. Ordered by
367
+ score DESC. Snoozed rows whose ``snoozed_until`` is still in the future are
368
+ held back, mirroring ``brain elicit list``.
369
+ """
370
+ rows = conn.execute(
371
+ """
372
+ SELECT id::text, signal_kind, target_type, target_id,
373
+ score, evidence_ids, rationale, status
374
+ FROM elicitation_gaps
375
+ WHERE tenant_id = %s
376
+ AND signal_kind = ANY(%s)
377
+ AND status IN ('surfaced', 'snoozed')
378
+ AND (snoozed_until IS NULL OR snoozed_until < now())
379
+ ORDER BY score DESC
380
+ LIMIT %s
381
+ """,
382
+ (tenant_id, list(signal_kinds), limit),
383
+ ).fetchall()
384
+ return [
385
+ QueueRow(
386
+ id=r[0],
387
+ signal_kind=r[1],
388
+ target_type=r[2],
389
+ target_id=r[3],
390
+ score=float(r[4]),
391
+ evidence_ids=list(r[5]),
392
+ rationale=r[6],
393
+ status=r[7],
394
+ )
395
+ for r in rows
396
+ ]
397
+
398
+
399
+ def dismiss_review_finding(
400
+ conn: psycopg.Connection[Any], *, tenant_id: str, id_prefix: str
401
+ ) -> str:
402
+ """Set ``status='dismissed'`` on the review finding matching ``id_prefix``.
403
+
404
+ Resolves ``id_prefix`` against open / dismissed review findings only
405
+ (``signal_kind`` in :data:`REVIEW_SIGNAL_KINDS`, ``status <> 'resolved'``).
406
+ Idempotent: dismissing an already-dismissed finding is a no-op that returns
407
+ its id. Returns the full finding id. Raises :class:`ValueError` when the
408
+ prefix matches no finding or is ambiguous — the CLI / MCP layer maps that to
409
+ a user-facing error.
410
+ """
411
+ matches = conn.execute(
412
+ """
413
+ SELECT id::text
414
+ FROM elicitation_gaps
415
+ WHERE tenant_id = %s
416
+ AND signal_kind = ANY(%s)
417
+ AND status <> 'resolved'
418
+ AND id::text LIKE %s
419
+ LIMIT 2
420
+ """,
421
+ (tenant_id, list(REVIEW_SIGNAL_KINDS), f"{id_prefix}%"),
422
+ ).fetchall()
423
+ if not matches:
424
+ raise ValueError(f"no review finding matches prefix {id_prefix!r}")
425
+ if len(matches) > 1:
426
+ raise ValueError(f"review finding prefix {id_prefix!r} is ambiguous")
427
+ finding_id = str(matches[0][0])
428
+ conn.execute(
429
+ """
430
+ UPDATE elicitation_gaps
431
+ SET status = 'dismissed', updated_at = now()
432
+ WHERE id = %s::uuid
433
+ """,
434
+ (finding_id,),
435
+ )
436
+ return finding_id
brain/review/render.py ADDED
@@ -0,0 +1,196 @@
1
+ """Pure renderers for a :class:`~brain.review.weekly.WeeklyReport`.
2
+
3
+ ``render_weekly_md`` produces the vault page (YAML frontmatter + Markdown body);
4
+ ``render_weekly_rich`` produces a compact terminal summary. Both are pure: the
5
+ same ``WeeklyReport`` always yields byte-identical output, which is what makes
6
+ the vault write idempotent (re-running a week overwrites with identical bytes).
7
+ The only collaborators are the deterministic
8
+ :func:`brain.vault.frontmatter.dump_frontmatter` and the wiki-link path helpers.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from ..vault.frontmatter import dump_frontmatter
15
+ from ..vault.paths import safe_wikilink_alias, strip_md_extension
16
+ from .weekly import ThemeBlock, WeeklyReport
17
+
18
+ _EMPTY_THEMES = "_No themes detected this week._"
19
+ _EMPTY_ACTIVITY = "_No documents interacted with this week._"
20
+ _EMPTY_LOOPS = "_No open loops._"
21
+ _EMPTY_INGESTED = "_No new captures this week._"
22
+ _EMPTY_PEOPLE = "_No key people identified._"
23
+
24
+
25
+ def _doc_link(doc_id: str, title: str, vault_paths: dict[str, str]) -> str:
26
+ """Render a doc reference as a wiki-link when it has a vault path, else plain.
27
+
28
+ ``[[<vault-path-no-md>|<alias>]]`` for vault-mirrored docs (alias has its
29
+ ``[`` / ``]`` neutralized so Quartz's wiki-link regex matches); the bare
30
+ title otherwise. Honors the "never guess wiki-link targets" rule — the
31
+ target is always the doc's stored ``vault_path``, never a derived slug.
32
+ """
33
+ vault_path = vault_paths.get(doc_id)
34
+ if vault_path:
35
+ target = strip_md_extension(vault_path)
36
+ return f"[[{target}|{safe_wikilink_alias(title)}]]"
37
+ return title
38
+
39
+
40
+ def _render_theme(block: ThemeBlock, vault_paths: dict[str, str]) -> list[str]:
41
+ """Render one theme block: heading, optional synthesis quote, doc bullets."""
42
+ heading = " & ".join(block.entity_names) if block.entity_names else block.key
43
+ lines = [f"### {heading}"]
44
+ if block.synthesis:
45
+ lines.append(f"> {block.synthesis}")
46
+ for doc_id, title in block.docs:
47
+ lines.append(f"- {_doc_link(doc_id, title, vault_paths)}")
48
+ return lines
49
+
50
+
51
+ def _render_body(report: WeeklyReport) -> str:
52
+ """Build the Markdown body (everything after the frontmatter fence)."""
53
+ lines: list[str] = [
54
+ f"# Weekly review · {report.week}",
55
+ f"_{report.start_date.isoformat()} → {report.end_date.isoformat()}_",
56
+ "",
57
+ "## Themes this week",
58
+ "",
59
+ ]
60
+ if report.themes:
61
+ for i, block in enumerate(report.themes):
62
+ if i > 0:
63
+ lines.append("")
64
+ lines.extend(_render_theme(block, report.vault_paths))
65
+ else:
66
+ lines.append(_EMPTY_THEMES)
67
+
68
+ lines += ["", f"## Activity ({len(report.activity)} docs interacted with)", ""]
69
+ if report.activity:
70
+ lines.append("| Doc | Interactions |")
71
+ lines.append("|-----|-------------|")
72
+ for doc in report.activity:
73
+ link = _doc_link(doc.document_id, doc.title, report.vault_paths)
74
+ lines.append(f"| {link} | {doc.interaction_count} |")
75
+ else:
76
+ lines.append(_EMPTY_ACTIVITY)
77
+
78
+ lines += ["", "## Open loops", ""]
79
+ if report.open_loops:
80
+ for row in report.open_loops:
81
+ source = _doc_link(
82
+ row.document_id, row.document_title, report.vault_paths
83
+ )
84
+ lines.append(f"- [ ] {row.text}")
85
+ lines.append(f" _from: {source}_")
86
+ else:
87
+ lines.append(_EMPTY_LOOPS)
88
+
89
+ lines += ["", f"## New captures ({len(report.ingested)} docs)", ""]
90
+ if report.ingested:
91
+ for ing in report.ingested:
92
+ link = _doc_link(ing.document_id, ing.title, report.vault_paths)
93
+ kind = ing.source_kind or "manual"
94
+ lines.append(f"- {link} ({kind})")
95
+ else:
96
+ lines.append(_EMPTY_INGESTED)
97
+
98
+ lines += ["", "## Key people", ""]
99
+ if report.key_people:
100
+ lines.append(" · ".join(report.key_people))
101
+ else:
102
+ lines.append(_EMPTY_PEOPLE)
103
+
104
+ return "\n".join(lines) + "\n"
105
+
106
+
107
+ def render_weekly_md(report: WeeklyReport) -> str:
108
+ """Render the full vault page: YAML frontmatter + Markdown body.
109
+
110
+ Deterministic — two calls on the same report return byte-identical strings.
111
+ Frontmatter fields are emitted in a fixed order (``dump_frontmatter`` keeps
112
+ insertion order) so the page round-trips through
113
+ :func:`brain.vault.frontmatter.parse_frontmatter`.
114
+ """
115
+ fields = {
116
+ "kind": "review",
117
+ "week": report.week,
118
+ "date": report.generated_date.isoformat(),
119
+ "tags": ["review", "weekly"],
120
+ "window_start": report.start_date.isoformat(),
121
+ "window_end": report.end_date.isoformat(),
122
+ }
123
+ return dump_frontmatter(fields, _render_body(report))
124
+
125
+
126
+ def render_weekly_json(report: WeeklyReport) -> dict[str, Any]:
127
+ """Render the machine-readable wire shape shared by CLI ``--json`` + MCP.
128
+
129
+ ``vault_path`` is the relative page slug (``reviews/<week>``) — present
130
+ whether or not the page was actually emitted, mirroring the MCP contract in
131
+ Plan 10 §3. Theme blocks expose ``community_key`` (the graph community key,
132
+ or the tag name on the fallback path) and ``doc_titles`` (not full doc refs).
133
+ """
134
+ return {
135
+ "week": report.week,
136
+ "start_date": report.start_date.isoformat(),
137
+ "end_date": report.end_date.isoformat(),
138
+ "vault_path": f"reviews/{report.week}",
139
+ "graph_used": report.graph_used,
140
+ "sections": {
141
+ "themes": [
142
+ {
143
+ "community_key": block.key,
144
+ "entity_names": block.entity_names,
145
+ "doc_titles": [title for _id, title in block.docs],
146
+ "synthesis": block.synthesis,
147
+ }
148
+ for block in report.themes
149
+ ],
150
+ "activity": [
151
+ {
152
+ "document_id": doc.document_id,
153
+ "title": doc.title,
154
+ "interaction_count": doc.interaction_count,
155
+ }
156
+ for doc in report.activity
157
+ ],
158
+ "open_loops": [
159
+ {"document_title": row.document_title, "text": row.text}
160
+ for row in report.open_loops
161
+ ],
162
+ "ingested": [
163
+ {
164
+ "document_id": ing.document_id,
165
+ "title": ing.title,
166
+ "source_kind": ing.source_kind,
167
+ }
168
+ for ing in report.ingested
169
+ ],
170
+ "key_people": report.key_people,
171
+ },
172
+ }
173
+
174
+
175
+ def render_weekly_rich(report: WeeklyReport) -> str:
176
+ """Render a compact terminal summary (plain text, no Rich markup).
177
+
178
+ Used by ``brain review weekly --no-emit``. Leads with the report title so
179
+ callers / tests can assert on "Weekly review".
180
+ """
181
+ lines = [
182
+ f"Weekly review · {report.week}",
183
+ f" {report.start_date.isoformat()} → {report.end_date.isoformat()}",
184
+ f" themes: {len(report.themes)} "
185
+ f"activity: {len(report.activity)} "
186
+ f"open loops: {len(report.open_loops)} "
187
+ f"captures: {len(report.ingested)}",
188
+ ]
189
+ for block in report.themes:
190
+ heading = " & ".join(block.entity_names) if block.entity_names else block.key
191
+ lines.append(f" • {heading}")
192
+ if block.synthesis:
193
+ lines.append(f" {block.synthesis}")
194
+ if report.key_people:
195
+ lines.append(f" key people: {' · '.join(report.key_people)}")
196
+ return "\n".join(lines)