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/ingest/pdf.py ADDED
@@ -0,0 +1,61 @@
1
+ """PDF extractor — pypdf primary, pdfplumber fallback."""
2
+ from pathlib import Path
3
+
4
+ from pypdf import PdfReader
5
+
6
+ from . import ExtractedDoc
7
+
8
+
9
+ def extract_pdf(path: Path) -> ExtractedDoc:
10
+ """Extract an :class:`ExtractedDoc` from a PDF file on disk.
11
+
12
+ Uses ``pypdf`` as the primary extractor. If no text is recovered (e.g. a
13
+ scanned PDF with images only), falls back to ``pdfplumber``, which handles
14
+ some layouts ``pypdf`` misses. Strips repeated header/footer lines that
15
+ appear on a majority of pages.
16
+ """
17
+ reader = PdfReader(str(path))
18
+ pages_text: list[str] = []
19
+ for page in reader.pages:
20
+ text = page.extract_text() or ""
21
+ pages_text.append(text.strip())
22
+
23
+ full_text = _strip_repeated_lines("\n\n".join(pages_text))
24
+
25
+ if not full_text.strip(): # pragma: no cover - only triggered by image-only PDFs
26
+ full_text = _fallback_pdfplumber(path)
27
+
28
+ return ExtractedDoc(
29
+ title=Path(path).stem,
30
+ content=full_text.strip(),
31
+ content_type="pdf",
32
+ source_path=str(Path(path).resolve()),
33
+ metadata={"page_count": len(reader.pages)},
34
+ )
35
+
36
+
37
+ def _fallback_pdfplumber(path: Path) -> str: # pragma: no cover - image-only PDFs
38
+ import pdfplumber
39
+
40
+ with pdfplumber.open(str(path)) as pdf:
41
+ return "\n\n".join((p.extract_text() or "").strip() for p in pdf.pages)
42
+
43
+
44
+ def _strip_repeated_lines(text: str) -> str:
45
+ """Remove header/footer lines that appear on >50% of pages."""
46
+ pages = text.split("\n\n")
47
+ if len(pages) < 3:
48
+ return text
49
+ line_counts: dict[str, int] = {}
50
+ for page in pages:
51
+ for line in {ln.strip() for ln in page.splitlines() if ln.strip()}:
52
+ line_counts[line] = line_counts.get(line, 0) + 1
53
+ threshold = len(pages) // 2
54
+ repeated = {line for line, count in line_counts.items() if count > threshold}
55
+ if not repeated:
56
+ return text
57
+ cleaned_pages = [
58
+ "\n".join(ln for ln in page.splitlines() if ln.strip() not in repeated)
59
+ for page in pages
60
+ ]
61
+ return "\n\n".join(cleaned_pages)
brain/ingest/stdin.py ADDED
@@ -0,0 +1,22 @@
1
+ """Generic stdin ingester for Claude-orchestrated sources (Krisp, Slack, etc)."""
2
+ from typing import Any
3
+
4
+ from . import ExtractedDoc
5
+
6
+
7
+ def make_doc(
8
+ *,
9
+ content: str,
10
+ title: str,
11
+ content_type: str,
12
+ source_path: str | None = None,
13
+ metadata: dict[str, Any] | None = None,
14
+ ) -> ExtractedDoc:
15
+ """Build an :class:`ExtractedDoc` from raw stdin content plus metadata."""
16
+ return ExtractedDoc(
17
+ title=title,
18
+ content=content.strip(),
19
+ content_type=content_type,
20
+ source_path=source_path,
21
+ metadata=metadata or {},
22
+ )
@@ -0,0 +1,91 @@
1
+ """Sub-token extractor — surface email/URL/hostname components for FTS."""
2
+ import re
3
+
4
+ # Standard noise TLDs / very common suffix words that add no retrieval value
5
+ # when emitted as standalone sub-tokens. Kept lowercase; comparison is
6
+ # case-insensitive.
7
+ _NOISE_TOKENS: frozenset[str] = frozenset(
8
+ {"com", "org", "net", "io", "co", "gov", "edu"}
9
+ )
10
+
11
+ # Match emails: local@host.tld[.tld...]
12
+ _EMAIL_RE = re.compile(r"[\w.+\-]+@[\w.\-]+\.\w+", re.UNICODE)
13
+
14
+ # Match URLs (http/https). Stops at whitespace and a few common closing
15
+ # punctuation chars so trailing `)`/`]`/`>` don't get folded into the URL.
16
+ _URL_RE = re.compile(r"https?://[^\s)\]>]+", re.UNICODE)
17
+
18
+ # Match bare hostnames: at least two dot-separated word groups, e.g.
19
+ # ``example.com/groups``. Restricted to word chars + hyphen so it doesn't
20
+ # match every dotted phrase.
21
+ _HOST_RE = re.compile(r"\b[\w\-]+(?:\.[\w\-]+)+\b", re.UNICODE)
22
+
23
+ # Splitter applied to each captured email/URL/host to break it into the
24
+ # individual word components. Includes ``@`` so email captures split on
25
+ # the local/host boundary, and ``:`` so any leftover scheme markers (e.g.
26
+ # port suffixes) don't fold a port number into the host word.
27
+ _SPLIT_RE = re.compile(r"[./+_@:?#&=]+", re.UNICODE)
28
+
29
+
30
+ def _is_useful(token: str) -> bool:
31
+ """Filter predicate — drop tokens too short or too noisy to keep."""
32
+ if len(token) <= 1:
33
+ return False
34
+ if token.isdigit():
35
+ return False
36
+ return token.lower() not in _NOISE_TOKENS
37
+
38
+
39
+ def _split_components(value: str) -> list[str]:
40
+ """Split a captured email/URL/hostname into its component words."""
41
+ return [part for part in _SPLIT_RE.split(value) if part]
42
+
43
+
44
+ def extract_sub_tokens(text: str) -> str:
45
+ """Extract sub-tokens from emails, URLs, and bare hostnames in ``text``.
46
+
47
+ Returns a single whitespace-joined string of word components in
48
+ *first-seen order* (deduplicated). Returns ``""`` when ``text`` has no
49
+ matches or is empty. Pure function — safe to call twice; idempotent in
50
+ the sense that re-running it on its own output never crashes and
51
+ produces a sane subset (no new sub-tokens to find since the output is
52
+ already plain space-separated words with no `.`/`@`/`/`).
53
+
54
+ Filters: tokens of length ≤ 1, digits-only tokens, and the noise
55
+ suffixes in :data:`_NOISE_TOKENS` are dropped.
56
+ """
57
+ if not text:
58
+ return ""
59
+
60
+ seen: set[str] = set()
61
+ ordered: list[str] = []
62
+
63
+ def _add(token: str) -> None:
64
+ if not _is_useful(token):
65
+ return
66
+ key = token.lower()
67
+ if key in seen:
68
+ return
69
+ seen.add(key)
70
+ ordered.append(token)
71
+
72
+ for match in _EMAIL_RE.finditer(text):
73
+ for component in _split_components(match.group(0)):
74
+ _add(component)
75
+
76
+ for match in _URL_RE.finditer(text):
77
+ url = match.group(0)
78
+ # Strip the scheme so we don't emit "https" / "http" as a sub-token.
79
+ if "://" in url:
80
+ url = url.split("://", 1)[1]
81
+ for component in _split_components(url):
82
+ _add(component)
83
+
84
+ for match in _HOST_RE.finditer(text):
85
+ host = match.group(0)
86
+ # Skip bare numerics like "1.2.3" (each part already filtered as
87
+ # digits-only, but we'd still touch the dedup table). Cheap guard.
88
+ for component in _split_components(host):
89
+ _add(component)
90
+
91
+ return " ".join(ordered)
brain/ingest/text.py ADDED
@@ -0,0 +1,16 @@
1
+ """Plain-text extractor."""
2
+ from pathlib import Path
3
+
4
+ from . import ExtractedDoc
5
+
6
+
7
+ def extract_text(path: Path) -> ExtractedDoc:
8
+ """Extract an :class:`ExtractedDoc` from a plain-text file on disk."""
9
+ content = Path(path).read_text(encoding="utf-8", errors="replace")
10
+ return ExtractedDoc(
11
+ title=Path(path).stem,
12
+ content=content.strip(),
13
+ content_type="txt",
14
+ source_path=str(Path(path).resolve()),
15
+ metadata={},
16
+ )
brain/interactions.py ADDED
@@ -0,0 +1,205 @@
1
+ """Append-only interaction log writes (one row per user event).
2
+
3
+ Each row in ``interactions`` records one feedback event (open / rate /
4
+ pin / click). Q1-C populated this table from two DOCUMENT surfaces:
5
+
6
+ - CLI: ``brain rate <id> useful|irrelevant`` → source='cli', action one
7
+ of ``rated_useful`` / ``rated_irrelevant``, ``session_id=NULL``.
8
+ - MCP: ``brain_show(..., originating_query=...)`` → source='mcp',
9
+ action='opened', ``query=originating_query``, optional
10
+ ``session_id`` minted by a prior ``brain_search`` call.
11
+
12
+ The wiki click surface (source='wiki') is reserved in the schema for a
13
+ future wave; this module accepts it today so the deferred surface needs
14
+ no additional migration.
15
+
16
+ G4-a (migration 015, spec §17d Q2) generalizes the writer so the graph
17
+ surfaces — entity / community / theme — become FIRST-CLASS rateable
18
+ targets. A row now targets EITHER a document (``document_id``) OR a graph
19
+ target (``target_type`` + ``target_id``), never both and never neither —
20
+ the XOR enforced both here (Python boundary) and by the authoritative DB
21
+ ``CHECK``. ``graph_retrieved`` is a PROVENANCE flag (a graph surface
22
+ produced this row), independent of the target shape: a document row
23
+ surfaced via a graph path is still a document row with
24
+ ``graph_retrieved=True``. No new ``source`` value is introduced.
25
+
26
+ Per plan §3.a, the writer is intentionally narrow: callers go through
27
+ :func:`record_interaction` instead of issuing INSERTs directly so the
28
+ Python-side enum + XOR validation can produce a clean
29
+ :class:`InteractionError` ahead of the authoritative DB-level ``CHECK``
30
+ constraints.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import uuid
35
+ from dataclasses import dataclass
36
+ from datetime import datetime
37
+ from typing import Any, Literal
38
+
39
+ import psycopg
40
+
41
+ from .errors import InteractionError
42
+
43
+ InteractionAction = Literal[
44
+ "clicked", "opened", "rated_useful", "rated_irrelevant", "pinned"
45
+ ]
46
+ InteractionSource = Literal["cli", "mcp", "wiki"]
47
+ InteractionTargetType = Literal["entity", "community", "theme"]
48
+
49
+
50
+ # Python-side enum gates — kept in sync with the SQL ``CHECK`` constraints
51
+ # on ``interactions.action`` / ``interactions.source`` (migration 010) and
52
+ # ``interactions.target_type`` (migration 015).
53
+ # Adding a new value here without the matching migration would silently
54
+ # bypass the Python gate but still trip the SQL ``CHECK``; adding to the
55
+ # SQL side without updating these sets would let a Python caller pass an
56
+ # enum value the DB rejects at INSERT time. Keep both in lockstep.
57
+ _VALID_ACTIONS: frozenset[str] = frozenset({
58
+ "clicked", "opened", "rated_useful", "rated_irrelevant", "pinned",
59
+ })
60
+ _VALID_SOURCES: frozenset[str] = frozenset({"cli", "mcp", "wiki"})
61
+ _VALID_TARGET_TYPES: frozenset[str] = frozenset({"entity", "community", "theme"})
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class InteractionRow:
66
+ """Read-back projection of one ``interactions`` row.
67
+
68
+ Used by tests + a future ``brain interactions show`` command (not
69
+ exposed yet — the table is write-only from the user's perspective for
70
+ now). ``id`` and ``document_id`` are stringified UUIDs so callers
71
+ don't need to import ``uuid`` just to compare; ``document_id`` is
72
+ ``None`` for graph-target rows (G4-a). ``target_type`` / ``target_id``
73
+ are set (and ``document_id`` ``None``) for graph-target rows, mutually
74
+ exclusive with ``document_id`` per the XOR. ``graph_retrieved`` is the
75
+ provenance flag, orthogonal to the target shape.
76
+ """
77
+
78
+ id: str
79
+ document_id: str | None
80
+ query: str | None
81
+ action: str
82
+ source: str
83
+ session_id: str | None
84
+ at: datetime
85
+ target_type: str | None = None
86
+ target_id: str | None = None
87
+ graph_retrieved: bool = False
88
+
89
+
90
+ def record_interaction(
91
+ conn: psycopg.Connection[Any],
92
+ *,
93
+ document_id: str | None = None,
94
+ action: InteractionAction,
95
+ source: InteractionSource,
96
+ query: str | None = None,
97
+ session_id: uuid.UUID | None = None,
98
+ target_type: InteractionTargetType | None = None,
99
+ target_id: str | None = None,
100
+ graph_retrieved: bool = False,
101
+ ) -> str:
102
+ """INSERT one row into ``interactions`` and return its UUID as text.
103
+
104
+ Validates ``action`` / ``source`` / ``target_type`` against the
105
+ Python-side enums and enforces the document-XOR-graph-target shape
106
+ before issuing the SQL, so an obvious mistake produces a clean
107
+ :class:`brain.errors.InteractionError` rather than a generic
108
+ :class:`psycopg.errors.CheckViolation`. The DB-level ``CHECK``
109
+ constraints remain the authoritative gate; this is belt-and-braces.
110
+
111
+ A row targets EITHER a document OR a graph target, never both and
112
+ never neither (migration 015 / spec §17d Q2):
113
+
114
+ - Document row: pass ``document_id``; leave ``target_type`` /
115
+ ``target_id`` unset. This is the unchanged Q1-C path used by
116
+ ``brain rate`` and MCP ``brain_show``.
117
+ - Graph-target row: pass BOTH ``target_type`` and ``target_id``;
118
+ leave ``document_id`` unset.
119
+
120
+ Args:
121
+ conn: Live Postgres connection. The caller controls the
122
+ transaction — for CLI / MCP we run with ``autocommit=True``
123
+ so each interaction is one round-trip.
124
+ document_id: Stringified UUID of the doc the event applies to.
125
+ Foreign-key enforced; deleting the doc cascades to its
126
+ interaction rows. ``None`` (the default) for graph-target rows.
127
+ action: One of :data:`_VALID_ACTIONS`. Type-narrowed by
128
+ :data:`InteractionAction` for static checkers.
129
+ source: One of :data:`_VALID_SOURCES`. Type-narrowed by
130
+ :data:`InteractionSource` for static checkers.
131
+ query: Optional originating query (the search string that led
132
+ the user to this doc). ``None`` is valid for surfaces with
133
+ no query intent (e.g., a direct CLI ``brain show``).
134
+ session_id: Optional UUID grouping a search-then-open pair.
135
+ ``None`` for the CLI rating path; populated by MCP when the
136
+ client passes back the id returned from ``brain_search``.
137
+ target_type: One of :data:`_VALID_TARGET_TYPES` ('entity' /
138
+ 'community' / 'theme') for a graph-target row; ``None`` for a
139
+ document row. Must be paired with ``target_id``.
140
+ target_id: Durable id of the graph target (entity UUID /
141
+ community_key / theme key) as text; ``None`` for a document
142
+ row. Must be paired with ``target_type``.
143
+ graph_retrieved: Provenance flag — ``True`` when a graph surface
144
+ produced this interaction. Orthogonal to the target shape: a
145
+ document row surfaced via a graph path is still a document row
146
+ with ``graph_retrieved=True``.
147
+
148
+ Returns:
149
+ The inserted row's UUID as a string.
150
+
151
+ Raises:
152
+ InteractionError: ``action`` / ``source`` / ``target_type`` is not
153
+ a recognised enum value, or the document-XOR-graph-target shape
154
+ is violated (both set, neither set, or a half-specified graph
155
+ target).
156
+ psycopg.Error: The INSERT itself failed (FK violation, DB
157
+ outage, etc.) — propagated unchanged so the CLI / MCP outer
158
+ wrappers can surface it with their framework's error type.
159
+ """
160
+ if action not in _VALID_ACTIONS:
161
+ raise InteractionError(f"unknown action: {action!r}")
162
+ if source not in _VALID_SOURCES:
163
+ raise InteractionError(f"unknown source: {source!r}")
164
+ if target_type is not None and target_type not in _VALID_TARGET_TYPES:
165
+ raise InteractionError(f"unknown target_type: {target_type!r}")
166
+
167
+ has_document = document_id is not None
168
+ has_target = target_type is not None or target_id is not None
169
+ if has_document and has_target:
170
+ raise InteractionError(
171
+ "interaction must target EITHER a document or a graph target, "
172
+ "not both (document_id is mutually exclusive with "
173
+ "target_type/target_id)"
174
+ )
175
+ if not has_document and not has_target:
176
+ raise InteractionError(
177
+ "interaction must target either a document (document_id) or a "
178
+ "graph target (target_type + target_id)"
179
+ )
180
+ if has_target and (target_type is None or target_id is None):
181
+ raise InteractionError(
182
+ "graph-target interaction requires BOTH target_type and target_id"
183
+ )
184
+
185
+ row = conn.execute(
186
+ """
187
+ INSERT INTO interactions
188
+ (document_id, query, action, source, session_id,
189
+ target_type, target_id, graph_retrieved)
190
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
191
+ RETURNING id::text
192
+ """,
193
+ (
194
+ document_id,
195
+ query,
196
+ action,
197
+ source,
198
+ str(session_id) if session_id is not None else None,
199
+ target_type,
200
+ target_id,
201
+ graph_retrieved,
202
+ ),
203
+ ).fetchone()
204
+ assert row is not None # RETURNING always yields one row
205
+ return str(row[0])