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/demo/__init__.py ADDED
@@ -0,0 +1,452 @@
1
+ """`brain demo` — zero-Ollama taste test over a synthetic Larkspur corpus.
2
+
3
+ Orchestration core for the demo experience: load the packaged synthetic
4
+ corpus, provision an isolated throwaway Postgres (stock ``pgvector`` image, a
5
+ Docker named volume, NEVER the prod container), seed it deterministically with
6
+ :class:`~brain.demo.embedder.DemoEmbedder` (no Ollama), run FTS-only hybrid
7
+ search, and tear the whole thing down.
8
+
9
+ The isolation contract is binding (see the module constants): a dedicated
10
+ compose project / container / database / named volume, and a hard guard
11
+ (:func:`_assert_not_demo_prod_db`) that refuses to operate against the prod
12
+ database on port 55432 / db ``second_brain``. The Typer surface lives in
13
+ :mod:`brain.cli_demo`; this module owns the provision / seed / query / status /
14
+ teardown primitives it calls.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import importlib.resources
19
+ import json
20
+ import socket
21
+ import subprocess
22
+ import time
23
+ from dataclasses import dataclass
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+ from typing import Any
27
+ from urllib.parse import urlparse
28
+
29
+ from brain.db import connect, ensure_embedding_column, run_migrations
30
+ from brain.errors import BrainError, DemoError
31
+ from brain.ingest import ExtractedDoc, ingest_document
32
+ from brain.queries import finalize_embedding_index
33
+ from brain.search import SearchResult, hybrid_search
34
+
35
+ from .embedder import DemoEmbedder
36
+
37
+ # --- Isolation contract (binding) ------------------------------------------
38
+ # A dedicated compose project + container + database + Docker NAMED VOLUME, on
39
+ # the stock pgvector image (NOT the custom AGE prod image). Nothing here may
40
+ # ever resolve to the prod container (port 55432, db ``second_brain``,
41
+ # ./data/postgres bind-mount) — the demo is a throwaway sandbox.
42
+ COMPOSE_PROJECT = "brain-demo"
43
+ CONTAINER_NAME = "second-brain-demo-postgres"
44
+ DEMO_DB_NAME = "second_brain_demo"
45
+ DEMO_IMAGE = "pgvector/pgvector:pg16"
46
+ DEMO_VOLUME = "brain-demo-pgdata"
47
+ DEMO_DB_USER = "brain"
48
+ DEMO_DB_PASSWORD = "brain" # noqa: S105 — throwaway local sandbox credential
49
+ DEFAULT_DEMO_PORT = 55433
50
+
51
+ # Where the generated compose file is materialized (a throwaway dir under the
52
+ # user's home — never the repo, never $BRAIN_HOME prod state).
53
+ DEMO_HOME = Path.home() / ".brain-demo"
54
+ COMPOSE_FILENAME = "docker-compose.yml"
55
+
56
+ # Prod-safety guard values — mirror ``tests/conftest.py``'s ``_assert_not_prod_db``.
57
+ _PROD_PORTS = frozenset({5433, 55432})
58
+ _PROD_DB_NAME = "second_brain"
59
+ # ``0.0.0.0`` binds all interfaces on the local host — treat it as local so a
60
+ # ``0.0.0.0:55432`` URL can't slip past the prod guard.
61
+ _LOCAL_HOSTS = frozenset({"", "localhost", "127.0.0.1", "::1", "0.0.0.0"})
62
+
63
+ # How long to wait for the freshly-provisioned container to accept connections.
64
+ _PROVISION_READY_TIMEOUT_S = 30.0
65
+ _PROVISION_POLL_INTERVAL_S = 0.5
66
+ _SUBPROCESS_TIMEOUT_S = 120
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class SeedReport:
71
+ """Outcome of :func:`seed_demo`.
72
+
73
+ ``ingested`` counts newly-created documents, ``skipped`` counts content-hash
74
+ /source dedup no-ops (a re-seed reports every doc skipped). ``total`` is the
75
+ corpus size — ``ingested + skipped`` on a clean seed.
76
+ """
77
+
78
+ ingested: int
79
+ skipped: int
80
+ total: int
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class DemoStatus:
85
+ """Snapshot of the demo sandbox for ``brain demo status``."""
86
+
87
+ running: bool
88
+ container: str
89
+ database_url: str | None
90
+ doc_count: int | None
91
+
92
+
93
+ def load_corpus() -> list[dict[str, Any]]:
94
+ """Load the packaged synthetic corpus manifest (22 docs).
95
+
96
+ Resolved via :func:`importlib.resources.files` so it works in both editable
97
+ checkouts and pipx/wheel installs. Returns the raw record list exactly as
98
+ authored in ``corpus/manifest.json``.
99
+ """
100
+ resource = importlib.resources.files("brain.demo") / "corpus" / "manifest.json"
101
+ text = resource.read_text(encoding="utf-8")
102
+ data = json.loads(text)
103
+ if not isinstance(data, list):
104
+ raise BrainError("demo corpus manifest must be a JSON list of records")
105
+ return data
106
+
107
+
108
+ def demo_database_url(port: int) -> str:
109
+ """Return the Postgres URL for the demo container at ``port``."""
110
+ return (
111
+ f"postgresql://{DEMO_DB_USER}:{DEMO_DB_PASSWORD}"
112
+ f"@localhost:{port}/{DEMO_DB_NAME}"
113
+ )
114
+
115
+
116
+ def _assert_not_demo_prod_db(database_url: str) -> None:
117
+ """Refuse to operate against the prod database.
118
+
119
+ Copied in spirit from ``tests/conftest.py``'s ``_assert_not_prod_db``:
120
+ aborts when the resolved (host, port, dbname) looks like the prod container
121
+ — any known prod port (55432 current, 5433 historical) on a local host, OR
122
+ the exact prod database name on any host. Every demo primitive that opens a
123
+ connection calls this first so a mis-pointed ``--database-url`` can never
124
+ seed / query / drop prod data.
125
+ """
126
+ parsed = urlparse(database_url)
127
+ host = (parsed.hostname or "").lower()
128
+ port = parsed.port
129
+ dbname = (parsed.path or "").lstrip("/")
130
+ is_local = host in _LOCAL_HOSTS
131
+ if (is_local and port in _PROD_PORTS) or (dbname == _PROD_DB_NAME):
132
+ raise BrainError(
133
+ "REFUSING to run `brain demo` against what looks like the PROD "
134
+ f"database (host={host!r} port={port!r} db={dbname!r}). The demo "
135
+ "must only ever touch its own throwaway container "
136
+ f"(db {DEMO_DB_NAME!r}, named volume {DEMO_VOLUME!r}). Fix "
137
+ "--database-url so it does not point at prod."
138
+ )
139
+
140
+
141
+ def _record_to_doc(record: dict[str, Any]) -> ExtractedDoc:
142
+ """Project a manifest record onto an :class:`ExtractedDoc` for ingest.
143
+
144
+ Promotable metadata (``date`` → ``sent_at``, ``participants``,
145
+ ``duration_min``, ``thread_id``) rides in ``metadata`` and is picked up by
146
+ the ingest pipeline's column-promotion. ``None`` / empty values are omitted
147
+ so promotion skips them cleanly. Demo docs are never file-backed, so
148
+ ``source_path`` stays ``None`` (dedup is by ``(source, external_id)``).
149
+ """
150
+ metadata: dict[str, Any] = {"date": record["date"]}
151
+ participants = record.get("participants") or []
152
+ if participants:
153
+ metadata["participants"] = list(participants)
154
+ duration = record.get("duration_min")
155
+ if duration is not None:
156
+ metadata["duration_min"] = duration
157
+ thread_id = record.get("thread_id")
158
+ if thread_id:
159
+ metadata["thread_id"] = thread_id
160
+ return ExtractedDoc(
161
+ title=record["title"],
162
+ content=record["body"],
163
+ content_type=record["content_type"],
164
+ source_path=None,
165
+ metadata=metadata,
166
+ )
167
+
168
+
169
+ def seed_demo(database_url: str, *, with_embeddings: bool = False) -> SeedReport:
170
+ """Ingest the full synthetic corpus into ``database_url``.
171
+
172
+ Reconciles ``chunks.embedding`` to :class:`DemoEmbedder`'s dim first (so the
173
+ demo runs regardless of the schema's shipped default dim), then ingests all
174
+ 22 docs with the deterministic embedder — no Ollama, no enrichment, no graph
175
+ sync, no vault mirror. Idempotent: a re-seed dedups on ``(source,
176
+ external_id)`` and reports every doc skipped.
177
+
178
+ ``with_embeddings=True`` additionally finalizes the embedding column (NOT
179
+ NULL + HNSW cosine index) so the vector leg of hybrid search is exercised;
180
+ the default leaves the column unindexed since the demo query path is
181
+ FTS-only.
182
+ """
183
+ _assert_not_demo_prod_db(database_url)
184
+ embedder = DemoEmbedder()
185
+ corpus = load_corpus()
186
+ ingested = 0
187
+ skipped = 0
188
+ with connect(database_url) as conn:
189
+ conn.autocommit = True
190
+ ensure_embedding_column(conn, embedder)
191
+ for record in corpus:
192
+ result = ingest_document(
193
+ conn,
194
+ embedder=embedder,
195
+ doc=_record_to_doc(record),
196
+ source_kind=record["source"],
197
+ source_external_id=record["external_id"],
198
+ tags=list(record.get("tags") or []),
199
+ enrich=False,
200
+ enricher=None,
201
+ vault_root=None,
202
+ graph_syncer=None,
203
+ )
204
+ if result.created:
205
+ ingested += 1
206
+ else:
207
+ skipped += 1
208
+ if with_embeddings:
209
+ finalize_embedding_index(conn, embedder)
210
+ return SeedReport(ingested=ingested, skipped=skipped, total=len(corpus))
211
+
212
+
213
+ def query_demo(
214
+ database_url: str,
215
+ query: str,
216
+ *,
217
+ limit: int = 5,
218
+ source: str | None = None,
219
+ tag: str | None = None,
220
+ person: str | None = None,
221
+ after: datetime | None = None,
222
+ with_embeddings: bool = False,
223
+ ) -> list[SearchResult]:
224
+ """Run hybrid search over the seeded demo corpus.
225
+
226
+ FTS-only by default (``with_embeddings=False`` → ``fts_only=True``): no
227
+ query embedding, fully deterministic, zero Ollama. ``person`` is resolved
228
+ directly to a lowercased participant key (the demo has no directory layer),
229
+ matching the case-insensitive participant overlap the search SQL applies.
230
+ """
231
+ _assert_not_demo_prod_db(database_url)
232
+ embedder = DemoEmbedder()
233
+ person_keys = [person.strip().lower()] if person else None
234
+ with connect(database_url) as conn:
235
+ conn.autocommit = True
236
+ return hybrid_search(
237
+ conn,
238
+ embedder=embedder,
239
+ query=query,
240
+ limit=limit,
241
+ source_kind=source,
242
+ tag=tag,
243
+ fts_only=not with_embeddings,
244
+ person_keys=person_keys,
245
+ person_display_name=person,
246
+ after=after,
247
+ )
248
+
249
+
250
+ # --- Docker / provisioning primitives --------------------------------------
251
+ # All Docker access funnels through :func:`_run` so tests can mock the single
252
+ # subprocess boundary (CLAUDE.md rule 13: subprocess-boundary mocks are fine).
253
+
254
+
255
+ def _run(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
256
+ """Run a subprocess, capturing output. The single mocked Docker boundary."""
257
+ return subprocess.run(
258
+ args,
259
+ check=check,
260
+ capture_output=True,
261
+ text=True,
262
+ timeout=_SUBPROCESS_TIMEOUT_S,
263
+ )
264
+
265
+
266
+ def _run_docker(args: list[str]) -> subprocess.CompletedProcess[str]:
267
+ """Run a docker command (check=True), translating failures into ``DemoError``.
268
+
269
+ Mirrors the boundary translation in :class:`brain.audio.TtsCommandRunner`: a
270
+ missing binary, a dead daemon, or a timeout surfaces as a single actionable
271
+ :class:`DemoError` carrying Docker's own stderr — never a raw traceback that
272
+ escapes the CLI's ``except BrainError`` catch.
273
+ """
274
+ try:
275
+ return _run(args)
276
+ except FileNotFoundError as exc:
277
+ raise DemoError(
278
+ "Docker CLI not found while starting the demo container — install "
279
+ "Docker, or pass --database-url <postgres-url> to seed an existing "
280
+ "empty database."
281
+ ) from exc
282
+ except subprocess.CalledProcessError as exc:
283
+ detail = (exc.stderr or "").strip() or "<no stderr captured>"
284
+ raise DemoError(
285
+ f"Docker failed to start the demo container (exit {exc.returncode}). "
286
+ f"Is the Docker daemon running?\n{detail}"
287
+ ) from exc
288
+ except subprocess.TimeoutExpired as exc:
289
+ raise DemoError(
290
+ f"Docker timed out after {exc.timeout:.0f}s while starting the demo "
291
+ "container. Check that the Docker daemon is responsive."
292
+ ) from exc
293
+
294
+
295
+ def _compose_file_text(port: int) -> str:
296
+ """Render the minimal demo compose file (stock image, named volume)."""
297
+ return (
298
+ "services:\n"
299
+ " postgres:\n"
300
+ f" image: {DEMO_IMAGE}\n"
301
+ f" container_name: {CONTAINER_NAME}\n"
302
+ " environment:\n"
303
+ f" POSTGRES_USER: {DEMO_DB_USER}\n"
304
+ f" POSTGRES_PASSWORD: {DEMO_DB_PASSWORD}\n"
305
+ f" POSTGRES_DB: {DEMO_DB_NAME}\n"
306
+ " ports:\n"
307
+ f' - "{port}:5432"\n'
308
+ " volumes:\n"
309
+ f" - {DEMO_VOLUME}:/var/lib/postgresql/data\n"
310
+ "volumes:\n"
311
+ f" {DEMO_VOLUME}:\n"
312
+ )
313
+
314
+
315
+ def _write_compose_file(port: int) -> Path:
316
+ """Materialize the compose file under :data:`DEMO_HOME`; return its path."""
317
+ DEMO_HOME.mkdir(parents=True, exist_ok=True)
318
+ path = DEMO_HOME / COMPOSE_FILENAME
319
+ path.write_text(_compose_file_text(port), encoding="utf-8")
320
+ return path
321
+
322
+
323
+ def _port_is_free(port: int) -> bool:
324
+ """True iff ``port`` can be bound on localhost (nothing else listening)."""
325
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
326
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
327
+ try:
328
+ sock.bind(("127.0.0.1", port))
329
+ except OSError:
330
+ return False
331
+ return True
332
+
333
+
334
+ def resolve_port(start: int, *, attempts: int = 20) -> int:
335
+ """Return ``start`` or the next free port above it (auto-bump on collision)."""
336
+ for candidate in range(start, start + attempts):
337
+ if _port_is_free(candidate):
338
+ return candidate
339
+ raise BrainError(
340
+ f"no free port found in [{start}, {start + attempts}) for the demo container"
341
+ )
342
+
343
+
344
+ def _wait_until_ready(database_url: str) -> None:
345
+ """Poll the provisioned container until it accepts connections (≤30s)."""
346
+ deadline = time.monotonic() + _PROVISION_READY_TIMEOUT_S
347
+ last_error: Exception | None = None
348
+ while time.monotonic() < deadline:
349
+ try:
350
+ with connect(database_url) as conn:
351
+ conn.execute("SELECT 1")
352
+ return
353
+ except Exception as exc: # noqa: BLE001 — retry any connection failure
354
+ last_error = exc
355
+ time.sleep(_PROVISION_POLL_INTERVAL_S)
356
+ raise BrainError(
357
+ f"demo Postgres did not become ready within "
358
+ f"{_PROVISION_READY_TIMEOUT_S:.0f}s: {last_error}"
359
+ )
360
+
361
+
362
+ def provision(port: int) -> str:
363
+ """Bring up the isolated demo Postgres and migrate it; return its URL.
364
+
365
+ Writes the minimal compose file, ``docker compose -p brain-demo up -d`` on
366
+ the stock pgvector image + named volume, waits for readiness, then applies
367
+ the relational migrations and reconciles ``chunks.embedding`` to the demo
368
+ embedder's dim. Graph/AGE is never bootstrapped — the stock image has no
369
+ AGE, and the demo query path never needs it.
370
+ """
371
+ database_url = demo_database_url(port)
372
+ _assert_not_demo_prod_db(database_url)
373
+ _write_compose_file(port)
374
+ _run_docker(
375
+ [
376
+ "docker", "compose",
377
+ "-p", COMPOSE_PROJECT,
378
+ "-f", str(DEMO_HOME / COMPOSE_FILENAME),
379
+ "up", "-d",
380
+ ]
381
+ )
382
+ _wait_until_ready(database_url)
383
+ with connect(database_url) as conn:
384
+ conn.autocommit = True
385
+ run_migrations(conn)
386
+ ensure_embedding_column(conn, DemoEmbedder())
387
+ return database_url
388
+
389
+
390
+ def _container_running() -> bool:
391
+ """True iff the demo container is up (``docker ps`` names it)."""
392
+ try:
393
+ result = _run(
394
+ [
395
+ "docker", "ps",
396
+ "--filter", f"name={CONTAINER_NAME}",
397
+ "--format", "{{.Names}}",
398
+ ],
399
+ check=False,
400
+ )
401
+ except (OSError, subprocess.SubprocessError):
402
+ return False
403
+ return CONTAINER_NAME in result.stdout.split()
404
+
405
+
406
+ def _count_documents(database_url: str) -> int | None:
407
+ """Return the demo corpus doc count, or ``None`` if the DB is unreachable."""
408
+ try:
409
+ with connect(database_url) as conn:
410
+ row = conn.execute("SELECT count(*) FROM documents").fetchone()
411
+ return int(row[0]) if row is not None else None
412
+ except Exception: # noqa: BLE001 — status is best-effort; unreachable → None
413
+ return None
414
+
415
+
416
+ def status(port: int = DEFAULT_DEMO_PORT) -> DemoStatus:
417
+ """Report whether the demo sandbox is running + its doc count."""
418
+ running = _container_running()
419
+ url = demo_database_url(port) if running else None
420
+ doc_count = _count_documents(url) if url is not None else None
421
+ return DemoStatus(
422
+ running=running,
423
+ container=CONTAINER_NAME,
424
+ database_url=url,
425
+ doc_count=doc_count,
426
+ )
427
+
428
+
429
+ def teardown() -> None:
430
+ """Destroy the demo sandbox: ``docker compose -p brain-demo down -v``.
431
+
432
+ ``down -v`` removes the named volume too, so nothing of the demo survives.
433
+ Only ever targets the ``brain-demo`` compose project — never prod (a
434
+ separate project on a bind-mount that ``down -v`` cannot reach anyway).
435
+
436
+ ``check=False`` already tolerates a non-zero ``down`` (nothing to remove),
437
+ but a missing Docker binary (``FileNotFoundError``) or a hung daemon
438
+ (``TimeoutExpired``) would still escape — translate those into a clean
439
+ :class:`DemoError` so the CLI reports gracefully instead of tracebacking.
440
+ """
441
+ compose_file = DEMO_HOME / COMPOSE_FILENAME
442
+ args = ["docker", "compose", "-p", COMPOSE_PROJECT]
443
+ if compose_file.is_file():
444
+ args += ["-f", str(compose_file)]
445
+ args += ["down", "-v"]
446
+ try:
447
+ _run(args, check=False)
448
+ except (OSError, subprocess.SubprocessError) as exc:
449
+ raise DemoError(
450
+ f"Docker was unavailable while tearing down the demo sandbox: {exc}. "
451
+ "If Docker isn't installed there is nothing to tear down."
452
+ ) from exc