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/connect.py ADDED
@@ -0,0 +1,925 @@
1
+ """Scoring core for `brain connect` — proactive auto-link suggestions (Plan 07).
2
+
3
+ This module owns the *logic*: it scores candidate (source_doc → target_doc)
4
+ wikilink pairs by an RRF blend of an entity-graph affinity leg
5
+ (normalized entity overlap over ``graph_entity_mentions``) and an embedding
6
+ affinity leg (cosine over ``chunks.embedding``), gates by a confidence floor,
7
+ dedups against already-linked pairs, and upserts the survivors into
8
+ ``link_suggestions``. It also exposes the accept/reject status mutations and the
9
+ Typer-free ``## See Also`` vault-writeback primitives shared by the CLI and the
10
+ MCP server (so neither layer duplicates the SQL or the file logic).
11
+
12
+ It deliberately carries no Typer / MCP imports: the CLI (``cli_connect.py``)
13
+ and the MCP server map the plain :mod:`brain.errors` exceptions raised here to
14
+ their own frameworks.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import re
20
+ from dataclasses import dataclass, replace
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import psycopg
26
+
27
+ from .config import Config
28
+ from .errors import ConnectError
29
+ from .vault._atomic import atomic_write_text
30
+ from .vault.paths import safe_wikilink_alias, strip_md_extension
31
+ from .wiki.build_related import _avg_embedding, _eligible_source_docs
32
+
33
+ _logger = logging.getLogger(__name__)
34
+
35
+ # Suggestion-id prefixes (like document-id prefixes) are hex digits + hyphens
36
+ # only; reject anything else before it reaches SQL so a user-supplied ``_`` /
37
+ # ``%`` cannot act as a LIKE wildcard. Mirrors ``queries._UUID_PREFIX_RE``.
38
+ _UUID_PREFIX_RE = re.compile(r"[0-9a-f-]+")
39
+ _MIN_PREFIX_LEN = 6
40
+
41
+ # Markdown heading the ``accept --write`` path appends suggested wikilinks under.
42
+ _SEE_ALSO_HEADING = "## See Also"
43
+
44
+ # --------------------------------------------------------------------------- #
45
+ # ``embedding_affinity`` KNN tuning (Task 4.1 — HNSW-friendly rewrite).
46
+ # --------------------------------------------------------------------------- #
47
+
48
+ # Starting KNN batch size = ``candidate_limit`` × this multiplier. On the live
49
+ # corpus nearly every doc sits above the cosine floor, so the first batch yields
50
+ # far more than ``candidate_limit`` distinct docs and the adaptive re-query
51
+ # below never fires; the multiplier is headroom for a rare cold / sparse source
52
+ # whose nearest chunks cluster onto a handful of docs.
53
+ _KNN_CANDIDATE_MULTIPLIER = 8
54
+
55
+ # Max adaptive doublings before falling back to the exhaustive scan. Bounds the
56
+ # worst case; correctness holds either way (the fallback is exact).
57
+ _KNN_MAX_ITERATIONS = 4
58
+
59
+ # pgvector 0.8.2 GUC. A *filtered* HNSW scan under a post-filter ``LIMIT``
60
+ # under-returns by default: the index hands the planner only ``hnsw.ef_search``
61
+ # rows, most of which the JOIN + eligibility filters then drop (measured on
62
+ # prod: a ``LIMIT 400`` yielded only 27 rows). ``strict_order`` makes the scan
63
+ # ITERATE until the ``LIMIT`` is satisfied AND keeps rows in exact ascending-
64
+ # distance order, which the per-doc-max exactness argument in
65
+ # :func:`_embedding_affinity_knn` relies on. ``SET LOCAL`` scopes it to the one
66
+ # transaction — never a session/global ``SET``. Verified against pgvector 0.8.2:
67
+ # the enum is ``{off, relaxed_order, strict_order}``; ``relaxed_order`` would be
68
+ # faster but breaks the exact-order precondition, so we require ``strict_order``.
69
+ _SET_ITERATIVE_SCAN = "SET LOCAL hnsw.iterative_scan = strict_order"
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class SuggestionRow:
74
+ """One row of the ``link_suggestions`` review queue, joined to doc titles."""
75
+
76
+ id: str
77
+ source_doc_id: str
78
+ target_doc_id: str
79
+ source_title: str
80
+ target_title: str
81
+ score: float
82
+ graph_score: float | None
83
+ embed_score: float | None
84
+ status: str
85
+ suggested_at: datetime | None
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class RefreshResult:
90
+ """Outcome counts for a ``brain connect refresh`` pass."""
91
+
92
+ source_docs: int = 0
93
+ candidates: int = 0
94
+ written: int = 0
95
+ dry_run: bool = False
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class ActionResult:
100
+ """Result of an accept/reject status mutation + writeback metadata.
101
+
102
+ ``source_vault_path`` / ``target_vault_path`` / ``target_title`` carry the
103
+ metadata the caller needs to build + insert the ``## See Also`` wikilink
104
+ for ``accept --write`` without a second round-trip. ``wikilink_written`` is
105
+ populated by the caller after it performs (or skips) the file write.
106
+ """
107
+
108
+ suggestion_id: str
109
+ status: str
110
+ source_doc_id: str
111
+ target_doc_id: str
112
+ source_vault_path: str | None
113
+ target_vault_path: str | None
114
+ target_title: str
115
+ wikilink_written: bool = False
116
+
117
+
118
+ # --------------------------------------------------------------------------- #
119
+ # Pure scoring helpers (no I/O).
120
+ # --------------------------------------------------------------------------- #
121
+
122
+
123
+ def normalized_overlap(shared: int, src_count: int, tgt_count: int) -> float:
124
+ """Normalized entity overlap ``shared / min(src_count, tgt_count)`` ∈ [0, 1].
125
+
126
+ This is the graph-affinity signal: the fraction of the *smaller* doc's
127
+ entities that the two docs share. A full subset (one doc's entities ⊂ the
128
+ other's) scores ``1.0``; disjoint sets score ``0.0``. Returns ``0.0`` when
129
+ either side has no entities (the denominator would be zero).
130
+ """
131
+ denom = min(src_count, tgt_count)
132
+ if denom <= 0:
133
+ return 0.0
134
+ return shared / denom
135
+
136
+
137
+ def score_doc_pair(
138
+ graph_signal: float | None, embed_signal: float | None
139
+ ) -> float:
140
+ """Blend the two affinity legs into one confidence score in [0, 1].
141
+
142
+ The legs are combined as ``(graph + embed) / 2`` with an *absent* leg
143
+ counting as ``0.0`` (fixed denominator of 2). This rewards corroboration: a
144
+ pair confirmed by BOTH the entity-graph leg and the embedding leg outscores
145
+ an otherwise-equal pair seen in only one leg — the whole point of the
146
+ feature. The result is directly comparable to ``cfg.connect_min_score`` and
147
+ is what the review table's ``Score`` column shows (the mean of the displayed
148
+ ``Graph`` / ``Embed`` legs).
149
+
150
+ Design note: the plan's prose mentioned ``rrf_contribution`` of leg *ranks*,
151
+ but raw RRF tops out at ~``2/(RRF_K+1)`` ≈ 0.033 — far below any usable
152
+ ``connect_min_score`` floor (nothing would ever clear the gate) and
153
+ inconsistent with the plan's display table, where ``Score`` is exactly the
154
+ mean of the raw ``Graph`` / ``Embed`` signals (0.72 = (0.81 + 0.63) / 2).
155
+ This linear blend of the normalized leg signals is the interpretation those
156
+ concrete artifacts pin down.
157
+ """
158
+ graph = graph_signal if graph_signal is not None else 0.0
159
+ embed = embed_signal if embed_signal is not None else 0.0
160
+ return (graph + embed) / 2.0
161
+
162
+
163
+ # --------------------------------------------------------------------------- #
164
+ # Affinity legs (DB reads).
165
+ # --------------------------------------------------------------------------- #
166
+
167
+
168
+ def graph_affinity(
169
+ conn: psycopg.Connection[Any],
170
+ *,
171
+ source_doc_id: str,
172
+ tenant_id: str,
173
+ candidate_limit: int,
174
+ ) -> dict[str, float]:
175
+ """Return ``{target_doc_id: graph_score}`` for one source doc.
176
+
177
+ Single set-based query (no per-candidate fanout): joins the source doc's
178
+ entity mentions against every other non-draft, vault-backed doc's mentions,
179
+ counts shared entities, and scores via :func:`normalized_overlap`. Pairs
180
+ with no shared entities are absent (they receive ``graph_score = 0.0`` /
181
+ no graph leg in the blend). Scoped to ``tenant_id`` (the relational
182
+ source-of-truth is multi-tenant; migration 012). Capped at
183
+ ``candidate_limit``. Returns an empty dict when the graph is unbuilt /
184
+ AGE-less — the feature degrades to embedding-only.
185
+ """
186
+ if candidate_limit < 1:
187
+ raise ConnectError(f"candidate_limit must be >= 1 (got {candidate_limit})")
188
+ rows = conn.execute(
189
+ """
190
+ WITH src_entities AS (
191
+ SELECT gem.entity_id,
192
+ COUNT(*) OVER () AS src_count
193
+ FROM graph_entity_mentions gem
194
+ WHERE gem.document_id = %(source)s::uuid
195
+ AND gem.tenant_id = %(tenant)s
196
+ ),
197
+ tgt_entities AS (
198
+ SELECT gem.document_id AS tgt_id,
199
+ gem.entity_id,
200
+ COUNT(*) OVER (PARTITION BY gem.document_id) AS tgt_count
201
+ FROM graph_entity_mentions gem
202
+ JOIN documents d ON d.id = gem.document_id
203
+ WHERE gem.tenant_id = %(tenant)s
204
+ AND d.draft = FALSE
205
+ AND d.vault_path IS NOT NULL
206
+ AND d.id <> %(source)s::uuid
207
+ ),
208
+ shared AS (
209
+ SELECT te.tgt_id,
210
+ COUNT(*) AS shared_count,
211
+ MAX(te.tgt_count) AS tgt_count,
212
+ MAX(se.src_count) AS src_count
213
+ FROM src_entities se
214
+ JOIN tgt_entities te USING (entity_id)
215
+ GROUP BY te.tgt_id
216
+ )
217
+ SELECT tgt_id::text, shared_count, src_count, tgt_count
218
+ FROM shared
219
+ WHERE shared_count > 0
220
+ ORDER BY (shared_count::float / LEAST(src_count, tgt_count)) DESC, tgt_id
221
+ LIMIT %(limit)s
222
+ """,
223
+ {
224
+ "source": source_doc_id,
225
+ "tenant": tenant_id,
226
+ "limit": candidate_limit,
227
+ },
228
+ ).fetchall()
229
+ scored: list[tuple[str, float]] = [
230
+ (
231
+ str(r[0]),
232
+ normalized_overlap(int(r[1]), int(r[2]), int(r[3])),
233
+ )
234
+ for r in rows
235
+ ]
236
+ # Re-sort in Python so the dict insertion order (= rank order) matches the
237
+ # Python-computed scores exactly, independent of any FP drift between the
238
+ # SQL ORDER BY expression and ``normalized_overlap``.
239
+ scored.sort(key=lambda item: (-item[1], item[0]))
240
+ return {tgt_id: score for tgt_id, score in scored}
241
+
242
+
243
+ def embedding_affinity(
244
+ conn: psycopg.Connection[Any],
245
+ *,
246
+ source_doc_id: str,
247
+ candidate_limit: int,
248
+ vector_sim_floor: float,
249
+ knn_multiplier: int = _KNN_CANDIDATE_MULTIPLIER,
250
+ ) -> dict[str, float]:
251
+ """Return ``{target_doc_id: best_cosine}`` for one source doc, rank-ordered.
252
+
253
+ The source doc's average chunk embedding is the query vector (reusing
254
+ :func:`brain.wiki.build_related._avg_embedding`); each candidate doc's score
255
+ is its best per-chunk cosine similarity, floored at ``vector_sim_floor``
256
+ (the same floor runtime ``brain search`` uses). Dict insertion order is the
257
+ cosine-descending rank order (ties broken by target-doc id). Empty when the
258
+ source doc has no embedded chunks (cold corpus) — the feature degrades to
259
+ graph-only.
260
+
261
+ Task 4.1 rewrite: the primary path (:func:`_embedding_affinity_knn`) rides
262
+ the ``chunks_embedding_idx`` HNSW index via a top-K nearest-neighbour scan
263
+ with the eligibility predicates IN the SQL, instead of the pre-4.1
264
+ ``MAX(...) GROUP BY`` + range-predicate query that seq-scanned every chunk
265
+ (measured ~1.2s/doc on prod → ~40ms). When the adaptive re-query cannot
266
+ converge within :data:`_KNN_MAX_ITERATIONS` (a cold / sparse source whose
267
+ nearest chunks pile onto a handful of docs) it falls back to the exact
268
+ :func:`_embedding_affinity_exhaustive` — correctness beats speed. The
269
+ output contract is identical across both paths.
270
+
271
+ ``knn_multiplier`` sets the first KNN batch (``candidate_limit`` ×
272
+ multiplier); it exists so tests can force truncation with a small value and
273
+ prove the adaptive re-query converges to the exhaustive result. Production
274
+ callers leave it at the default.
275
+ """
276
+ if candidate_limit < 1:
277
+ raise ConnectError(f"candidate_limit must be >= 1 (got {candidate_limit})")
278
+ if knn_multiplier < 1:
279
+ raise ConnectError(f"knn_multiplier must be >= 1 (got {knn_multiplier})")
280
+ src_embedding = _avg_embedding(conn, source_doc_id)
281
+ if src_embedding is None:
282
+ return {}
283
+ knn = _embedding_affinity_knn(
284
+ conn,
285
+ src_embedding=src_embedding,
286
+ source_doc_id=source_doc_id,
287
+ candidate_limit=candidate_limit,
288
+ vector_sim_floor=vector_sim_floor,
289
+ knn_multiplier=knn_multiplier,
290
+ )
291
+ if knn is not None:
292
+ return knn
293
+ return _embedding_affinity_exhaustive(
294
+ conn,
295
+ src_embedding=src_embedding,
296
+ source_doc_id=source_doc_id,
297
+ candidate_limit=candidate_limit,
298
+ vector_sim_floor=vector_sim_floor,
299
+ )
300
+
301
+
302
+ # KNN leg: the eligibility predicates live IN the query so ``LIMIT k`` counts
303
+ # only eligible chunks and the HNSW index (``chunks_embedding_idx``) drives the
304
+ # ordering. Paired with :data:`_SET_ITERATIVE_SCAN` so the filtered scan does
305
+ # not under-return.
306
+ _KNN_SQL = """
307
+ SELECT c.document_id::text AS doc_id,
308
+ 1 - (c.embedding <=> %(vec)s::vector) AS cosine
309
+ FROM chunks c
310
+ JOIN documents d ON d.id = c.document_id
311
+ WHERE d.draft = FALSE
312
+ AND d.vault_path IS NOT NULL
313
+ AND d.id <> %(source)s::uuid
314
+ AND c.embedding IS NOT NULL
315
+ ORDER BY c.embedding <=> %(vec)s::vector
316
+ LIMIT %(k)s
317
+ """
318
+
319
+
320
+ def _embedding_affinity_knn(
321
+ conn: psycopg.Connection[Any],
322
+ *,
323
+ src_embedding: Any,
324
+ source_doc_id: str,
325
+ candidate_limit: int,
326
+ vector_sim_floor: float,
327
+ knn_multiplier: int,
328
+ ) -> dict[str, float] | None:
329
+ """Ride ``chunks_embedding_idx``: fetch K nearest eligible chunks → floor →
330
+ per-doc max → sort → truncate. Returns the ``{doc: best_cosine}`` mapping, or
331
+ ``None`` to signal "did not converge — use the exhaustive fallback".
332
+
333
+ Exactness argument (why the returned docs match the exhaustive scan): with
334
+ ``strict_order`` the ``LIMIT k`` rows are the true k nearest eligible chunks
335
+ in exact ascending-distance (descending-cosine) order. So:
336
+
337
+ * Every doc that APPEARS has its globally-best chunk among the k — that
338
+ chunk's cosine is ≥ the k-th (smallest returned) cosine, and any other
339
+ chunk of the doc is ≤ its best, so nothing better sits beyond the cut.
340
+ Its per-doc max is therefore exact.
341
+ * Every doc NOT among the k has all chunks below the k-th cosine, hence a
342
+ true best below every captured doc's best.
343
+
344
+ Consequently, once the k nearest chunks contain ≥ ``candidate_limit``
345
+ distinct floor-passing docs, the top ``candidate_limit`` by best cosine are
346
+ fully and exactly determined (no unseen doc can outrank them). We also stop
347
+ early when the batch is provably complete: it under-filled (all eligible
348
+ chunks seen) or its smallest returned cosine already fell below the floor
349
+ (nothing further can pass). Otherwise we double k and retry, capped at
350
+ :data:`_KNN_MAX_ITERATIONS` before conceding to the exhaustive fallback.
351
+
352
+ ``SET LOCAL`` is scoped by :meth:`psycopg.Connection.transaction`; under the
353
+ autocommit connection the CLI uses this opens a real ``BEGIN``/``COMMIT`` so
354
+ the GUC never leaks past the scan. (Assumes the eligible chunk count stays
355
+ within ``hnsw.max_scan_tuples`` so an under-fill means true exhaustion —
356
+ true for the current corpus; the exhaustive fallback backstops the rest.)
357
+ """
358
+ k = candidate_limit * knn_multiplier
359
+ with conn.transaction():
360
+ conn.execute(_SET_ITERATIVE_SCAN)
361
+ for _ in range(_KNN_MAX_ITERATIONS):
362
+ rows = conn.execute(
363
+ _KNN_SQL,
364
+ {"vec": src_embedding, "source": source_doc_id, "k": k},
365
+ ).fetchall()
366
+ floor_rows = [
367
+ (str(r[0]), float(r[1]))
368
+ for r in rows
369
+ if float(r[1]) >= vector_sim_floor
370
+ ]
371
+ distinct_docs = {doc_id for doc_id, _ in floor_rows}
372
+ # Provably complete when the batch under-filled (every eligible
373
+ # chunk seen) or its smallest returned cosine is already below the
374
+ # floor (rows are in exact descending-cosine order, so nothing
375
+ # further can pass).
376
+ seen_all = len(rows) < k or (
377
+ len(rows) > 0 and float(rows[-1][1]) < vector_sim_floor
378
+ )
379
+ if seen_all or len(distinct_docs) >= candidate_limit:
380
+ return _group_per_doc_max(floor_rows, candidate_limit)
381
+ k *= 2
382
+ return None
383
+
384
+
385
+ def _group_per_doc_max(
386
+ floor_rows: list[tuple[str, float]], candidate_limit: int
387
+ ) -> dict[str, float]:
388
+ """Collapse floor-passing ``(doc_id, cosine)`` rows to the per-doc max,
389
+ ordered cosine-descending then doc-id, truncated to ``candidate_limit``.
390
+
391
+ Mirrors the exhaustive query's ``MAX(...) ... ORDER BY cosine DESC, doc_id
392
+ LIMIT`` in Python. Values are byte-identical to the SQL aggregate: both take
393
+ the max of the same float8 cosines each backend computed.
394
+ """
395
+ by_doc: dict[str, float] = {}
396
+ for doc_id, cosine in floor_rows:
397
+ prev = by_doc.get(doc_id)
398
+ if prev is None or cosine > prev:
399
+ by_doc[doc_id] = cosine
400
+ ordered = sorted(by_doc.items(), key=lambda item: (-item[1], item[0]))
401
+ return {doc_id: cosine for doc_id, cosine in ordered[:candidate_limit]}
402
+
403
+
404
+ def _embedding_affinity_exhaustive(
405
+ conn: psycopg.Connection[Any],
406
+ *,
407
+ src_embedding: Any,
408
+ source_doc_id: str,
409
+ candidate_limit: int,
410
+ vector_sim_floor: float,
411
+ ) -> dict[str, float]:
412
+ """Pre-4.1 exhaustive ``MAX(...) GROUP BY`` scan — exact but seq-scans every
413
+ chunk (the range predicate on the cosine defeats the HNSW index).
414
+
415
+ Retained verbatim as the correctness fallback for the rare case where the
416
+ adaptive KNN re-query in :func:`_embedding_affinity_knn` cannot converge
417
+ (a cold / sparse corpus where k would have to grow past the cap). Callers
418
+ reach it only after that path returns ``None``.
419
+ """
420
+ rows = conn.execute(
421
+ """
422
+ SELECT d.id::text AS doc_id,
423
+ MAX(1 - (c.embedding <=> %(vec)s::vector)) AS cosine
424
+ FROM chunks c
425
+ JOIN documents d ON d.id = c.document_id
426
+ WHERE d.draft = FALSE
427
+ AND d.vault_path IS NOT NULL
428
+ AND d.id <> %(source)s::uuid
429
+ AND c.embedding IS NOT NULL
430
+ AND 1 - (c.embedding <=> %(vec)s::vector) >= %(floor)s
431
+ GROUP BY d.id
432
+ ORDER BY cosine DESC, doc_id
433
+ LIMIT %(limit)s
434
+ """,
435
+ {
436
+ "vec": src_embedding,
437
+ "source": source_doc_id,
438
+ "floor": vector_sim_floor,
439
+ "limit": candidate_limit,
440
+ },
441
+ ).fetchall()
442
+ return {str(r[0]): float(r[1]) for r in rows}
443
+
444
+
445
+ def _existing_link_targets(
446
+ conn: psycopg.Connection[Any], source_doc_id: str
447
+ ) -> set[str]:
448
+ """Return target ids already linked from ``source_doc_id``.
449
+
450
+ Reads both ``links`` (extracted wikilinks) and ``derived_links``
451
+ (fence-emitted edges); both use ``src_document_id`` / ``dst_document_id``
452
+ (verified: migrations 003 + 005). A pair already present in either table is
453
+ dropped from the candidate set so ``connect`` never re-suggests an existing
454
+ edge.
455
+
456
+ Both ``links`` and ``derived_links`` are treated as UNDIRECTED here, because
457
+ a suggestion pair is undirected for review purposes (migration 022): if A
458
+ and B are already connected in EITHER orientation, the pair must not be
459
+ re-suggested in either orientation. ``links`` rows are individually directed
460
+ (a wikilink from A's body to B), so we union both the ``src = source`` and
461
+ ``dst = source`` directions. ``derived_links`` are stored as a single
462
+ CANONICAL ``(LEAST, GREATEST)`` pair (already undirected — see
463
+ ``derived_links.pass_runner._canonical_pair``).
464
+ """
465
+ rows = conn.execute(
466
+ """
467
+ SELECT dst_document_id::text FROM links WHERE src_document_id = %(src)s::uuid
468
+ UNION
469
+ SELECT src_document_id::text FROM links WHERE dst_document_id = %(src)s::uuid
470
+ UNION
471
+ SELECT dst_document_id::text FROM derived_links WHERE src_document_id = %(src)s::uuid
472
+ UNION
473
+ SELECT src_document_id::text FROM derived_links WHERE dst_document_id = %(src)s::uuid
474
+ """,
475
+ {"src": source_doc_id},
476
+ ).fetchall()
477
+ return {str(r[0]) for r in rows}
478
+
479
+
480
+ def _retire_linked_pending(
481
+ conn: psycopg.Connection[Any], source_ids: list[str]
482
+ ) -> int:
483
+ """Delete ``pending`` suggestions whose pair is now in ``links`` / ``derived_links``.
484
+
485
+ Closes the window where a suggestion is queued, then the user draws the link
486
+ manually (or a derived edge appears) — the stale pending row must leave the
487
+ queue on the next refresh. Only ``pending`` rows are removed (accepted /
488
+ rejected are frozen). Returns the row count deleted.
489
+
490
+ Scoping is by EITHER endpoint (``source_doc_id`` OR ``target_doc_id`` in
491
+ ``source_ids``), not just the stored source: suggestions are undirected
492
+ (migration 022), so the stored orientation is not stable. A partial
493
+ ``refresh --doc B`` must still retire a pending row stored as ``A -> B`` once
494
+ the pair is linked, even though ``B`` is the row's target, not its source.
495
+
496
+ Both ``links`` and ``derived_links`` are matched UNDIRECTED: a link drawn in
497
+ EITHER orientation retires the pending row regardless of how the suggestion
498
+ stored its source/target.
499
+ """
500
+ if not source_ids:
501
+ return 0
502
+ cur = conn.execute(
503
+ """
504
+ DELETE FROM link_suggestions ls
505
+ WHERE ls.status = 'pending'
506
+ AND (ls.source_doc_id = ANY(%(srcs)s::uuid[])
507
+ OR ls.target_doc_id = ANY(%(srcs)s::uuid[]))
508
+ AND (
509
+ EXISTS (
510
+ -- links are matched undirected — match either orientation.
511
+ SELECT 1 FROM links l
512
+ WHERE (l.src_document_id = ls.source_doc_id
513
+ AND l.dst_document_id = ls.target_doc_id)
514
+ OR (l.src_document_id = ls.target_doc_id
515
+ AND l.dst_document_id = ls.source_doc_id)
516
+ )
517
+ OR EXISTS (
518
+ -- derived_links are canonical/undirected — match either way.
519
+ SELECT 1 FROM derived_links d
520
+ WHERE (d.src_document_id = ls.source_doc_id
521
+ AND d.dst_document_id = ls.target_doc_id)
522
+ OR (d.src_document_id = ls.target_doc_id
523
+ AND d.dst_document_id = ls.source_doc_id)
524
+ )
525
+ )
526
+ """,
527
+ {"srcs": source_ids},
528
+ )
529
+ return cur.rowcount
530
+
531
+
532
+ # --------------------------------------------------------------------------- #
533
+ # Refresh pipeline (DB read + write).
534
+ # --------------------------------------------------------------------------- #
535
+
536
+
537
+ def refresh_suggestions(
538
+ conn: psycopg.Connection[Any],
539
+ cfg: Config,
540
+ *,
541
+ doc_prefix: str | None = None,
542
+ dry_run: bool = False,
543
+ ) -> RefreshResult:
544
+ """Recompute candidate suggestions and upsert the survivors.
545
+
546
+ For each eligible source doc (non-draft, vault-backed, ≥1 embedded chunk —
547
+ the same predicate as ``build_related``), blend the graph + embedding legs
548
+ via RRF, drop pairs already linked or scoring below ``cfg.connect_min_score``,
549
+ keep the top ``cfg.connect_max_per_doc``, and upsert into
550
+ ``link_suggestions``. Suggestions are UNDIRECTED (migration 022): exactly one
551
+ row persists per unordered pair, storing the best-scoring orientation (see
552
+ :func:`_upsert_suggestion`). Accepted/rejected rows are frozen and their
553
+ mirror is never re-suggested.
554
+
555
+ ``doc_prefix`` limits the refresh to a single source doc (resolved via the
556
+ document-id prefix). ``dry_run`` computes everything but writes nothing.
557
+ The caller owns transaction scope (commit/autocommit).
558
+ """
559
+ if cfg.connect_candidate_limit < 1:
560
+ raise ConnectError(
561
+ f"connect_candidate_limit must be >= 1 (got {cfg.connect_candidate_limit})"
562
+ )
563
+ if cfg.connect_max_per_doc < 1:
564
+ raise ConnectError(
565
+ f"connect_max_per_doc must be >= 1 (got {cfg.connect_max_per_doc})"
566
+ )
567
+ if not (0.0 < cfg.connect_min_score <= 1.0):
568
+ raise ConnectError(
569
+ f"connect_min_score must be in (0.0, 1.0] (got {cfg.connect_min_score})"
570
+ )
571
+
572
+ sources = _eligible_source_docs(conn)
573
+ if doc_prefix is not None:
574
+ # Resolve the prefix lazily here (kept out of the import surface) so a
575
+ # bad prefix surfaces the same IdPrefix* errors the rest of the CLI uses.
576
+ from .queries import resolve_document_prefix
577
+
578
+ target_id = resolve_document_prefix(conn, doc_prefix)
579
+ sources = [s for s in sources if s.id == target_id]
580
+
581
+ if not dry_run:
582
+ # Retire any PENDING suggestion whose pair has since been linked (a
583
+ # manual wikilink or derived edge added after it was queued) so the
584
+ # review queue never re-surfaces an edge that already exists (Codex
585
+ # R2 #1). Scoped to the sources being refreshed; accepted/rejected
586
+ # rows are frozen and untouched.
587
+ _retire_linked_pending(conn, [s.id for s in sources])
588
+
589
+ candidates_total = 0
590
+ written_total = 0
591
+ for source in sources:
592
+ scored = _score_source(conn, cfg, source_doc_id=source.id)
593
+ candidates_total += len(scored)
594
+ kept = [pair for pair in scored if pair[1] >= cfg.connect_min_score]
595
+ kept = kept[: cfg.connect_max_per_doc]
596
+ written_total += len(kept)
597
+ if dry_run:
598
+ continue
599
+ for target_id, score, graph_score, embed_score in kept:
600
+ _upsert_suggestion(
601
+ conn,
602
+ source_doc_id=source.id,
603
+ target_doc_id=target_id,
604
+ score=score,
605
+ graph_score=graph_score,
606
+ embed_score=embed_score,
607
+ )
608
+
609
+ _logger.info(
610
+ "connect refresh: sources=%d candidates=%d written=%d dry_run=%s",
611
+ len(sources),
612
+ candidates_total,
613
+ written_total,
614
+ dry_run,
615
+ )
616
+ return RefreshResult(
617
+ source_docs=len(sources),
618
+ candidates=candidates_total,
619
+ written=written_total,
620
+ dry_run=dry_run,
621
+ )
622
+
623
+
624
+ def _score_source(
625
+ conn: psycopg.Connection[Any],
626
+ cfg: Config,
627
+ *,
628
+ source_doc_id: str,
629
+ ) -> list[tuple[str, float, float | None, float | None]]:
630
+ """Blend both legs for one source doc → ranked ``(tgt, score, g, e)`` list.
631
+
632
+ The returned list is sorted by RRF score descending (ties broken by target
633
+ id) and has the already-linked pairs removed. ``g`` / ``e`` are the raw
634
+ graph / embedding leg signals (or ``None`` when the pair is absent from
635
+ that leg) — stored on the row for display, distinct from the blended
636
+ ``score``.
637
+ """
638
+ graph = graph_affinity(
639
+ conn,
640
+ source_doc_id=source_doc_id,
641
+ tenant_id=cfg.graph_tenant_id,
642
+ candidate_limit=cfg.connect_candidate_limit,
643
+ )
644
+ embed = embedding_affinity(
645
+ conn,
646
+ source_doc_id=source_doc_id,
647
+ candidate_limit=cfg.connect_candidate_limit,
648
+ vector_sim_floor=cfg.vector_sim_floor,
649
+ )
650
+ already_linked = _existing_link_targets(conn, source_doc_id)
651
+
652
+ blended: list[tuple[str, float, float | None, float | None]] = []
653
+ for target_id in set(graph) | set(embed):
654
+ if target_id in already_linked:
655
+ continue
656
+ graph_score = graph.get(target_id)
657
+ embed_score = embed.get(target_id)
658
+ score = score_doc_pair(graph_score, embed_score)
659
+ blended.append((target_id, score, graph_score, embed_score))
660
+ blended.sort(key=lambda item: (-item[1], item[0]))
661
+ return blended
662
+
663
+
664
+ def _upsert_suggestion(
665
+ conn: psycopg.Connection[Any],
666
+ *,
667
+ source_doc_id: str,
668
+ target_doc_id: str,
669
+ score: float,
670
+ graph_score: float | None,
671
+ embed_score: float | None,
672
+ ) -> None:
673
+ """Upsert one UNDIRECTED suggestion, keeping the best-scoring orientation.
674
+
675
+ A suggested pair is undirected for review purposes (migration 022): exactly
676
+ one row may exist per unordered pair, regardless of which doc is stored as
677
+ source vs target. The conflict is inferred against the functional unique
678
+ index ``uq_link_suggestions_unordered_pair`` on
679
+ ``(LEAST(source, target), GREATEST(source, target))``.
680
+
681
+ On conflict the row is rewritten — INCLUDING its orientation
682
+ (``source_doc_id`` / ``target_doc_id``) — to the incoming candidate, but
683
+ ONLY when the existing row is ``pending`` AND the new ``score`` is strictly
684
+ greater. This keeps the better-scoring orientation (so the ``accept --write``
685
+ writeback targets the more sensible "source" doc) and never thrashes on
686
+ ties. Because the conflict consumes the insert, an accepted/rejected pair
687
+ can NOT have its mirror re-inserted: refresh never resuggests the reverse of
688
+ a decided pair (the WHERE leaves the frozen row untouched and no new row is
689
+ created).
690
+ """
691
+ conn.execute(
692
+ """
693
+ INSERT INTO link_suggestions
694
+ (source_doc_id, target_doc_id, score, graph_score, embed_score)
695
+ VALUES (%(src)s::uuid, %(dst)s::uuid, %(score)s, %(graph)s, %(embed)s)
696
+ ON CONFLICT (LEAST(source_doc_id, target_doc_id),
697
+ GREATEST(source_doc_id, target_doc_id)) DO UPDATE
698
+ SET source_doc_id = EXCLUDED.source_doc_id,
699
+ target_doc_id = EXCLUDED.target_doc_id,
700
+ score = EXCLUDED.score,
701
+ graph_score = EXCLUDED.graph_score,
702
+ embed_score = EXCLUDED.embed_score,
703
+ suggested_at = now()
704
+ WHERE link_suggestions.status = 'pending'
705
+ AND EXCLUDED.score > link_suggestions.score
706
+ """,
707
+ {
708
+ "src": source_doc_id,
709
+ "dst": target_doc_id,
710
+ "score": score,
711
+ "graph": graph_score,
712
+ "embed": embed_score,
713
+ },
714
+ )
715
+
716
+
717
+ # --------------------------------------------------------------------------- #
718
+ # Queue reads.
719
+ # --------------------------------------------------------------------------- #
720
+
721
+
722
+ def iter_suggestions(
723
+ conn: psycopg.Connection[Any],
724
+ *,
725
+ status: str | None = "pending",
726
+ limit: int = 20,
727
+ ) -> list[SuggestionRow]:
728
+ """Return suggestions joined to their source/target titles.
729
+
730
+ ``status=None`` returns every row (the ``--all`` view); otherwise filters to
731
+ one status. Ordered by score descending. ``limit`` caps the result.
732
+ """
733
+ rows = conn.execute(
734
+ """
735
+ SELECT ls.id::text, ls.source_doc_id::text, ls.target_doc_id::text,
736
+ sd.title, td.title, ls.score, ls.graph_score, ls.embed_score,
737
+ ls.status, ls.suggested_at
738
+ FROM link_suggestions ls
739
+ JOIN documents sd ON sd.id = ls.source_doc_id
740
+ JOIN documents td ON td.id = ls.target_doc_id
741
+ WHERE (%(status)s::text IS NULL OR ls.status = %(status)s)
742
+ ORDER BY ls.score DESC, ls.id
743
+ LIMIT %(limit)s
744
+ """,
745
+ {"status": status, "limit": limit},
746
+ ).fetchall()
747
+ return [
748
+ SuggestionRow(
749
+ id=str(r[0]),
750
+ source_doc_id=str(r[1]),
751
+ target_doc_id=str(r[2]),
752
+ source_title=str(r[3] or ""),
753
+ target_title=str(r[4] or ""),
754
+ score=float(r[5]),
755
+ graph_score=None if r[6] is None else float(r[6]),
756
+ embed_score=None if r[7] is None else float(r[7]),
757
+ status=str(r[8]),
758
+ suggested_at=r[9],
759
+ )
760
+ for r in rows
761
+ ]
762
+
763
+
764
+ def suggestion_counts(conn: psycopg.Connection[Any]) -> dict[str, int]:
765
+ """Return ``{status: count}`` for the three statuses (zero-filled)."""
766
+ rows = conn.execute(
767
+ "SELECT status, COUNT(*) FROM link_suggestions GROUP BY status"
768
+ ).fetchall()
769
+ counts = {"pending": 0, "accepted": 0, "rejected": 0}
770
+ for status, count in rows:
771
+ counts[str(status)] = int(count)
772
+ return counts
773
+
774
+
775
+ def resolve_suggestion_prefix(conn: psycopg.Connection[Any], prefix: str) -> str:
776
+ """Resolve a suggestion-id prefix (min 6 hex chars) to a full id.
777
+
778
+ Mirrors :func:`brain.queries.resolve_document_prefix` but against
779
+ ``link_suggestions``. Raises :class:`ConnectError` on a too-short /
780
+ non-hex / not-found / ambiguous prefix.
781
+ """
782
+ if len(prefix) < _MIN_PREFIX_LEN:
783
+ raise ConnectError("suggestion id prefix must be at least 6 characters")
784
+ if not _UUID_PREFIX_RE.fullmatch(prefix):
785
+ raise ConnectError(
786
+ "suggestion id prefix must contain only hex digits and hyphens"
787
+ )
788
+ rows = conn.execute(
789
+ "SELECT id::text FROM link_suggestions WHERE id::text LIKE %s",
790
+ (prefix + "%",),
791
+ ).fetchall()
792
+ if not rows:
793
+ raise ConnectError(f"suggestion not found: {prefix}")
794
+ if len(rows) > 1:
795
+ raise ConnectError(f"suggestion id prefix ambiguous: {prefix}")
796
+ return str(rows[0][0])
797
+
798
+
799
+ # --------------------------------------------------------------------------- #
800
+ # Accept / reject (status mutation + writeback metadata).
801
+ # --------------------------------------------------------------------------- #
802
+
803
+
804
+ def load_action_context(
805
+ conn: psycopg.Connection[Any], suggestion_id: str
806
+ ) -> ActionResult:
807
+ """Return a suggestion's current state + writeback metadata WITHOUT mutating.
808
+
809
+ Lets an ``accept --write`` caller fetch the source/target vault paths and
810
+ title, perform the vault write FIRST, and only then flip the status — so a
811
+ write failure never leaves the row frozen ``accepted`` with no wikilink
812
+ (Codex R1 #1). ``status`` is the row's current status. Raises
813
+ :class:`ConnectError` for a missing suggestion id.
814
+ """
815
+ row = conn.execute(
816
+ """
817
+ SELECT ls.status, ls.source_doc_id::text, ls.target_doc_id::text,
818
+ sd.vault_path, td.vault_path, td.title
819
+ FROM link_suggestions ls
820
+ JOIN documents sd ON sd.id = ls.source_doc_id
821
+ JOIN documents td ON td.id = ls.target_doc_id
822
+ WHERE ls.id = %s::uuid
823
+ """,
824
+ (suggestion_id,),
825
+ ).fetchone()
826
+ if row is None:
827
+ raise ConnectError(f"suggestion not found: {suggestion_id}")
828
+ return ActionResult(
829
+ suggestion_id=suggestion_id,
830
+ status=str(row[0]),
831
+ source_doc_id=str(row[1]),
832
+ target_doc_id=str(row[2]),
833
+ source_vault_path=row[3],
834
+ target_vault_path=row[4],
835
+ target_title=str(row[5] or ""),
836
+ )
837
+
838
+
839
+ def set_suggestion_status(
840
+ conn: psycopg.Connection[Any], suggestion_id: str, status: str
841
+ ) -> ActionResult:
842
+ """Flip a suggestion to ``accepted`` / ``rejected`` and stamp ``actioned_at``.
843
+
844
+ Returns the writeback metadata (source/target vault paths + target title)
845
+ so an ``accept --write`` caller can build + insert the wikilink without a
846
+ second query. Raises :class:`ConnectError` for an unknown status or a
847
+ missing suggestion id.
848
+ """
849
+ if status not in ("accepted", "rejected"):
850
+ raise ConnectError(
851
+ f"status must be 'accepted' or 'rejected' (got {status!r})"
852
+ )
853
+ ctx = load_action_context(conn, suggestion_id)
854
+ conn.execute(
855
+ "UPDATE link_suggestions SET status = %s, actioned_at = now() WHERE id = %s::uuid",
856
+ (status, suggestion_id),
857
+ )
858
+ return replace(ctx, status=status)
859
+
860
+
861
+ # --------------------------------------------------------------------------- #
862
+ # Vault writeback primitives (Typer-free; shared by CLI + MCP).
863
+ # --------------------------------------------------------------------------- #
864
+
865
+
866
+ def build_see_also_wikilink(target_vault_path: str, target_title: str) -> str:
867
+ """Build the path-form alias wikilink ``[[<path-no-md>|<title>]]``.
868
+
869
+ The alias (path) form — not a bare ``[[Title]]`` — guards against
870
+ misresolution when two docs share a title. The target path is stripped of
871
+ its ``.md`` suffix (matching ``_resolve_by_vault_path``); the title has
872
+ brackets sanitized (Quartz alias rule) and any ``|`` replaced with ``-``
873
+ (the wikilink delimiter).
874
+ """
875
+ link_path = strip_md_extension(target_vault_path)
876
+ safe_title = safe_wikilink_alias(target_title).replace("|", "-")
877
+ return f"[[{link_path}|{safe_title}]]"
878
+
879
+
880
+ def append_see_also_link(vault_file: Path, wikilink: str) -> bool:
881
+ """Insert ``- <wikilink>`` into the file's ``## See Also`` section.
882
+
883
+ Idempotent: returns ``False`` (no write) when ``wikilink`` is already in the
884
+ file. Otherwise:
885
+
886
+ - If a ``## See Also`` heading exists (anywhere — not only as the trailing
887
+ section, Codex R1 #2), the bullet is inserted at the END of that section
888
+ (just before the next markdown heading, or EOF), so it never lands under
889
+ an unrelated later section.
890
+ - Otherwise a new ``## See Also`` section is appended at the end of the file.
891
+
892
+ Writes via :func:`atomic_write_text` and returns ``True``. Never logs the
893
+ file body (privacy rule: ids / paths only).
894
+ """
895
+ text = vault_file.read_text(encoding="utf-8") if vault_file.exists() else ""
896
+ if wikilink in text:
897
+ return False
898
+ bullet = f"- {wikilink}"
899
+ lines = text.splitlines()
900
+ heading_idx = next(
901
+ (i for i, line in enumerate(lines) if line.strip() == _SEE_ALSO_HEADING),
902
+ None,
903
+ )
904
+ if heading_idx is None:
905
+ body = text.rstrip("\n")
906
+ new_text = (
907
+ f"{body}\n\n{_SEE_ALSO_HEADING}\n\n{bullet}\n"
908
+ if body
909
+ else f"{_SEE_ALSO_HEADING}\n\n{bullet}\n"
910
+ )
911
+ atomic_write_text(vault_file, new_text)
912
+ return True
913
+ # Find the end of the See Also section: the next markdown heading after it,
914
+ # or EOF. Insert the bullet just after the section's last non-blank line.
915
+ section_end = len(lines)
916
+ for j in range(heading_idx + 1, len(lines)):
917
+ if lines[j].startswith("#"):
918
+ section_end = j
919
+ break
920
+ insert_at = section_end
921
+ while insert_at - 1 > heading_idx and lines[insert_at - 1].strip() == "":
922
+ insert_at -= 1
923
+ lines.insert(insert_at, bullet)
924
+ atomic_write_text(vault_file, "\n".join(lines) + "\n")
925
+ return True