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,445 @@
1
+ """Interactive elicitation session: draft each gap, let the user correct & codify.
2
+
3
+ The loop is the human-in-the-loop core of ``brain elicit``: for every queued
4
+ :class:`~brain.elicit.schema.Gap` it asks the injected :class:`Drafter` for a
5
+ confident first draft, presents it, and routes the user's keypress to one of
6
+ four terminal actions — edit & save (codify a vault note), skip (dismiss),
7
+ snooze N days, or quit. Every collaborator that touches the outside world
8
+ (the editor, stdin, the drafter) is injected so the loop is fully
9
+ deterministic under test. This module MUST NOT import :mod:`brain.cli` — the
10
+ CLI imports the session, never the reverse.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import uuid
15
+ from collections.abc import Callable
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import psycopg
20
+ import typer
21
+
22
+ from ..config import Config
23
+ from ..edit_session import (
24
+ EditorAbortedError,
25
+ EditorParseFailedError,
26
+ EditorUnchangedError,
27
+ build_payload,
28
+ )
29
+ from ..edit_session import run_editor_session as _run_editor_session
30
+ from ..errors import ElicitError
31
+ from ..vault import create_vault_note
32
+ from ..vault.paths import safe_wikilink_alias, strip_md_extension
33
+ from ..vault.slug import slugify
34
+ from .drafter import Drafter
35
+ from .schema import ENTITY_TARGET_TYPES, ElicitDraft, ElicitOutcome, Gap
36
+
37
+ InputFn = Callable[[], str]
38
+ EditFn = Callable[..., "tuple[dict[str, Any], str]"]
39
+
40
+ _MENU = "[e] edit & save [s] skip [n] snooze N days [q] quit"
41
+ _EVIDENCE_PREVIEW = 3
42
+
43
+ def _default_input() -> str:
44
+ """Production input source: prompt the user for a single line via Typer."""
45
+ return str(typer.prompt("›", prompt_suffix=" "))
46
+
47
+
48
+ def run_session(
49
+ cfg: Config,
50
+ conn: psycopg.Connection[Any],
51
+ *,
52
+ drafter: Drafter,
53
+ gaps: list[Gap],
54
+ tenant_id: str,
55
+ vault_path: Path | None,
56
+ input_fn: InputFn = _default_input,
57
+ edit_fn: EditFn = _run_editor_session,
58
+ ) -> list[ElicitOutcome]:
59
+ """Drive the interactive review loop over ``gaps``; return what happened.
60
+
61
+ For each gap: draft → present → prompt. ``e`` codifies the corrected rule
62
+ into a vault note (outcome ``accepted``); ``s`` dismisses the gap (outcome
63
+ ``dismissed``); ``n`` snoozes it for N days (outcome ``snoozed``); ``q``
64
+ stops the loop early, returning the outcomes accumulated so far.
65
+
66
+ The connection is expected to be in autocommit mode so each status
67
+ transition (and any authored note) persists immediately — a quit midway
68
+ must not roll back already-resolved gaps.
69
+ """
70
+ outcomes: list[ElicitOutcome] = []
71
+ for gap in gaps:
72
+ draft = drafter.draft(conn, gap, tenant_id=tenant_id)
73
+ _present(gap, draft)
74
+ outcome = _handle_gap(
75
+ cfg,
76
+ conn,
77
+ gap=gap,
78
+ draft=draft,
79
+ tenant_id=tenant_id,
80
+ vault_path=vault_path,
81
+ input_fn=input_fn,
82
+ edit_fn=edit_fn,
83
+ )
84
+ if outcome is None: # user quit
85
+ break
86
+ outcomes.append(outcome)
87
+ return outcomes
88
+
89
+
90
+ def _present(gap: Gap, draft: ElicitDraft) -> None:
91
+ """Print the gap's draft rule, rationale, and a few evidence references."""
92
+ typer.echo("")
93
+ typer.echo(f"▸ {draft.title}")
94
+ target_label = gap.target_name or gap.target_id
95
+ if target_label:
96
+ typer.echo(f" about: {target_label}")
97
+ if gap.rationale:
98
+ typer.echo(f" why: {gap.rationale}")
99
+ typer.echo(f" draft: {draft.draft_text}")
100
+ if draft.evidence_ids:
101
+ preview = ", ".join(draft.evidence_ids[:_EVIDENCE_PREVIEW])
102
+ typer.echo(f" evidence: {preview}")
103
+ if draft.evidence_texts:
104
+ excerpt = draft.evidence_texts[0].strip().replace("\n", " ")
105
+ typer.echo(f" excerpt: {excerpt[:160]}")
106
+
107
+
108
+ def _handle_gap(
109
+ cfg: Config,
110
+ conn: psycopg.Connection[Any],
111
+ *,
112
+ gap: Gap,
113
+ draft: ElicitDraft,
114
+ tenant_id: str,
115
+ vault_path: Path | None,
116
+ input_fn: InputFn,
117
+ edit_fn: EditFn,
118
+ ) -> ElicitOutcome | None:
119
+ """Prompt until the user makes a terminal choice; ``None`` means quit."""
120
+ while True:
121
+ typer.echo(_MENU)
122
+ choice = input_fn().strip().lower()
123
+ if choice == "q":
124
+ return None
125
+ if choice == "s":
126
+ return _skip(conn, gap, tenant_id=tenant_id)
127
+ if choice == "n":
128
+ return _snooze(conn, gap, input_fn, tenant_id=tenant_id)
129
+ if choice == "e":
130
+ outcome = _edit_and_save(
131
+ cfg,
132
+ conn,
133
+ gap=gap,
134
+ draft=draft,
135
+ tenant_id=tenant_id,
136
+ vault_path=vault_path,
137
+ edit_fn=edit_fn,
138
+ )
139
+ if outcome is not None:
140
+ return outcome
141
+ continue # empty / unchanged / aborted edit — re-prompt
142
+ typer.echo("Unrecognized choice; pick [e]dit, [s]kip, [n]snooze, or [q]uit.")
143
+
144
+
145
+ def _assert_one_row(cur: psycopg.Cursor[Any], gap: Gap, action: str) -> None:
146
+ """Fail loud when a status UPDATE didn't hit exactly one tenant-owned row.
147
+
148
+ A ``rowcount != 1`` means the gap id either doesn't exist or belongs to a
149
+ different tenant — a silent no-op would hide that, so raise instead.
150
+ """
151
+ if cur.rowcount != 1:
152
+ raise ElicitError(
153
+ f"{action} affected {cur.rowcount} rows for gap {gap.gap_id} "
154
+ "(gap missing or owned by another tenant)"
155
+ )
156
+
157
+
158
+ def _skip(
159
+ conn: psycopg.Connection[Any], gap: Gap, *, tenant_id: str
160
+ ) -> ElicitOutcome:
161
+ """Dismiss the gap so it drops out of the open queue."""
162
+ cur = conn.execute(
163
+ "UPDATE elicitation_gaps SET status='dismissed', updated_at=now() "
164
+ "WHERE id=%s AND tenant_id=%s",
165
+ (gap.gap_id, tenant_id),
166
+ )
167
+ _assert_one_row(cur, gap, "dismiss")
168
+ typer.echo("Skipped.")
169
+ return ElicitOutcome(gap_id=gap.gap_id, action="dismissed")
170
+
171
+
172
+ def _snooze(
173
+ conn: psycopg.Connection[Any], gap: Gap, input_fn: InputFn, *, tenant_id: str
174
+ ) -> ElicitOutcome:
175
+ """Snooze the gap for a user-supplied number of days (>= 1)."""
176
+ typer.echo("Snooze for how many days?")
177
+ while True:
178
+ raw = input_fn().strip()
179
+ try:
180
+ days = int(raw)
181
+ except ValueError:
182
+ typer.echo("Please enter a whole number of days (>= 1).")
183
+ continue
184
+ if days < 1:
185
+ typer.echo("Days must be >= 1.")
186
+ continue
187
+ break
188
+ cur = conn.execute(
189
+ "UPDATE elicitation_gaps SET status='snoozed', "
190
+ "snoozed_until=now() + make_interval(days => %s), updated_at=now() "
191
+ "WHERE id=%s AND tenant_id=%s",
192
+ (days, gap.gap_id, tenant_id),
193
+ )
194
+ _assert_one_row(cur, gap, "snooze")
195
+ typer.echo(f"Snoozed for {days} day(s).")
196
+ return ElicitOutcome(gap_id=gap.gap_id, action="snoozed", snoozed_days=days)
197
+
198
+
199
+ def _edit_and_save(
200
+ cfg: Config,
201
+ conn: psycopg.Connection[Any],
202
+ *,
203
+ gap: Gap,
204
+ draft: ElicitDraft,
205
+ tenant_id: str,
206
+ vault_path: Path | None,
207
+ edit_fn: EditFn,
208
+ ) -> ElicitOutcome | None:
209
+ """Open the editor on the draft; codify the corrected body into a note.
210
+
211
+ Returns ``None`` (so the caller re-prompts) when the user makes no usable
212
+ change — an empty body, a body identical to the draft, an aborted editor,
213
+ or a payload that never parsed.
214
+ """
215
+ initial_text = build_payload(
216
+ title=draft.title,
217
+ content_type="note",
218
+ tags=_note_tags(gap),
219
+ metadata={
220
+ "gap_id": gap.gap_id,
221
+ "signal_kind": gap.signal_kind,
222
+ "evidence_ids": list(gap.evidence_ids),
223
+ },
224
+ body=draft.draft_text,
225
+ )
226
+ try:
227
+ _header, body = edit_fn(initial_text, doc_id_label=gap.gap_id)
228
+ except EditorUnchangedError:
229
+ typer.echo("No changes made — [e]dit again or [s]kip?")
230
+ return None
231
+ except (EditorAbortedError, EditorParseFailedError) as exc:
232
+ typer.echo(f"Edit not saved ({exc}) — [e]dit again or [s]kip?")
233
+ return None
234
+
235
+ body = body.strip()
236
+ if not body or body == draft.draft_text.strip():
237
+ typer.echo("Draft unchanged — [e]dit again or [s]kip?")
238
+ return None
239
+
240
+ note_id = _codify(
241
+ cfg,
242
+ conn,
243
+ vault_path=vault_path,
244
+ gap=gap,
245
+ draft=draft,
246
+ body=body,
247
+ tenant_id=tenant_id,
248
+ )
249
+ typer.echo(f"Saved rule → {note_id}")
250
+ return ElicitOutcome(gap_id=gap.gap_id, action="accepted", note_id=note_id)
251
+
252
+
253
+ def _codify(
254
+ cfg: Config,
255
+ conn: psycopg.Connection[Any],
256
+ *,
257
+ vault_path: Path | None,
258
+ gap: Gap,
259
+ draft: ElicitDraft,
260
+ body: str,
261
+ tenant_id: str,
262
+ ) -> str:
263
+ """Author the corrected rule as a vault note and mark the gap resolved.
264
+
265
+ Deliberately writes NO ``interactions`` row — that table is append-only
266
+ with a fixed action vocabulary and has no value for "rule codified"; the
267
+ resolved ``elicitation_gaps`` row (with ``resolved_note_id``) is the
268
+ durable record.
269
+ """
270
+ if vault_path is None:
271
+ raise ValueError("vault_path is required to codify an elicited rule")
272
+ # Preflight: confirm the gap is still open AND owned by this tenant BEFORE
273
+ # authoring any note. Without this, a stale / cross-tenant / already-resolved
274
+ # gap would create a vault note + document row and only THEN fail the
275
+ # post-update _assert_one_row guard — leaving an orphan note behind. The
276
+ # post-update guard below stays as belt-and-suspenders for the resolve UPDATE.
277
+ preflight = conn.execute(
278
+ "SELECT status FROM elicitation_gaps WHERE id=%s AND tenant_id=%s",
279
+ (gap.gap_id, tenant_id),
280
+ ).fetchone()
281
+ if preflight is None or preflight[0] not in ("surfaced", "snoozed"):
282
+ observed = preflight[0] if preflight is not None else "absent"
283
+ raise ElicitError(
284
+ f"cannot codify gap {gap.gap_id}: gap missing, owned by another "
285
+ f"tenant, or no longer open (status={observed})"
286
+ )
287
+ footer = _build_source_footer(conn, gap, tenant_id=tenant_id)
288
+ note_body = f"{body}{footer}" if footer else body
289
+ note_id = create_vault_note(
290
+ conn,
291
+ cfg=cfg,
292
+ vault_path=vault_path,
293
+ title=draft.title,
294
+ body=note_body,
295
+ tags=_note_tags(gap),
296
+ )
297
+ cur = conn.execute(
298
+ "UPDATE elicitation_gaps SET status='resolved', resolved_note_id=%s::uuid, "
299
+ "updated_at=now() WHERE id=%s AND tenant_id=%s",
300
+ (note_id, gap.gap_id, tenant_id),
301
+ )
302
+ _assert_one_row(cur, gap, "resolve")
303
+ return note_id
304
+
305
+
306
+ def _note_tags(gap: Gap) -> list[str]:
307
+ """Canonical tag set for an elicited rule note."""
308
+ return ["tacit", gap.signal_kind]
309
+
310
+
311
+ def _build_source_footer(
312
+ conn: psycopg.Connection[Any], gap: Gap, *, tenant_id: str
313
+ ) -> str:
314
+ """Build a grounded ``## Source`` footer wiki-linking the gap's provenance.
315
+
316
+ Spec §1/§4.5: codified rules are "wikilinked to their source entity." We
317
+ resolve only REAL data so no link is ever guessed (repo rule: never invent
318
+ wiki-link targets):
319
+
320
+ - **Evidence:** the gap's evidence documents are resolved to their actual
321
+ ``documents.vault_path`` (rows that exist AND have been exported to the
322
+ vault). Each is rendered as a path-form wiki-link
323
+ ``[[<vault-path-no-md>|<title>]]`` — the same shape the derived-links
324
+ fence / People Hub emit and that
325
+ :func:`brain.vault.resolver._resolve_by_vault_path` matches, so the link
326
+ is guaranteed to resolve.
327
+ - **Source entity:** for an entity-typed gap whose ``target_id`` is a real
328
+ entity UUID, the entity's display name is rendered as a wiki-link *only
329
+ when* a real vault page for it can be resolved (a People-Hub page or an
330
+ authored vault note titled after the entity — see
331
+ :func:`_resolve_entity_link`). When no such page exists the name stays
332
+ PLAIN TEXT — a broken link is worse than plain text, and we never guess a
333
+ path that may not exist.
334
+
335
+ Returns ``""`` when neither an entity name nor any evidence vault_path
336
+ resolves, so the caller appends nothing rather than an empty section.
337
+ """
338
+ lines: list[str] = []
339
+
340
+ entity_name = _resolve_entity_name(conn, gap, tenant_id=tenant_id)
341
+ if entity_name:
342
+ link = _resolve_entity_link(conn, entity_name)
343
+ lines.append(f"Elicited from: {link or entity_name}")
344
+
345
+ evidence_links = _resolve_evidence_links(conn, gap)
346
+ if evidence_links:
347
+ if lines:
348
+ lines.append("")
349
+ lines.append("Evidence:")
350
+ lines.extend(f"- {link}" for link in evidence_links)
351
+
352
+ if not lines:
353
+ return ""
354
+ return "\n\n## Source\n\n" + "\n".join(lines) + "\n"
355
+
356
+
357
+ def _resolve_entity_name(
358
+ conn: psycopg.Connection[Any], gap: Gap, *, tenant_id: str
359
+ ) -> str | None:
360
+ """Return the source entity's display name, or ``None`` if not resolvable.
361
+
362
+ Guarded so a ``user_flagged`` gap with a raw-string ``target_id`` (not a
363
+ UUID) is skipped gracefully rather than raising on the UUID cast.
364
+ """
365
+ if gap.target_type not in ENTITY_TARGET_TYPES:
366
+ return None
367
+ try:
368
+ uuid.UUID(gap.target_id)
369
+ except (ValueError, AttributeError, TypeError):
370
+ return None
371
+ row = conn.execute(
372
+ "SELECT name FROM graph_entities WHERE id = %s::uuid AND tenant_id = %s",
373
+ (gap.target_id, tenant_id),
374
+ ).fetchone()
375
+ if row is None or not row[0]:
376
+ return None
377
+ return str(row[0])
378
+
379
+
380
+ def _resolve_entity_link(
381
+ conn: psycopg.Connection[Any], entity_name: str
382
+ ) -> str | None:
383
+ """Return a resolvable path-form wiki-link to the entity's vault page, or None.
384
+
385
+ Best-effort and NEVER guesses (repo rule: never invent wiki-link targets):
386
+ we only emit a link to a ``documents`` row that actually exists AND carries
387
+ a non-null ``vault_path``. Two real shapes resolve, in priority order:
388
+
389
+ 1. A People-Hub page at ``people/<slug>.md`` (``slug = slugify(name)``) —
390
+ the canonical place an entity (person/org/…) gets a vault page.
391
+ 2. An authored vault note titled after the entity (``kind = 'vault'``
392
+ preferred), then any titled match with a vault_path.
393
+
394
+ The link target is :data:`documents.vault_path` (path-form, sans ``.md``),
395
+ the same shape :func:`brain.vault.resolver._resolve_by_vault_path` matches,
396
+ so the emitted link is guaranteed to resolve. Returns ``None`` when nothing
397
+ resolves and the caller keeps the entity as plain text.
398
+ """
399
+ people_candidate = f"people/{slugify(entity_name)}.md"
400
+ row = conn.execute(
401
+ "SELECT vault_path FROM documents "
402
+ "WHERE vault_path IS NOT NULL "
403
+ "AND (vault_path = %s OR lower(title) = lower(%s)) "
404
+ "ORDER BY (vault_path = %s) DESC, (kind = 'vault') DESC, vault_path "
405
+ "LIMIT 1",
406
+ (people_candidate, entity_name, people_candidate),
407
+ ).fetchone()
408
+ if row is None or not row[0]:
409
+ return None
410
+ target = strip_md_extension(str(row[0]))
411
+ return f"[[{target}|{safe_wikilink_alias(entity_name)}]]"
412
+
413
+
414
+ def _resolve_evidence_links(
415
+ conn: psycopg.Connection[Any], gap: Gap
416
+ ) -> list[str]:
417
+ """Render the gap's evidence docs as resolvable path-form wiki-links.
418
+
419
+ Only evidence ids that parse as UUIDs are queried (non-UUID placeholders
420
+ are skipped, never cast), and only rows with a non-null ``vault_path`` are
421
+ rendered — so every emitted link points at a real, exported file.
422
+ """
423
+ valid_ids: list[str] = []
424
+ for ev in gap.evidence_ids:
425
+ try:
426
+ uuid.UUID(str(ev))
427
+ except (ValueError, AttributeError, TypeError):
428
+ continue
429
+ valid_ids.append(str(ev))
430
+ if not valid_ids:
431
+ return []
432
+ rows = conn.execute(
433
+ "SELECT vault_path, title FROM documents "
434
+ "WHERE id = ANY(%s::uuid[]) AND vault_path IS NOT NULL "
435
+ "ORDER BY vault_path",
436
+ (valid_ids,),
437
+ ).fetchall()
438
+ links: list[str] = []
439
+ for vault_path, title in rows:
440
+ target = strip_md_extension(str(vault_path))
441
+ if title:
442
+ links.append(f"[[{target}|{safe_wikilink_alias(str(title))}]]")
443
+ else:
444
+ links.append(f"[[{target}]]")
445
+ return links
@@ -0,0 +1,54 @@
1
+ """Allowlist + identifier-safety helpers for pgvector embedding columns.
2
+
3
+ The dim-reconciliation paths (:func:`brain.db.ensure_embedding_column` /
4
+ :func:`brain.queries.finalize_embedding_index`) are generalized over
5
+ ``(table, column)`` so the GraphRAG tables can reuse them. To keep that
6
+ generalization safe, every table/column pair is validated against the
7
+ hard-coded allowlist here and the identifiers are only ever passed to SQL
8
+ through :class:`psycopg.sql.Identifier` — never string-formatted into a
9
+ statement. A non-allowlisted pair raises before any SQL is built.
10
+ """
11
+ from .errors import BrainError
12
+
13
+ # Hard-coded allowlist of (table, column) pairs that carry a pgvector embedding
14
+ # column subject to dim reconciliation. ``graph_communities.summary_embedding``
15
+ # is forward-compat (G3) and is NOT exercised yet — it is listed so the helper
16
+ # accepts it without a code change once the communities migration lands.
17
+ EMBEDDING_TARGET_ALLOWLIST: frozenset[tuple[str, str]] = frozenset(
18
+ {
19
+ ("chunks", "embedding"),
20
+ ("graph_entities", "embedding"),
21
+ ("graph_communities", "summary_embedding"),
22
+ }
23
+ )
24
+
25
+
26
+ def validate_embedding_target(table: str, column: str) -> None:
27
+ """Reject any ``(table, column)`` pair not on the embedding allowlist.
28
+
29
+ Guards every generalized dim-reconciliation entry point so a caller can
30
+ never smuggle an arbitrary identifier into DDL — defense in depth alongside
31
+ the :class:`psycopg.sql.Identifier` quoting used to build the statements.
32
+
33
+ Raises:
34
+ BrainError: ``(table, column)`` is not allowlisted.
35
+ """
36
+ if (table, column) not in EMBEDDING_TARGET_ALLOWLIST:
37
+ allowed = ", ".join(
38
+ f"{tbl}.{col}" for tbl, col in sorted(EMBEDDING_TARGET_ALLOWLIST)
39
+ )
40
+ raise BrainError(
41
+ f"refusing to reconcile embedding column {table}.{column}: "
42
+ f"not on the allowlist ({allowed})"
43
+ )
44
+
45
+
46
+ def embedding_index_name(table: str, column: str) -> str:
47
+ """Return the conventional HNSW index name for ``<table>.<column>``.
48
+
49
+ Matches the legacy ``chunks_embedding_idx`` name so the existing chunks
50
+ index is found unchanged after the generalization (both the drop in
51
+ :func:`brain.db.ensure_embedding_column` and the create in
52
+ :func:`brain.queries.finalize_embedding_index` derive the name here).
53
+ """
54
+ return f"{table}_{column}_idx"