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/elicit/queue.py ADDED
@@ -0,0 +1,220 @@
1
+ """Build the ranked, de-duplicated elicitation gap queue."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Sequence
5
+ from dataclasses import replace
6
+ from typing import Any
7
+
8
+ import psycopg
9
+
10
+ from ..config import Config
11
+ from ..rank_fusion import rrf_contribution
12
+ from .detectors import GapDetector
13
+ from .schema import ENTITY_TARGET_TYPES, Gap
14
+
15
+ # A UUID-shaped ``target_id`` (entity-typed gaps reference a ``graph_entities``
16
+ # row by id). Guards the F2 purge so a ``user_flagged`` gap carrying a raw-string
17
+ # target (not a UUID) is never treated as an orphaned entity reference.
18
+ _UUID_RE = r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
19
+
20
+
21
+ def _normalize(gaps: list[Gap]) -> list[Gap]:
22
+ """Rescale gap scores so the maximum is 1.0; identity on empty list."""
23
+ if not gaps:
24
+ return []
25
+ top = max(g.score for g in gaps) or 1.0
26
+ return [replace(g, score=g.score / top) for g in gaps]
27
+
28
+
29
+ def _rank_gaps(by_signal: dict[str, list[Gap]]) -> list[Gap]:
30
+ """Merge per-signal gap lists into one ranked list using Reciprocal Rank Fusion.
31
+
32
+ Each signal list is independently score-normalised; then RRF contributions
33
+ are accumulated per ``target_id`` across all signals. Targets that appear in
34
+ multiple signal lists receive additive boosts. The returned list is ordered
35
+ descending by the accumulated RRF score (also normalised to [0, 1]).
36
+ """
37
+ contrib: dict[str, float] = {}
38
+ chosen: dict[str, Gap] = {}
39
+ for gaps in by_signal.values():
40
+ for rank, g in enumerate(
41
+ sorted(_normalize(gaps), key=lambda x: x.score, reverse=True)
42
+ ):
43
+ contrib[g.target_id] = contrib.get(g.target_id, 0.0) + rrf_contribution(rank)
44
+ if g.target_id not in chosen or len(g.evidence_ids) > len(
45
+ chosen[g.target_id].evidence_ids
46
+ ):
47
+ chosen[g.target_id] = g
48
+ ranked = [replace(chosen[t], score=c) for t, c in contrib.items()]
49
+ top = max((g.score for g in ranked), default=1.0) or 1.0
50
+ ranked = [replace(g, score=g.score / top) for g in ranked]
51
+ return sorted(ranked, key=lambda g: g.score, reverse=True)
52
+
53
+
54
+ def _purge_orphaned_entity_gaps(
55
+ conn: psycopg.Connection[Any], tenant_id: str
56
+ ) -> int:
57
+ """Delete open entity-typed gaps whose ``graph_entities`` target is gone (F2).
58
+
59
+ The cross-type collapse (:func:`brain.graph_rag.cross_type.collapse_cross_type_concepts`)
60
+ merges duplicate entities and GCs the losers, leaving ``elicitation_gaps`` rows
61
+ whose UUID ``target_id`` no longer resolves. This drops those phantom gaps so a
62
+ stale entry never lingers in the queue. Scoped to:
63
+
64
+ * ``status IN ('surfaced', 'snoozed')`` — the two active states the queue
65
+ read-back surfaces; never touches user-``resolved`` rows (a snoozed gap whose
66
+ entity was merged away would otherwise resurface as a phantom on expiry);
67
+ * entity target types only (:data:`ENTITY_TARGET_TYPES`);
68
+ * a UUID-shaped ``target_id`` (:data:`_UUID_RE`) — a ``user_flagged`` gap with a
69
+ raw-string target is never mistaken for an orphaned entity reference;
70
+ * ``NOT EXISTS`` a matching tenant-scoped ``graph_entities`` row.
71
+
72
+ Returns the number of rows purged. Parameterized SQL.
73
+ """
74
+ cur = conn.execute(
75
+ """
76
+ DELETE FROM elicitation_gaps eg
77
+ WHERE eg.tenant_id = %s
78
+ AND eg.status IN ('surfaced', 'snoozed')
79
+ AND eg.target_type = ANY(%s)
80
+ AND eg.target_id ~ %s
81
+ AND NOT EXISTS (
82
+ SELECT 1 FROM graph_entities ge
83
+ WHERE ge.tenant_id = eg.tenant_id AND ge.id::text = eg.target_id
84
+ )
85
+ """,
86
+ (tenant_id, list(ENTITY_TARGET_TYPES), _UUID_RE),
87
+ )
88
+ return cur.rowcount
89
+
90
+
91
+ def build_queue(
92
+ conn: psycopg.Connection[Any],
93
+ *,
94
+ cfg: Config,
95
+ tenant_id: str,
96
+ detectors: list[GapDetector],
97
+ limit: int,
98
+ include_low_confidence: bool = False,
99
+ signal_kinds: Sequence[str] | None = None,
100
+ target_ids: Sequence[str] | None = None,
101
+ target_types: Sequence[str] | None = None,
102
+ ) -> list[Gap]:
103
+ """Run ``detectors``, upsert results, then return the filtered open queue.
104
+
105
+ Detection results are RRF-merged across signal kinds, upserted into
106
+ ``elicitation_gaps`` (with the partial-index conflict clause so resolved gaps
107
+ are not overwritten), and then read back in score order with evidence and
108
+ confidence filters applied.
109
+
110
+ ``signal_kinds`` / ``target_ids`` / ``target_types`` optionally scope the
111
+ read-back so the caller sees only the gaps it asked about. They restrict the
112
+ SELECT, not the detectors: ``brain elicit --signal delta`` runs only the
113
+ delta detector AND
114
+ filters the read-back to ``signal_kind = 'delta'`` so unrelated pre-existing
115
+ open gaps from prior runs don't leak into the session. ``None`` (the
116
+ default) adds no predicate — ``brain elicit list`` and the unfiltered
117
+ ``brain elicit`` still see the whole open queue.
118
+ """
119
+ # Discovery cap: honour an explicit caller ``limit`` larger than the config
120
+ # default so ``brain elicit list --limit 100`` can surface up to 100 gaps,
121
+ # while the unfiltered default still discovers ``cfg.elicit_queue_limit``.
122
+ # The read-back ``LIMIT`` below stays the caller's ``limit`` exactly.
123
+ discovery_limit = max(limit, cfg.elicit_queue_limit)
124
+ by_signal: dict[str, list[Gap]] = {}
125
+ for det in detectors:
126
+ by_signal[det.signal_kind] = det.detect(
127
+ conn, tenant_id=tenant_id, limit=discovery_limit
128
+ )
129
+ ranked = _rank_gaps(by_signal)
130
+ # Evidence guard: skip gaps with too few supporting docs. ``user_flagged``
131
+ # (an explicit user request) and ``search_failure`` (a repeated failed
132
+ # search — by definition NO doc answers it, so ``evidence_ids`` is always
133
+ # empty) are both exempt; the signal itself is the evidence.
134
+ ranked = [
135
+ g
136
+ for g in ranked
137
+ if len(g.evidence_ids) >= cfg.elicit_min_evidence_docs
138
+ or g.signal_kind in ("user_flagged", "search_failure")
139
+ ]
140
+ # Upsert passing gaps; the partial index prevents overwriting resolved rows.
141
+ for g in ranked:
142
+ conn.execute(
143
+ """
144
+ INSERT INTO elicitation_gaps
145
+ (tenant_id, signal_kind, target_type, target_id,
146
+ score, evidence_ids, rationale)
147
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
148
+ ON CONFLICT (tenant_id, signal_kind, target_id) WHERE status <> 'resolved'
149
+ DO UPDATE SET
150
+ score = EXCLUDED.score,
151
+ evidence_ids = EXCLUDED.evidence_ids,
152
+ rationale = EXCLUDED.rationale,
153
+ updated_at = now()
154
+ """,
155
+ (
156
+ tenant_id,
157
+ g.signal_kind,
158
+ g.target_type,
159
+ g.target_id,
160
+ g.score,
161
+ g.evidence_ids,
162
+ g.rationale,
163
+ ),
164
+ )
165
+ # F2 self-heal: drop open gaps whose entity target was merged/deleted (e.g. by
166
+ # the cross-type collapse) so a phantom gap never lingers in the queue.
167
+ _purge_orphaned_entity_gaps(conn, tenant_id)
168
+ # Read back the open queue with score and confidence floors applied, plus
169
+ # the optional signal/target scoping (parameterized; static clause strings
170
+ # only — no user values are interpolated into the SQL).
171
+ floor = 0.0 if include_low_confidence else cfg.elicit_min_gap_score
172
+ clauses = [
173
+ "eg.tenant_id = %s",
174
+ "eg.status IN ('surfaced', 'snoozed')",
175
+ "(eg.snoozed_until IS NULL OR eg.snoozed_until < now())",
176
+ "eg.score >= %s",
177
+ # Mirror the pre-upsert evidence guard on read-back so previously
178
+ # persisted sparse gaps can't resurface (user_flagged and
179
+ # search_failure are exempt, exactly as in the upsert filter above).
180
+ "(cardinality(eg.evidence_ids) >= %s "
181
+ "OR eg.signal_kind IN ('user_flagged', 'search_failure'))",
182
+ ]
183
+ params: list[Any] = [tenant_id, floor, cfg.elicit_min_evidence_docs]
184
+ if signal_kinds is not None:
185
+ clauses.append("eg.signal_kind = ANY(%s)")
186
+ params.append(list(signal_kinds))
187
+ if target_ids is not None:
188
+ clauses.append("eg.target_id = ANY(%s)")
189
+ params.append(list(target_ids))
190
+ if target_types is not None:
191
+ clauses.append("eg.target_type = ANY(%s)")
192
+ params.append(list(target_types))
193
+ params.append(limit)
194
+ # LEFT JOIN graph_entities on TEXT equality so a raw-string target_id
195
+ # (e.g. a user_flagged gap whose target is not a UUID) never triggers a
196
+ # uuid-cast error — it simply finds no match and target_name stays "".
197
+ sql = (
198
+ "SELECT eg.id::text, eg.signal_kind, eg.target_type, eg.target_id, "
199
+ "eg.score, eg.evidence_ids, eg.rationale, ge.name "
200
+ "FROM elicitation_gaps eg "
201
+ "LEFT JOIN graph_entities ge "
202
+ "ON ge.tenant_id = eg.tenant_id AND ge.id::text = eg.target_id "
203
+ f"WHERE {' AND '.join(clauses)} "
204
+ "ORDER BY eg.score DESC LIMIT %s"
205
+ )
206
+ rows = conn.execute(sql, tuple(params)).fetchall()
207
+ return [
208
+ Gap(
209
+ gap_id=r[0],
210
+ signal_kind=r[1],
211
+ target_type=r[2],
212
+ target_id=r[3],
213
+ score=float(r[4]),
214
+ evidence_ids=list(r[5]),
215
+ evidence_texts=[],
216
+ rationale=r[6],
217
+ target_name=r[7] or "",
218
+ )
219
+ for r in rows
220
+ ]
brain/elicit/schema.py ADDED
@@ -0,0 +1,48 @@
1
+ """Frozen value objects for tacit-knowledge elicitation."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Literal
6
+
7
+ SignalKind = Literal[
8
+ "delta", "orphan", "contradiction", "user_flagged", "search_failure"
9
+ ]
10
+ TargetType = Literal["person", "org", "project", "topic", "tool", "doc"]
11
+ OutcomeAction = Literal["accepted", "skipped", "snoozed", "dismissed"]
12
+
13
+ # Target types whose ``target_id`` is a real ``graph_entities`` UUID (so the gap
14
+ # can be orphaned when its entity is merged/deleted — used by the F2 self-heal in
15
+ # :mod:`brain.elicit.queue` and the name resolution in :mod:`brain.elicit.session`).
16
+ ENTITY_TARGET_TYPES: frozenset[str] = frozenset(
17
+ {"person", "org", "project", "topic", "tool"}
18
+ )
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Gap:
23
+ gap_id: str
24
+ signal_kind: SignalKind
25
+ target_type: TargetType
26
+ target_id: str
27
+ score: float
28
+ evidence_ids: list[str] = field(default_factory=list)
29
+ evidence_texts: list[str] = field(default_factory=list)
30
+ rationale: str = ""
31
+ target_name: str = ""
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ElicitDraft:
36
+ gap_id: str
37
+ title: str
38
+ draft_text: str
39
+ evidence_ids: list[str] = field(default_factory=list)
40
+ evidence_texts: list[str] = field(default_factory=list)
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ElicitOutcome:
45
+ gap_id: str
46
+ action: OutcomeAction
47
+ note_id: str | None = None
48
+ snoozed_days: int | None = None