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/gaps.py ADDED
@@ -0,0 +1,390 @@
1
+ """Search-query logging and search-failure-driven knowledge-gap detection."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import re
6
+ import uuid
7
+ from collections import Counter
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import psycopg
12
+
13
+ from .set_similarity import jaccard
14
+
15
+ if TYPE_CHECKING:
16
+ from .elicit.schema import Gap
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Strip punctuation to whitespace before tokenizing so "q3-hiring" and
21
+ # "q3 hiring" normalize identically.
22
+ _PUNCT_RE = re.compile(r"[^\w\s]")
23
+
24
+ # Two SQL passes mined by both the read view (``top_search_failures``) and the
25
+ # detector (``SearchFailureDetector.detect``). Both are parameterized; the
26
+ # lookback window is bound as an integer day-count via ``make_interval``.
27
+ # Lexical-miss pass. The headline knowledge-gap signal.
28
+ #
29
+ # Design bug found in live QA: hybrid search's VECTOR leg always returns
30
+ # nearest neighbours, so an off-corpus query logs ``result_count > 0`` (filler)
31
+ # and a ``result_count = 0`` predicate NEVER fires on the CLI path. The real
32
+ # signal is the FTS (lexical) leg matching ZERO chunks (``fts_count = 0``): the
33
+ # corpus has no lexical trace of the query. We keep the ``'zero_results'`` kind
34
+ # label (see ``top_search_failures`` / the detector) for backward compatibility
35
+ # — the *meaning* ("the corpus had no useful answer") is unchanged; only the
36
+ # detection mechanism moved from ``result_count`` to ``fts_count``.
37
+ #
38
+ # Old rows predate migration 023 and carry ``fts_count IS NULL``. For them we
39
+ # fall back to the historical ``result_count = 0`` semantics so no past
40
+ # zero-result evidence is lost; the new signal applies to rows written after
41
+ # the fix. Both branches are index-backed (migration 019's result_count=0
42
+ # partial index + migration 023's fts_count=0 partial index → a BitmapOr plan).
43
+ _ZERO_RESULT_SQL = """
44
+ SELECT query, COUNT(*) AS n
45
+ FROM search_queries
46
+ WHERE tenant_id = %s
47
+ AND (fts_count = 0 OR (fts_count IS NULL AND result_count = 0))
48
+ AND at >= NOW() - make_interval(days => %s)
49
+ GROUP BY query
50
+ ORDER BY n DESC, query
51
+ """
52
+
53
+ # No-click: a non-empty result set that the user never opened/clicked within
54
+ # the same search session. MCP-only — CLI searches carry ``session_id IS NULL``
55
+ # (no session to join), so the predicate excludes them automatically.
56
+ #
57
+ # Mutual exclusivity with the lexical-miss pass (matters since migration 023):
58
+ # a lexical miss now keys off ``fts_count = 0``, but such a row can still have
59
+ # ``result_count > 0`` (vector filler). Without the ``fts_count`` guard below,
60
+ # an MCP lexical-miss that was never opened would match BOTH passes and get
61
+ # counted twice (and emit two ``SearchFailure`` rows for one event). We exclude
62
+ # ``fts_count = 0`` rows here so each event is attributed to exactly one pass —
63
+ # the lexical miss is the stronger signal. ``fts_count IS NULL`` (legacy /
64
+ # pre-023 rows) stays eligible for no-click, preserving the prior behavior
65
+ # where ``result_count = 0`` and ``result_count > 0`` were already exclusive.
66
+ _NO_CLICK_SQL = """
67
+ SELECT sq.query, COUNT(*) AS n
68
+ FROM search_queries sq
69
+ WHERE sq.tenant_id = %s
70
+ AND sq.result_count > 0
71
+ AND (sq.fts_count IS NULL OR sq.fts_count > 0)
72
+ AND sq.session_id IS NOT NULL
73
+ AND sq.at >= NOW() - make_interval(days => %s)
74
+ AND NOT EXISTS (
75
+ SELECT 1 FROM interactions i
76
+ WHERE i.session_id = sq.session_id
77
+ AND i.action IN ('clicked', 'opened')
78
+ )
79
+ GROUP BY sq.query
80
+ ORDER BY n DESC, sq.query
81
+ """
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class SearchFailure:
86
+ """One ranked failed-query row for the ``brain gaps`` read view.
87
+
88
+ ``kind`` is ``'zero_results'`` (the search returned nothing) or
89
+ ``'no_click'`` (results were returned but never opened in-session).
90
+ ``query`` is the raw stored string for the CLI surface, or the derived
91
+ normalized canonical label when ``top_search_failures(normalize=True)``
92
+ (the MCP surface — raw query strings stay server-side; see Plan 08 §6).
93
+ """
94
+
95
+ query: str
96
+ count: int
97
+ kind: str
98
+
99
+
100
+ def record_search_query(
101
+ conn: psycopg.Connection[Any],
102
+ *,
103
+ query: str,
104
+ result_count: int,
105
+ session_id: uuid.UUID | None,
106
+ source: str,
107
+ fts_count: int | None = None,
108
+ tenant_id: str = "default",
109
+ ) -> None:
110
+ """INSERT one row into ``search_queries``. Best-effort on a transient blip.
111
+
112
+ A single parameterized INSERT. Callers run with ``autocommit=True`` so this
113
+ is one round-trip; logging must never slow or break the search response.
114
+
115
+ Error contract (Plan 08 §3 / §3e):
116
+
117
+ - :class:`psycopg.OperationalError` (a transient connection blip) is
118
+ **swallowed** — a DB hiccup must not break a search the user already got
119
+ results for. Logged at WARNING with the exception *type* only.
120
+ - :class:`psycopg.errors.UndefinedTable` (migration 019 not applied) is
121
+ **swallowed with a loud, actionable WARNING** naming ``brain init``.
122
+ Search is the daily-driver command; a binary upgrade that lands before
123
+ the operator re-runs ``brain init`` must never break search itself
124
+ (observed live against a pre-019 prod DB). The operator still sees the
125
+ warning on every search until the migration is applied, and the
126
+ ``brain gaps`` surfaces fail loudly with the same hint. The INSERT runs
127
+ inside its own ``conn.transaction()`` (savepoint when nested) so the
128
+ failure never poisons or rolls back the caller's transaction.
129
+ - :class:`psycopg.errors.UndefinedColumn` naming ``fts_count`` (migration
130
+ 023 not applied — a pre-023 DB that has the table but lacks the new
131
+ column) gets the **same swallow-with-hint** treatment, for the same
132
+ daily-driver reason: a binary that writes ``fts_count`` must not break
133
+ search on a DB that hasn't run ``brain init`` since the upgrade. The
134
+ guard is narrowed to the ``fts_count`` column so a genuinely-unknown
135
+ column still propagates as a real bug.
136
+ - Any other schema/programming error **propagates** — those are real bugs
137
+ that must surface visibly, never be silently eaten.
138
+
139
+ Privacy (Plan 08 §6 — firm contract): the raw ``query`` string MUST NOT
140
+ appear at INFO level or above. It may only be logged at DEBUG (the blip
141
+ path below) where local-only debugging is the explicit opt-in.
142
+ """
143
+ try:
144
+ # The inner transaction() scopes the best-effort INSERT: a savepoint
145
+ # when the caller is already in a transaction, a plain transaction
146
+ # under autocommit. On failure only THIS insert rolls back — the
147
+ # caller's prior work and open transaction state are untouched (a
148
+ # bare conn.rollback() here would clobber both, and is forbidden
149
+ # inside an explicit conn.transaction() block).
150
+ with conn.transaction():
151
+ conn.execute(
152
+ """
153
+ INSERT INTO search_queries
154
+ (tenant_id, query, result_count, fts_count, session_id, source)
155
+ VALUES (%s, %s, %s, %s, %s, %s)
156
+ """,
157
+ (
158
+ tenant_id,
159
+ query,
160
+ result_count,
161
+ fts_count,
162
+ str(session_id) if session_id is not None else None,
163
+ source,
164
+ ),
165
+ )
166
+ except psycopg.errors.UndefinedTable:
167
+ # Migration 019 not applied yet (e.g. binary upgraded before `brain
168
+ # init` re-ran). Search must keep working; nag until the operator
169
+ # migrates.
170
+ logger.warning(
171
+ "search-query logging skipped: search_queries table missing "
172
+ "(migration 019 not applied) — run `brain init` to enable "
173
+ "`brain gaps`"
174
+ )
175
+ except psycopg.errors.UndefinedColumn as exc:
176
+ # Pre-023 DB: the table exists but lacks the additive ``fts_count``
177
+ # column (migration 023 not applied — e.g. binary upgraded before
178
+ # `brain init` re-ran). Same daily-driver contract as the missing-table
179
+ # case above: search must keep working; nag until the operator
180
+ # migrates. Narrowed to the fts_count column so a genuinely-unknown
181
+ # column still propagates as a real bug.
182
+ if "fts_count" not in str(exc):
183
+ raise
184
+ logger.warning(
185
+ "search-query logging skipped: search_queries.fts_count column "
186
+ "missing (migration 023 not applied) — run `brain init` to enable "
187
+ "lexical-gap detection in `brain gaps`"
188
+ )
189
+ except psycopg.OperationalError as exc:
190
+ # Transient blip only — schema/programming errors other than the
191
+ # missing-table case above propagate as real bugs.
192
+ logger.warning(
193
+ "search-query logging skipped (DB blip): %s", type(exc).__name__
194
+ )
195
+ logger.debug("search-query logging failed for query=%r: %s", query, exc)
196
+
197
+
198
+ def search_queries_schema_hint(exc: psycopg.Error) -> str | None:
199
+ """Map a missing ``search_queries`` schema object to a `brain init` hint.
200
+
201
+ The ``brain gaps`` read path (:func:`top_search_failures`,
202
+ :class:`SearchFailureDetector`) reads the ``search_queries`` table and,
203
+ since migration 023, its ``fts_count`` column. On a DB that hasn't applied
204
+ migration 019 (no table) or 023 (no column) the query raises, and the
205
+ surfaces (CLI / MCP) must fail loudly-but-cleanly with an actionable hint
206
+ instead of a traceback — mirroring the swallow-with-hint contract the
207
+ search write path uses in :func:`record_search_query`.
208
+
209
+ Returns the hint string for the two known migration gaps, or ``None`` for
210
+ any other error (a genuinely-unknown column / real bug) so the caller
211
+ re-raises it.
212
+ """
213
+ if isinstance(exc, psycopg.errors.UndefinedTable):
214
+ return (
215
+ "search_queries table missing (migration 019 not applied) — "
216
+ "run `brain init` first"
217
+ )
218
+ if isinstance(exc, psycopg.errors.UndefinedColumn) and "fts_count" in str(exc):
219
+ return (
220
+ "search_queries.fts_count column missing (migration 023 not "
221
+ "applied) — run `brain init` first"
222
+ )
223
+ return None
224
+
225
+
226
+ def _normalize_tokens(query: str) -> frozenset[str]:
227
+ """Casefold, strip punctuation, split on whitespace → distinct token set."""
228
+ cleaned = _PUNCT_RE.sub(" ", query.casefold())
229
+ return frozenset(cleaned.split())
230
+
231
+
232
+ def _canonical_key(query: str) -> str:
233
+ """Collision-resistant normalized label: sorted distinct tokens, space-joined.
234
+
235
+ Aggressive normalization (casefold + dedup + sort) so two queries that
236
+ differ only in token order or punctuation collapse to the same
237
+ ``elicitation_gaps.target_id`` — a residual collision is a harmless
238
+ score-update upsert (Plan 08 §6).
239
+ """
240
+ return " ".join(sorted(_normalize_tokens(query)))
241
+
242
+
243
+ def cluster_failed_queries(
244
+ queries: list[str], *, threshold: float = 0.5
245
+ ) -> list[list[str]]:
246
+ """Greedily group queries by token-Jaccard similarity ≥ ``threshold``.
247
+
248
+ Pure Python — no DB, no LLM. Each query is normalized to a distinct token
249
+ set (:func:`_normalize_tokens`); a query joins the first existing cluster
250
+ whose seed token set it overlaps by Jaccard ≥ ``threshold``, else it seeds a
251
+ new cluster. Returned clusters preserve the original query strings (so the
252
+ caller can count frequencies) and are ordered by descending size.
253
+
254
+ Example at the default ``threshold=0.5``: ``"q3 hiring"`` and
255
+ ``"q3 hiring plan"`` share 2 of 3 distinct tokens (Jaccard ≈ 0.67) → one
256
+ cluster; ``"benefits policy"`` and ``"benefits plan"`` share 1 of 3
257
+ (Jaccard ≈ 0.33) → separate clusters.
258
+ """
259
+ clusters: list[list[str]] = []
260
+ seeds: list[frozenset[str]] = []
261
+ for query in queries:
262
+ tokens = _normalize_tokens(query)
263
+ for idx, seed in enumerate(seeds):
264
+ if jaccard(tokens, seed) >= threshold:
265
+ clusters[idx].append(query)
266
+ break
267
+ else:
268
+ clusters.append([query])
269
+ seeds.append(tokens)
270
+ order = sorted(
271
+ range(len(clusters)), key=lambda i: (-len(clusters[i]), clusters[i][0])
272
+ )
273
+ return [clusters[i] for i in order]
274
+
275
+
276
+ def _failed_query_counts(
277
+ conn: psycopg.Connection[Any], *, tenant_id: str, lookback_days: int
278
+ ) -> dict[str, int]:
279
+ """Merge zero-result + no-click occurrence counts per raw query string."""
280
+ counts: dict[str, int] = {}
281
+ for sql in (_ZERO_RESULT_SQL, _NO_CLICK_SQL):
282
+ for query, n in conn.execute(sql, (tenant_id, lookback_days)).fetchall():
283
+ counts[query] = counts.get(query, 0) + int(n)
284
+ return counts
285
+
286
+
287
+ class SearchFailureDetector:
288
+ """Surface repeated search failures as ``search_failure`` knowledge gaps.
289
+
290
+ Implements the :class:`brain.elicit.detectors.GapDetector` protocol so it
291
+ plugs into ``build_queue`` / ``DETECTOR_REGISTRY`` unmodified. Mines the
292
+ ``search_queries`` log over ``lookback_days``, clusters near-duplicate
293
+ failed queries, and emits one :class:`Gap` per cluster whose total
294
+ occurrence count is ≥ ``min_cluster_size``.
295
+
296
+ Each gap carries ``evidence_ids=[]`` — intentionally empty, since the
297
+ defining property of a search-failure gap is that *no* document answers it.
298
+ ``build_queue`` exempts ``search_failure`` from the evidence-docs guard
299
+ exactly as it does ``user_flagged``.
300
+ """
301
+
302
+ signal_kind = "search_failure"
303
+
304
+ def __init__(self, *, lookback_days: int, min_cluster_size: int) -> None:
305
+ self._lookback_days = lookback_days
306
+ self._min_cluster_size = min_cluster_size
307
+
308
+ def detect(
309
+ self, conn: psycopg.Connection[Any], *, tenant_id: str, limit: int
310
+ ) -> list[Gap]:
311
+ # Local import keeps ``brain.gaps`` import-light: ``elicit.schema`` (and
312
+ # the ``brain.elicit`` package __init__ it triggers) is only loaded when
313
+ # the detector actually runs, breaking the
314
+ # detectors → gaps → elicit.schema → detectors cycle at module load.
315
+ from .elicit.schema import Gap
316
+
317
+ counts = _failed_query_counts(
318
+ conn, tenant_id=tenant_id, lookback_days=self._lookback_days
319
+ )
320
+ if not counts:
321
+ return []
322
+ # Expand to a flat occurrence list so cluster size == raw frequency and
323
+ # the canonical label is the most-frequent member of its cluster.
324
+ expanded = [q for q, n in counts.items() for _ in range(n)]
325
+ gaps: list[Gap] = []
326
+ for cluster in cluster_failed_queries(expanded):
327
+ size = len(cluster)
328
+ if size < self._min_cluster_size:
329
+ continue
330
+ most_common = Counter(cluster).most_common(1)[0][0]
331
+ canonical_label = _canonical_key(most_common)
332
+ gaps.append(
333
+ Gap(
334
+ gap_id=str(uuid.uuid4()),
335
+ signal_kind="search_failure",
336
+ target_type="topic",
337
+ target_id=canonical_label,
338
+ score=float(size),
339
+ evidence_ids=[],
340
+ rationale=(
341
+ f"Asked {size} time(s) with no useful result: "
342
+ f"'{canonical_label}'."
343
+ ),
344
+ )
345
+ )
346
+ if len(gaps) >= limit:
347
+ break
348
+ return gaps
349
+
350
+
351
+ def top_search_failures(
352
+ conn: psycopg.Connection[Any],
353
+ *,
354
+ tenant_id: str = "default",
355
+ since_days: int,
356
+ limit: int,
357
+ normalize: bool = False,
358
+ ) -> list[SearchFailure]:
359
+ """Rank failed queries (zero-result + no-click) over the lookback window.
360
+
361
+ Read-only view backing ``brain gaps`` / MCP ``brain_gaps`` — never writes to
362
+ ``elicitation_gaps``. When ``normalize=True`` (the MCP surface) each query is
363
+ collapsed to its normalized canonical label and counts are summed per
364
+ (label, kind), so only derived strings — never raw stored query text — leave
365
+ the server (Plan 08 §6). When ``normalize=False`` (the local CLI surface)
366
+ raw query strings are returned as-is.
367
+ """
368
+ rows: list[tuple[str, int, str]] = []
369
+ limited = " LIMIT %s"
370
+ for query, n in conn.execute(
371
+ _ZERO_RESULT_SQL + limited, (tenant_id, since_days, limit)
372
+ ).fetchall():
373
+ rows.append((query, int(n), "zero_results"))
374
+ for query, n in conn.execute(
375
+ _NO_CLICK_SQL + limited, (tenant_id, since_days, limit)
376
+ ).fetchall():
377
+ rows.append((query, int(n), "no_click"))
378
+
379
+ if normalize:
380
+ agg: dict[tuple[str, str], int] = {}
381
+ for query, n, kind in rows:
382
+ key = (_canonical_key(query), kind)
383
+ agg[key] = agg.get(key, 0) + n
384
+ rows = [(label, n, kind) for (label, kind), n in agg.items()]
385
+
386
+ rows.sort(key=lambda t: (-t[1], t[0]))
387
+ return [
388
+ SearchFailure(query=query, count=n, kind=kind)
389
+ for query, n, kind in rows[:limit]
390
+ ]
@@ -0,0 +1,94 @@
1
+ """GraphRAG: entity-centric graph retrieval alongside the vector/hybrid RAG.
2
+
3
+ The public API grows per wave. Value objects live in
4
+ :mod:`brain.graph_rag.schema`; the storage/traversal backends (``GraphBackend``
5
+ Protocol + the default Apache ``AgeBackend``) live in
6
+ :mod:`brain.graph_rag.backends`; the incremental reconcile (person aspect, wave
7
+ G1-b) lives in :mod:`brain.graph_rag.reconcile`. Concept extraction +
8
+ ``graph_rag_search`` arrive in G2.
9
+ """
10
+ from .backends import AgeBackend, GraphBackend, PersonScope, TraversalHit
11
+ from .build import BuildResult, build_graph
12
+ from .communities import (
13
+ BUILD_VERSION,
14
+ CommunityBuildResult,
15
+ DetectedCommunity,
16
+ build_communities,
17
+ compute_members_hash,
18
+ compute_source_graph_hash,
19
+ detect_communities,
20
+ list_communities,
21
+ match_communities,
22
+ )
23
+ from .communities_summary import (
24
+ CommunitySummaryResult,
25
+ summarize_communities,
26
+ )
27
+ from .extract import (
28
+ EntityExtractor,
29
+ ExtractedEntity,
30
+ OllamaExtractor,
31
+ make_extractor,
32
+ )
33
+ from .grouping import group_themes
34
+ from .reconcile import (
35
+ CONCEPTS_ASPECT,
36
+ PersonResolver,
37
+ ReconcileConfig,
38
+ ReconcileResult,
39
+ RefreshResult,
40
+ ResolvedPerson,
41
+ default_person_resolver,
42
+ reconcile_document,
43
+ refresh_aggregates,
44
+ remove_document,
45
+ )
46
+ from .retrieve import (
47
+ AUTO_MODE,
48
+ FUSE_MODE,
49
+ GLOBAL_MODE,
50
+ LOCAL_MODE,
51
+ THEMES_MODE,
52
+ graph_rag_search,
53
+ )
54
+
55
+ __all__ = [
56
+ "AUTO_MODE",
57
+ "BUILD_VERSION",
58
+ "CONCEPTS_ASPECT",
59
+ "FUSE_MODE",
60
+ "GLOBAL_MODE",
61
+ "LOCAL_MODE",
62
+ "THEMES_MODE",
63
+ "AgeBackend",
64
+ "BuildResult",
65
+ "CommunityBuildResult",
66
+ "CommunitySummaryResult",
67
+ "DetectedCommunity",
68
+ "EntityExtractor",
69
+ "ExtractedEntity",
70
+ "GraphBackend",
71
+ "OllamaExtractor",
72
+ "PersonResolver",
73
+ "PersonScope",
74
+ "ReconcileConfig",
75
+ "ReconcileResult",
76
+ "RefreshResult",
77
+ "ResolvedPerson",
78
+ "TraversalHit",
79
+ "build_communities",
80
+ "build_graph",
81
+ "compute_members_hash",
82
+ "compute_source_graph_hash",
83
+ "default_person_resolver",
84
+ "detect_communities",
85
+ "graph_rag_search",
86
+ "group_themes",
87
+ "list_communities",
88
+ "make_extractor",
89
+ "match_communities",
90
+ "reconcile_document",
91
+ "refresh_aggregates",
92
+ "remove_document",
93
+ "summarize_communities",
94
+ ]
@@ -0,0 +1,113 @@
1
+ """Shared retrieval helpers for the local + themes graph paths (G2 file-size split).
2
+
3
+ Extracted from :mod:`brain.graph_rag.retrieve` (the G2 wave-boundary file-size
4
+ split, mirroring the G1 :mod:`brain.graph_rag.aggregates` / G2-c
5
+ :mod:`brain.graph_rag.relational` extractions) so the local-path/dispatch module
6
+ (:mod:`brain.graph_rag.retrieve`) and the themes module
7
+ (:mod:`brain.graph_rag.themes`) share ONE copy of the entity-row mapping +
8
+ ranked-document-to-:class:`~brain.search.SearchResult` snippet shaping. This is a
9
+ pure move — no behavior change.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ import psycopg
16
+
17
+ from .schema import GraphEntity
18
+
19
+ if TYPE_CHECKING:
20
+ from ..search import SearchResult
21
+
22
+
23
+ def _fetch_entities(
24
+ conn: psycopg.Connection[Any], tenant_id: str, entity_ids: list[str]
25
+ ) -> list[GraphEntity]:
26
+ """Load ``graph_entities`` rows for the reached entity ids (tenant-scoped)."""
27
+ if not entity_ids:
28
+ return []
29
+ rows = conn.execute(
30
+ "SELECT id::text, entity_type, name, canonical_key, description, doc_count "
31
+ "FROM graph_entities WHERE tenant_id = %s AND id = ANY(%s)",
32
+ (tenant_id, entity_ids),
33
+ ).fetchall()
34
+ return [_row_to_entity(row, tenant_id) for row in rows]
35
+
36
+
37
+ def _row_to_entity(row: tuple[Any, ...], tenant_id: str) -> GraphEntity:
38
+ """Map a ``(id, entity_type, name, canonical_key, description, doc_count)``
39
+ row to a :class:`GraphEntity` value object."""
40
+ return GraphEntity(
41
+ id=str(row[0]),
42
+ entity_type=str(row[1]),
43
+ name=str(row[2]),
44
+ canonical_key=str(row[3]),
45
+ tenant_id=tenant_id,
46
+ description=row[4],
47
+ doc_count=int(row[5]),
48
+ )
49
+
50
+
51
+ def _build_doc_results(
52
+ conn: psycopg.Connection[Any],
53
+ query: str,
54
+ ranked: list[tuple[str, float]],
55
+ ) -> list[SearchResult]:
56
+ """Shape ranked documents into ``SearchResult``s, reusing the snippet path.
57
+
58
+ Reuses :data:`brain.search.SearchResult` (spec §4 D8 — graph docs may reuse
59
+ the search hit shape), :data:`brain.search.SNIPPET_LENGTH`, and
60
+ :func:`brain.search._build_tsquery` so snippet selection matches hybrid
61
+ search rather than reinventing it: per document, the best chunk by ``ts_rank``
62
+ for the query, falling back to the leading chunk (lowest ``chunk_index``)
63
+ when the query matches nothing. ``score`` carries the *graph* document score
64
+ (not an RRF score). Document order preserves the graph ranking.
65
+ """
66
+ # Late import keeps :mod:`brain.graph_rag` import-cheap and free of any
67
+ # import cycle with the ingest pipeline that :mod:`brain.search` pulls in
68
+ # (mirrors the TYPE_CHECKING-only SearchResult reference in schema.py).
69
+ from ..search import SNIPPET_LENGTH, SearchResult, _build_tsquery
70
+
71
+ if not ranked:
72
+ return []
73
+ doc_ids = [doc_id for doc_id, _ in ranked]
74
+ scores = dict(ranked)
75
+
76
+ meta_rows = conn.execute(
77
+ "SELECT d.id::text, d.title, d.content_type, d.tags, s.kind "
78
+ "FROM documents d LEFT JOIN sources s ON s.id = d.source_id "
79
+ "WHERE d.id = ANY(%s)",
80
+ (doc_ids,),
81
+ ).fetchall()
82
+ meta = {str(row[0]): row for row in meta_rows}
83
+
84
+ tsquery = _build_tsquery(conn, query)
85
+ snippet_rows = conn.execute(
86
+ "SELECT DISTINCT ON (c.document_id) c.document_id::text, c.content "
87
+ "FROM chunks c WHERE c.document_id = ANY(%s) "
88
+ "ORDER BY c.document_id, "
89
+ "ts_rank(c.tsv, to_tsquery('english', %s)) DESC, c.chunk_index ASC",
90
+ (doc_ids, tsquery),
91
+ ).fetchall()
92
+ snippet_by_doc = {str(row[0]): str(row[1]) for row in snippet_rows}
93
+
94
+ results: list[SearchResult] = []
95
+ for doc_id in doc_ids: # preserve graph ranking order
96
+ row = meta.get(doc_id)
97
+ if row is None:
98
+ # A mention referenced a document with no surviving ``documents`` row.
99
+ # Defensive: ``ON DELETE CASCADE`` normally clears mentions first.
100
+ continue
101
+ snippet = snippet_by_doc.get(doc_id, "")[:SNIPPET_LENGTH]
102
+ results.append(
103
+ SearchResult(
104
+ document_id=doc_id,
105
+ title=row[1],
106
+ content_type=row[2],
107
+ tags=list(row[3] or []),
108
+ source_kind=row[4],
109
+ snippet=snippet,
110
+ score=scores[doc_id],
111
+ )
112
+ )
113
+ return results