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,362 @@
1
+ """Linker pass — rebuild derived_links rows for a set of touched documents."""
2
+ import datetime
3
+ import json
4
+ import logging
5
+ from email.utils import parsedate_to_datetime
6
+ from typing import Any, Literal, cast
7
+
8
+ import psycopg
9
+
10
+ from brain.vault.derived_links.directory import DirectoryStore
11
+ from brain.vault.derived_links.participants import extract_gmail_addresses
12
+ from brain.vault.derived_links.rules import (
13
+ DocSnapshot,
14
+ Evidence,
15
+ rule_same_day_participant,
16
+ rule_shared_participant,
17
+ rule_shared_thread,
18
+ )
19
+
20
+ _logger = logging.getLogger(__name__)
21
+
22
+ # Source kinds the linker considers. Manual / vault docs never carry the
23
+ # metadata shapes the rules read, so they're excluded from both the touched
24
+ # snapshot pass and the candidate pool.
25
+ _LINKABLE_SOURCE_KINDS: frozenset[str] = frozenset({"gmail", "krisp"})
26
+
27
+
28
+ def rebuild_derived_for(
29
+ conn: psycopg.Connection[Any],
30
+ doc_ids: set[str],
31
+ *,
32
+ directory: DirectoryStore,
33
+ owner_participants: frozenset[str] = frozenset(),
34
+ ) -> tuple[int, set[str]]:
35
+ """Rebuild ``derived_links`` rows whose src or dst is in ``doc_ids``.
36
+
37
+ Steps (all in one transaction):
38
+ 1. SELECT touched docs + their participant keys + dates.
39
+ 2. SELECT every other Gmail/Krisp doc (candidate pool).
40
+ 3. Compute candidate pairs (de-duplicated by canonical ordering); run
41
+ R1/R2/R3 against each.
42
+ 4. R3 supersedes R2 for the same pair.
43
+ 5. DELETE FROM derived_links WHERE src or dst IN doc_ids (capturing
44
+ the row endpoints so callers see partners that LOST an edge in
45
+ this pass too).
46
+ 6. UPSERT the new edge set with ``(LEAST, GREATEST)`` ordering. The
47
+ INSERT uses ``ON CONFLICT (src, dst, rule) DO UPDATE`` so a
48
+ concurrent rebuild from another connection (``brain vault
49
+ sync --watch`` worker vs foreground ``brain vault sync``) cannot
50
+ race the INSERT into a UniqueViolation — the row simply gets its
51
+ ``evidence`` + ``weight`` refreshed to the values this rebuild
52
+ computed.
53
+
54
+ Returns ``(inserted_count, affected_ids)``:
55
+
56
+ - ``inserted_count`` — number of ``derived_links`` rows inserted in
57
+ step 6.
58
+ - ``affected_ids`` — superset of ``doc_ids`` containing every endpoint
59
+ that had an edge added (step 6) OR removed (step 5). This is the
60
+ touched-set that downstream callers (Phase D's fence renderer)
61
+ iterate over to decide which ``_ingested/`` files need their fence
62
+ regenerated. Includes the input ``doc_ids`` even when no edges were
63
+ added or removed, so callers can rely on the input being a subset
64
+ of the output.
65
+
66
+ ``owner_participants`` is the set of corpus-owner identifiers (emails
67
+ or display names, lowercased + trimmed at config-load time per
68
+ :class:`brain.config.Config.owner_participants`) that should be
69
+ stripped from each :class:`DocSnapshot.participant_keys` BEFORE rule
70
+ evaluation. Without this filter the owner is a participant on
71
+ essentially every doc, which floods R2 / R3 with noise edges keyed
72
+ on a single shared identity. The default (empty frozenset) is a
73
+ fast-path no-op and preserves the historical behaviour for tests
74
+ and any caller that hasn't opted in.
75
+
76
+ The set short-circuits to ``(0, set())`` when ``doc_ids`` is empty —
77
+ no DB round-trip, no transaction.
78
+ """
79
+ if not doc_ids:
80
+ return 0, set()
81
+
82
+ # 1+2. Snapshot every linkable doc once. The corpus is small (~500 rows
83
+ # at full scale per the spec), so a single SELECT + Python-side hash
84
+ # join beats per-touched-doc round-trips. Joining to ``sources``
85
+ # keeps manual / vault rows out — they don't carry the metadata
86
+ # shapes the rule functions read.
87
+ rows = conn.execute(
88
+ """
89
+ SELECT d.id::text, s.kind, d.metadata
90
+ FROM documents d
91
+ JOIN sources s ON s.id = d.source_id
92
+ WHERE s.kind = ANY(%s)
93
+ """,
94
+ (sorted(_LINKABLE_SOURCE_KINDS),),
95
+ ).fetchall()
96
+
97
+ snapshots: dict[str, DocSnapshot] = {}
98
+ for row_id, source_kind, metadata in rows:
99
+ snap = _build_snapshot(
100
+ document_id=str(row_id),
101
+ source_kind=str(source_kind),
102
+ metadata=dict(metadata or {}),
103
+ directory=directory,
104
+ owner_participants=owner_participants,
105
+ )
106
+ snapshots[snap.document_id] = snap
107
+
108
+ # Touched docs that aren't linkable (manual / vault / missing) contribute
109
+ # no pairs. Any pre-existing edges with them are still removed by the
110
+ # DELETE below.
111
+ touched_in_corpus = {d for d in doc_ids if d in snapshots}
112
+
113
+ # 3+4. Walk the touched set, pair each against the rest of the corpus,
114
+ # de-dupe via canonical ordering, then apply rules.
115
+ seen_pairs: set[tuple[str, str]] = set()
116
+ pair_evidence: list[tuple[str, str, Evidence]] = []
117
+
118
+ for touched_id in touched_in_corpus:
119
+ a = snapshots[touched_id]
120
+ for other_id, b in snapshots.items():
121
+ if other_id == touched_id:
122
+ continue
123
+
124
+ canonical = _canonical_pair(touched_id, other_id)
125
+ if canonical in seen_pairs:
126
+ continue
127
+ seen_pairs.add(canonical)
128
+
129
+ for evidence in _evaluate_pair(a, b):
130
+ pair_evidence.append((canonical[0], canonical[1], evidence))
131
+
132
+ # 5+6. DELETE then INSERT in one transaction. The DELETE scope is the
133
+ # original ``doc_ids`` set (not just ``touched_in_corpus``) so a
134
+ # caller that passes a now-deleted / kind-changed doc still has its
135
+ # stale edges cleared.
136
+ #
137
+ # The DELETE returns the endpoints of every removed row so the
138
+ # affected-set captures partners that LOST an edge in this pass.
139
+ # Without that, a fence renderer wouldn't know to regenerate the
140
+ # partner's "Related" section after a deletion.
141
+ doc_ids_list = list(doc_ids)
142
+ affected_ids: set[str] = set(doc_ids)
143
+ with conn.transaction():
144
+ deleted_rows = conn.execute(
145
+ "DELETE FROM derived_links "
146
+ "WHERE src_document_id = ANY(%s) OR dst_document_id = ANY(%s) "
147
+ "RETURNING src_document_id::text, dst_document_id::text",
148
+ (doc_ids_list, doc_ids_list),
149
+ ).fetchall()
150
+ for src, dst in deleted_rows:
151
+ affected_ids.add(str(src))
152
+ affected_ids.add(str(dst))
153
+ for src, dst, evidence in pair_evidence:
154
+ # ``ON CONFLICT DO UPDATE`` makes the INSERT race-safe against a
155
+ # concurrent rebuild (e.g. ``brain vault sync --watch`` worker
156
+ # firing on a file event while a foreground ``brain vault sync``
157
+ # commits a fresh ``derived_links`` row in the same window).
158
+ # Without this, our DELETE-then-INSERT could DELETE a row, observe
159
+ # a concurrent INSERT of the same canonical pair from a sibling
160
+ # transaction, and crash on UniqueViolation. The semantics are
161
+ # preserved: rebuild's intent is "make these rows reflect the
162
+ # current snapshot", and an UPSERT does exactly that — refreshes
163
+ # ``evidence`` + ``weight`` to the freshly-computed values.
164
+ conn.execute(
165
+ """
166
+ INSERT INTO derived_links
167
+ (src_document_id, dst_document_id, rule, evidence, weight)
168
+ VALUES (%s, %s, %s, %s::jsonb, %s)
169
+ ON CONFLICT (src_document_id, dst_document_id, rule)
170
+ DO UPDATE SET evidence = EXCLUDED.evidence,
171
+ weight = EXCLUDED.weight
172
+ """,
173
+ (
174
+ src,
175
+ dst,
176
+ evidence.rule,
177
+ json.dumps(evidence.payload),
178
+ evidence.weight,
179
+ ),
180
+ )
181
+ affected_ids.add(src)
182
+ affected_ids.add(dst)
183
+
184
+ _logger.info(
185
+ "rebuilt %d derived edges across %d touched docs "
186
+ "(%d in corpus, %d affected ids)",
187
+ len(pair_evidence),
188
+ len(doc_ids),
189
+ len(touched_in_corpus),
190
+ len(affected_ids),
191
+ )
192
+ return len(pair_evidence), affected_ids
193
+
194
+
195
+ def _canonical_pair(a: str, b: str) -> tuple[str, str]:
196
+ """Return ``(LEAST, GREATEST)`` so the same unordered pair always orders identically.
197
+
198
+ Mirrors the SQL ``LEAST(src, dst) / GREATEST(src, dst)`` canonicalization
199
+ that backstops ``derived_links``' ``UNIQUE (src, dst, rule)`` constraint.
200
+ """
201
+ return (a, b) if a < b else (b, a)
202
+
203
+
204
+ def _evaluate_pair(a: DocSnapshot, b: DocSnapshot) -> list[Evidence]:
205
+ """Run R1, R2, R3 against ``(a, b)`` and apply R3-supersedes-R2.
206
+
207
+ R1 and R3/R2 are independent rules and may co-exist on the same pair
208
+ (different ``derived_links.rule`` rows). Within the participant family,
209
+ R3 (same-day) is strictly stronger than R2 (no date constraint), so R2
210
+ is suppressed when R3 fires.
211
+ """
212
+ evidences: list[Evidence] = []
213
+
214
+ r1 = rule_shared_thread(a, b)
215
+ if r1 is not None:
216
+ evidences.append(r1)
217
+
218
+ r3 = rule_same_day_participant(a, b)
219
+ if r3 is not None:
220
+ evidences.append(r3)
221
+ else:
222
+ r2 = rule_shared_participant(a, b)
223
+ if r2 is not None:
224
+ evidences.append(r2)
225
+
226
+ return evidences
227
+
228
+
229
+ def _build_snapshot(
230
+ *,
231
+ document_id: str,
232
+ source_kind: str,
233
+ metadata: dict[str, Any],
234
+ directory: DirectoryStore,
235
+ owner_participants: frozenset[str] = frozenset(),
236
+ ) -> DocSnapshot:
237
+ """Project a DB row into a :class:`DocSnapshot` for rule evaluation.
238
+
239
+ Gmail keys are derived from ``from``/``to`` headers via
240
+ :func:`extract_gmail_addresses`, with each ``(display, email)`` pair
241
+ contributing the email plus either the directory-resolved email for
242
+ ``display`` or the normalized display name itself.
243
+
244
+ Krisp keys come from ``metadata['_participant_keys']`` (populated at
245
+ ingest time by :func:`brain.ingest._apply_pre_insert_metadata`). Name-only
246
+ keys are bridged to emails via :meth:`DirectoryStore.resolve_name_to_email`
247
+ so cross-source linking works without baking the directory into the
248
+ pre-insert step.
249
+
250
+ ``owner_participants`` (lowercased at config load) is subtracted from the
251
+ final key set — emails AND display-name keys both run through the same
252
+ ``key.lower() in owner_participants`` filter — so the corpus owner can't
253
+ create participant-overlap edges between every doc they're on. The
254
+ default (empty frozenset) skips the dict-comp entirely (zero overhead
255
+ for callers that haven't opted in).
256
+ """
257
+ if source_kind == "gmail":
258
+ keys = _gmail_participant_keys(metadata, directory)
259
+ elif source_kind == "krisp":
260
+ keys = _krisp_participant_keys(metadata, directory)
261
+ else: # pragma: no cover - SELECT filter keeps us here
262
+ keys = set()
263
+
264
+ if owner_participants:
265
+ keys = {k for k in keys if k.lower() not in owner_participants}
266
+
267
+ return DocSnapshot(
268
+ document_id=document_id,
269
+ source_kind=cast(Literal["gmail", "krisp", "manual"], source_kind),
270
+ metadata=metadata,
271
+ participant_keys=frozenset(keys),
272
+ date=_parse_date(metadata.get("date"), source_kind=source_kind),
273
+ )
274
+
275
+
276
+ def _gmail_participant_keys(
277
+ metadata: dict[str, Any], directory: DirectoryStore
278
+ ) -> set[str]:
279
+ """Build participant keys for a Gmail snapshot.
280
+
281
+ Each ``(display, email)`` pair contributes the email itself; the display
282
+ name (if present) is run through the directory — if it resolves to an
283
+ email, that email is added (catches the ``"person-x"`` → ``person-a@…`` bridge);
284
+ otherwise the normalized display name is added so a Krisp doc that only
285
+ knows the name can still match.
286
+ """
287
+ keys: set[str] = set()
288
+ for display, email in extract_gmail_addresses(metadata):
289
+ keys.add(email)
290
+ if display:
291
+ resolved = directory.resolve_name_to_email(display)
292
+ keys.add(resolved if resolved else display)
293
+ return keys
294
+
295
+
296
+ def _krisp_participant_keys(
297
+ metadata: dict[str, Any], directory: DirectoryStore
298
+ ) -> set[str]:
299
+ """Build participant keys for a Krisp snapshot.
300
+
301
+ Reads ``metadata['_participant_keys']`` (sorted list, ingest-time output)
302
+ and bridges name-only keys to emails via the directory so a Krisp call
303
+ labeled ``**Pat Morgan | 0:01**`` can match a Gmail with
304
+ ``from: "Pat Morgan <redacted@example.com>"``.
305
+ """
306
+ raw = metadata.get("_participant_keys")
307
+ if not isinstance(raw, list):
308
+ return set()
309
+
310
+ keys: set[str] = set()
311
+ for entry in raw:
312
+ if not isinstance(entry, str):
313
+ continue
314
+ token = entry.strip()
315
+ if not token:
316
+ continue
317
+ if "@" in token:
318
+ keys.add(token)
319
+ continue
320
+ resolved = directory.resolve_name_to_email(token)
321
+ keys.add(resolved if resolved else token)
322
+ return keys
323
+
324
+
325
+ def _parse_date(
326
+ raw: Any, *, source_kind: str
327
+ ) -> datetime.date | None:
328
+ """Parse ``metadata['date']`` per source convention.
329
+
330
+ - Krisp stores ISO date strings (``2026-04-15`` or ``2026-04-15T12:00``)
331
+ passed through ``brain ingest-stdin --date``.
332
+ - Gmail stores RFC 5322 strings (``Wed, 15 Apr 2026 12:00:00 -0700``).
333
+
334
+ Returns ``None`` for missing, non-string, or unparseable values — R3
335
+ quietly degrades when the date is missing rather than crashing the pass.
336
+ """
337
+ if not isinstance(raw, str):
338
+ return None
339
+ text = raw.strip()
340
+ if not text:
341
+ return None
342
+
343
+ if source_kind == "krisp":
344
+ # ``date.fromisoformat`` is strict YYYY-MM-DD on 3.11+. Slicing the
345
+ # first 10 characters tolerates the ``YYYY-MM-DDTHH:MM:SS`` variant
346
+ # without pulling in ``datetime.fromisoformat``'s wider but still
347
+ # not-RFC-5322 grammar.
348
+ try:
349
+ return datetime.date.fromisoformat(text[:10])
350
+ except ValueError:
351
+ _logger.debug("krisp date parse failed: %r", text)
352
+ return None
353
+
354
+ # Gmail (and any future RFC-5322 source).
355
+ try:
356
+ parsed = parsedate_to_datetime(text)
357
+ except (TypeError, ValueError):
358
+ _logger.debug("gmail date parse failed: %r", text)
359
+ return None
360
+ if parsed is None:
361
+ return None
362
+ return parsed.date()
@@ -0,0 +1,137 @@
1
+ """Pure rule functions for the metadata-aware linker — R1, R2, R3."""
2
+ import datetime
3
+ from dataclasses import dataclass
4
+ from typing import Any, Literal
5
+
6
+ # Confidence weights for the three derived-link rules. Stored on every
7
+ # `derived_links.weight` insert so formatters can tier-style edges
8
+ # (spec §Q3). Constrained by migration 005's `CHECK (weight >= 0 AND weight <= 1)`.
9
+ WEIGHT_SHARED_THREAD = 1.0 # R1 — Gmail thread match (highest confidence)
10
+ WEIGHT_SAME_DAY_PARTICIPANT = 0.7 # R3 — participant + ±1-day match
11
+ WEIGHT_SHARED_PARTICIPANT = 0.4 # R2 — participant only (weakest)
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class DocSnapshot:
16
+ """A read-only projection of a document used by rule evaluation.
17
+
18
+ Built once per linker pass from `documents` + `metadata->'_participant_keys'`.
19
+ Rules consume snapshots; they never query the DB themselves. ``source_kind``
20
+ matches ``sources.kind`` in the DB schema, or ``None`` for vault-tier docs
21
+ without a source.
22
+ """
23
+
24
+ document_id: str
25
+ source_kind: Literal["gmail", "krisp", "manual"] | None
26
+ metadata: dict[str, Any]
27
+ participant_keys: frozenset[str]
28
+ date: datetime.date | None
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Evidence:
33
+ """Outcome of a successful rule evaluation for a document pair.
34
+
35
+ The pass-runner stores `(rule, weight, payload)` into `derived_links`
36
+ columns + the `evidence` JSONB column. Payload shape varies per rule —
37
+ e.g. {"thread_id": "..."} for R1, {"participant": "...", "date": "..."}
38
+ for R3.
39
+ """
40
+
41
+ rule: Literal["shared_thread", "shared_participant", "same_day_participant"]
42
+ weight: float # 1.0 / 0.4 / 0.7
43
+ payload: dict[str, Any]
44
+
45
+
46
+ def rule_shared_thread(a: DocSnapshot, b: DocSnapshot) -> Evidence | None:
47
+ """R1 — Gmail↔Gmail edge when both share `metadata.thread_id`.
48
+
49
+ Returns None for non-Gmail pairs, missing thread_ids, or thread_id
50
+ mismatch. Weight ``WEIGHT_SHARED_THREAD``.
51
+ """
52
+ if a.document_id == b.document_id:
53
+ return None
54
+ if a.source_kind != "gmail" or b.source_kind != "gmail":
55
+ return None
56
+
57
+ thread_a = a.metadata.get("thread_id")
58
+ thread_b = b.metadata.get("thread_id")
59
+ if not (isinstance(thread_a, str) and thread_a.strip()):
60
+ return None
61
+ if not (isinstance(thread_b, str) and thread_b.strip()):
62
+ return None
63
+ if thread_a != thread_b:
64
+ return None
65
+
66
+ return Evidence(
67
+ rule="shared_thread",
68
+ weight=WEIGHT_SHARED_THREAD,
69
+ payload={"thread_id": thread_a},
70
+ )
71
+
72
+
73
+ def rule_shared_participant(a: DocSnapshot, b: DocSnapshot) -> Evidence | None:
74
+ """R2 — edge when both docs' participant_keys intersect.
75
+
76
+ Applies across Krisp↔Gmail, Krisp↔Krisp, Gmail↔Gmail. Returns None for
77
+ self-pairs, empty intersections. Weight ``WEIGHT_SHARED_PARTICIPANT``.
78
+ The pass-runner is responsible for suppressing R2 when R3 fires for
79
+ the same pair.
80
+ """
81
+ if a.document_id == b.document_id:
82
+ return None
83
+
84
+ shared = a.participant_keys & b.participant_keys
85
+ if not shared:
86
+ return None
87
+
88
+ representative = sorted(shared)[0]
89
+ return Evidence(
90
+ rule="shared_participant",
91
+ weight=WEIGHT_SHARED_PARTICIPANT,
92
+ payload={"participant": representative, "shared_count": len(shared)},
93
+ )
94
+
95
+
96
+ def rule_same_day_participant(a: DocSnapshot, b: DocSnapshot) -> Evidence | None:
97
+ """R3 — Krisp↔Gmail edge when participants intersect AND dates within ±1 day.
98
+
99
+ Strictly stronger than R2 for Krisp↔Gmail pairs. Returns None for
100
+ non-Krisp/Gmail pairs, missing dates, mismatched participant sets, or
101
+ dates more than 1 day apart. Timezone-naive comparison. Weight
102
+ ``WEIGHT_SAME_DAY_PARTICIPANT``.
103
+ """
104
+ if a.document_id == b.document_id:
105
+ return None
106
+ if {a.source_kind, b.source_kind} != {"krisp", "gmail"}:
107
+ return None
108
+
109
+ a_date = a.date
110
+ b_date = b.date
111
+ if a_date is None or b_date is None:
112
+ return None
113
+
114
+ day_delta = abs((a_date - b_date).days)
115
+ if day_delta > 1:
116
+ return None
117
+
118
+ shared = a.participant_keys & b.participant_keys
119
+ if not shared:
120
+ return None
121
+
122
+ representative = sorted(shared)[0]
123
+ if a.source_kind == "krisp":
124
+ krisp_date_iso, gmail_date_iso = a_date.isoformat(), b_date.isoformat()
125
+ else:
126
+ krisp_date_iso, gmail_date_iso = b_date.isoformat(), a_date.isoformat()
127
+
128
+ return Evidence(
129
+ rule="same_day_participant",
130
+ weight=WEIGHT_SAME_DAY_PARTICIPANT,
131
+ payload={
132
+ "participant": representative,
133
+ "krisp_date": krisp_date_iso,
134
+ "gmail_date": gmail_date_iso,
135
+ "day_delta": day_delta,
136
+ },
137
+ )