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/audio.py ADDED
@@ -0,0 +1,591 @@
1
+ """Plan 04 — `brain audio` two-host audio-overview script generation.
2
+
3
+ Turns a GraphRAG theme/community bundle into a NotebookLM-style two-host
4
+ podcast script (``PodcastScript``), plus a pluggable ``TtsBackend`` Protocol for
5
+ optional synthesis. This module owns ONE reason to change: bundle → prompt →
6
+ script. Graph retrieval, artifact writing, and CLI orchestration live in
7
+ ``cli.py``; the LLM round-trip is delegated to the shared
8
+ :func:`brain.chat.chat_json` helper (consumed AS-IS — no signature drift).
9
+
10
+ Privacy contract: the bundle carries ONLY entity names, theme summaries, and
11
+ per-document ``(title, summary)`` pairs (``documents.summary`` is the ≤60-word
12
+ enrichment projection). Full document bodies never reach the prompt — the bundle
13
+ is assembled from :func:`brain.queries.fetch_document_summary`, which selects
14
+ ``title``/``summary`` only.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import shlex
20
+ import subprocess
21
+ from collections.abc import Callable, Sequence
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, Any, Protocol
25
+
26
+ from .chat import chat_json
27
+ from .errors import AudioError, EnrichmentError, OllamaUnavailable, TtsError
28
+
29
+ if TYPE_CHECKING:
30
+ import psycopg
31
+
32
+ from .config import Config
33
+ from .graph_rag.schema import GraphContext, GraphEntity
34
+
35
+
36
+ class _GroupLike(Protocol):
37
+ """Structural shape shared by ``ThemeGroup`` and ``CommunityGroup``.
38
+
39
+ Both carry the fields the bundle assembly reads (``score``/``summary``/
40
+ ``entities``/``doc_ids``); this Protocol lets one code path handle either
41
+ without a runtime ``isinstance`` branch (Liskov substitution). Declared as
42
+ read-only properties so a ``Sequence[_GroupLike]`` stays covariant over the
43
+ two concrete frozen dataclasses.
44
+ """
45
+
46
+ @property
47
+ def score(self) -> float: ...
48
+ @property
49
+ def summary(self) -> str | None: ...
50
+ @property
51
+ def entities(self) -> list[GraphEntity]: ...
52
+ @property
53
+ def doc_ids(self) -> list[str]: ...
54
+
55
+ _logger = logging.getLogger(__name__)
56
+
57
+ # Speaker labels for the two-host format. The generator validates every turn's
58
+ # speaker resolves to exactly one of these (spec §5 test_script_generator_speaker_names).
59
+ HOST = "Host"
60
+ GUEST = "Guest"
61
+ _VALID_SPEAKERS = (HOST, GUEST)
62
+
63
+ # Source-kind discriminators carried on the bundle + the PodcastScript.
64
+ KIND_THEMES = "themes"
65
+ KIND_GLOBAL = "global"
66
+
67
+ # Top-N representative documents pulled per group (spec §3 step 3c "top-2").
68
+ _DOCS_PER_GROUP = 2
69
+
70
+ # Default completion-length budget for the dialogue call (spec §3 step 4c).
71
+ _DEFAULT_NUM_PREDICT = 2048
72
+
73
+ # The schema handed to ``chat_json`` — its KEYS are the required top-level keys
74
+ # of the returned JSON object. The generator does the deeper per-turn structural
75
+ # validation itself (chat_json only checks key presence).
76
+ _TURNS_SCHEMA: dict[str, Any] = {"turns": "list of {speaker, text} dialogue turns"}
77
+
78
+ # A callable matching the shape the generator needs from ``chat_json``:
79
+ # ``(prompt, schema, num_predict) -> parsed JSON object``. Dependency-inverted so
80
+ # tests inject a fake without a live Ollama.
81
+ ChatFn = Callable[[str, dict[str, Any], int], dict[str, Any]]
82
+
83
+ # A token counter ``(text) -> int``. Injected so prompt budgeting is testable
84
+ # without tiktoken; production uses the cl100k_base counter below.
85
+ TokenCounter = Callable[[str], int]
86
+
87
+
88
+ # --------------------------------------------------------------------------- #
89
+ # Value objects
90
+ # --------------------------------------------------------------------------- #
91
+ @dataclass(frozen=True)
92
+ class ScriptTurn:
93
+ """One speaker turn in the dialogue. ``speaker`` is ``Host`` or ``Guest``."""
94
+
95
+ speaker: str
96
+ text: str
97
+
98
+ def to_dict(self) -> dict[str, str]:
99
+ """Serialize to the wire shape ``{"speaker", "text"}``."""
100
+ return {"speaker": self.speaker, "text": self.text}
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class PodcastScript:
105
+ """A generated two-host audio-overview script (spec §3 script schema).
106
+
107
+ ``generated_at`` is an ISO-8601 UTC string stamped by the CLI caller (this
108
+ module never calls ``datetime.now()`` — workflow-resume safe). ``model`` is
109
+ the Ollama model that produced the dialogue.
110
+ """
111
+
112
+ title: str
113
+ source_kind: str
114
+ source_person: str | None
115
+ source_topic: str | None
116
+ theme_count: int
117
+ generated_at: str
118
+ model: str
119
+ turns: list[ScriptTurn]
120
+
121
+ def to_dict(self) -> dict[str, Any]:
122
+ """Serialize to the public JSON artifact shape (spec §3)."""
123
+ return {
124
+ "title": self.title,
125
+ "source": {
126
+ "kind": self.source_kind,
127
+ "person": self.source_person,
128
+ "topic": self.source_topic,
129
+ "theme_count": self.theme_count,
130
+ },
131
+ "generated_at": self.generated_at,
132
+ "model": self.model,
133
+ "turns": [turn.to_dict() for turn in self.turns],
134
+ }
135
+
136
+ def to_markdown(self) -> str:
137
+ """Render the human-editable Markdown transcript artifact (spec §3)."""
138
+ lines = [f"## {self.title}", ""]
139
+ for turn in self.turns:
140
+ lines.append(f"> **{turn.speaker}:** {turn.text}")
141
+ lines.append("")
142
+ return "\n".join(lines).rstrip() + "\n"
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class BundleGroup:
147
+ """One theme/community in the bundle: entity labels + summaries (no bodies)."""
148
+
149
+ entity_names: list[str] = field(default_factory=list)
150
+ summary: str | None = None
151
+ # Per-document (title, summary) pairs — never full body content.
152
+ docs: list[tuple[str, str | None]] = field(default_factory=list)
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class SourceBundle:
157
+ """The internal value object fed to the script prompt (not serialized).
158
+
159
+ Assembled from a :class:`~brain.graph_rag.schema.GraphContext` by
160
+ :func:`bundle_from_graph_context`. Carries entity-name lists + per-doc
161
+ ``(title, summary)`` pairs only — the privacy boundary.
162
+ """
163
+
164
+ kind: str
165
+ person: str | None
166
+ topic: str | None
167
+ groups: list[BundleGroup] = field(default_factory=list)
168
+
169
+ @property
170
+ def theme_count(self) -> int:
171
+ """Number of groups in the bundle (the script's ``theme_count``)."""
172
+ return len(self.groups)
173
+
174
+
175
+ # A summary fetcher ``(conn, document_id) -> (title, summary) | None``. Injected
176
+ # so the bundle assembly is testable without the default queries import.
177
+ SummaryFetcher = Callable[
178
+ ["psycopg.Connection[Any]", str], "tuple[str, str | None] | None"
179
+ ]
180
+
181
+
182
+ # --------------------------------------------------------------------------- #
183
+ # Bundle assembly
184
+ # --------------------------------------------------------------------------- #
185
+ def _select_top_groups(
186
+ groups: Sequence[_GroupLike], theme_limit: int
187
+ ) -> list[_GroupLike]:
188
+ """Return the top ``theme_limit`` groups by ``score`` DESC (stable tiebreak).
189
+
190
+ The original index is the deterministic tiebreak so equal-score groups keep
191
+ their retrieval order across runs. Typed against :class:`_GroupLike` so one
192
+ body ranks ``ThemeGroup``s and ``CommunityGroup``s alike.
193
+ """
194
+ scored = [(group.score, index, group) for index, group in enumerate(groups)]
195
+ scored.sort(key=lambda item: (-item[0], item[1]))
196
+ return [group for _, _, group in scored[:theme_limit]]
197
+
198
+
199
+ def bundle_from_graph_context(
200
+ conn: psycopg.Connection[Any],
201
+ ctx: GraphContext,
202
+ *,
203
+ theme_limit: int,
204
+ fetch_summary: SummaryFetcher | None = None,
205
+ ) -> SourceBundle:
206
+ """Assemble a :class:`SourceBundle` from a graph retrieval context.
207
+
208
+ Takes the top ``theme_limit`` groups (``ctx.themes`` for themes mode, else
209
+ ``ctx.communities`` for global mode) ranked by ``score`` descending — a
210
+ stable original-index tiebreak keeps repeated runs byte-identical. For each
211
+ group it collects entity names + the group ``summary`` + the top
212
+ :data:`_DOCS_PER_GROUP` documents' ``(title, summary)`` pairs via
213
+ ``fetch_summary`` (default :func:`brain.queries.fetch_document_summary`,
214
+ which selects ``title``/``summary`` only — never ``content``).
215
+
216
+ Groups without a summary fall back to entity names + doc titles, so the
217
+ prompt is never empty even on an un-enriched corpus.
218
+ """
219
+ if fetch_summary is None:
220
+ from .queries import fetch_document_summary
221
+
222
+ fetch_summary = fetch_document_summary
223
+
224
+ if ctx.themes:
225
+ kind = KIND_THEMES
226
+ selected = _select_top_groups(ctx.themes, theme_limit)
227
+ else:
228
+ kind = KIND_GLOBAL
229
+ selected = _select_top_groups(ctx.communities, theme_limit)
230
+
231
+ groups: list[BundleGroup] = []
232
+ for group in selected:
233
+ entity_names = [
234
+ entity.name for entity in group.entities if entity.name.strip()
235
+ ]
236
+ docs: list[tuple[str, str | None]] = []
237
+ for doc_id in group.doc_ids[:_DOCS_PER_GROUP]:
238
+ fetched = fetch_summary(conn, doc_id)
239
+ if fetched is not None:
240
+ docs.append(fetched)
241
+ groups.append(
242
+ BundleGroup(entity_names=entity_names, summary=group.summary, docs=docs)
243
+ )
244
+
245
+ return SourceBundle(
246
+ kind=kind,
247
+ person=ctx.person,
248
+ topic=ctx.query if kind == KIND_GLOBAL else None,
249
+ groups=groups,
250
+ )
251
+
252
+
253
+ # --------------------------------------------------------------------------- #
254
+ # Title + prompt construction (pure)
255
+ # --------------------------------------------------------------------------- #
256
+ def make_title(bundle: SourceBundle) -> str:
257
+ """Build a deterministic episode title from the bundle's source."""
258
+ if bundle.kind == KIND_THEMES and bundle.person:
259
+ return f"Themes From My Conversations With {bundle.person}"
260
+ if bundle.kind == KIND_GLOBAL and bundle.topic:
261
+ return f"A Conversation About {bundle.topic}"
262
+ return "An Audio Overview From My Second Brain"
263
+
264
+
265
+ _SYSTEM_FRAMING = (
266
+ "You are the script writer for a two-host audio overview, in the style of a "
267
+ "short explanatory podcast. Two hosts — Host and Guest — discuss the source "
268
+ "material below in a natural, curious, back-and-forth conversation. Host "
269
+ "leads and asks questions; Guest adds insight and detail. Ground EVERY "
270
+ "statement strictly in the provided material — never invent facts, names, "
271
+ "numbers, or quotes. Keep each turn to two or three sentences."
272
+ )
273
+
274
+
275
+ def _format_group(index: int, group: BundleGroup) -> str:
276
+ """Render one bundle group as a prompt section (entity names + summaries)."""
277
+ lines = [f"Theme {index}:"]
278
+ if group.entity_names:
279
+ lines.append(f"- Key topics: {', '.join(group.entity_names)}")
280
+ if group.summary:
281
+ lines.append(f"- Summary: {group.summary}")
282
+ for title, summary in group.docs:
283
+ if summary:
284
+ lines.append(f"- Source “{title}”: {summary}")
285
+ else:
286
+ lines.append(f"- Source “{title}”")
287
+ return "\n".join(lines)
288
+
289
+
290
+ def build_prompt(
291
+ bundle: SourceBundle,
292
+ *,
293
+ max_turns: int,
294
+ max_input_tokens: int,
295
+ count_tokens: TokenCounter,
296
+ ) -> str:
297
+ """Build the single user prompt for the dialogue call (privacy-bounded).
298
+
299
+ Greedily includes bundle groups while the running prompt stays within
300
+ ``max_input_tokens`` (measured by the injected ``count_tokens``); the first
301
+ group is always included even if it alone exceeds the budget, so a prompt is
302
+ never empty. The model is instructed to emit JSON ``{"turns": [...]}`` with
303
+ at most ``max_turns`` alternating Host/Guest turns.
304
+ """
305
+ header = _SYSTEM_FRAMING
306
+ instruction = (
307
+ f"Write a dialogue of AT MOST {max_turns} turns, strictly alternating "
308
+ f'"{HOST}" and "{GUEST}", starting with {HOST}. Respond with ONLY a JSON '
309
+ 'object of the form {"turns": [{"speaker": "Host", "text": "..."}, '
310
+ '{"speaker": "Guest", "text": "..."}]}. Use no speaker names other than '
311
+ f'"{HOST}" and "{GUEST}".'
312
+ )
313
+
314
+ included: list[str] = []
315
+ for index, group in enumerate(bundle.groups, start=1):
316
+ section = _format_group(index, group)
317
+ candidate = "\n\n".join([header, *included, section, instruction])
318
+ if included and count_tokens(candidate) > max_input_tokens:
319
+ _logger.debug(
320
+ "audio prompt: stopping at %d/%d groups (token budget %d)",
321
+ len(included),
322
+ len(bundle.groups),
323
+ max_input_tokens,
324
+ )
325
+ break
326
+ included.append(section)
327
+
328
+ return "\n\n".join([header, *included, instruction])
329
+
330
+
331
+ # --------------------------------------------------------------------------- #
332
+ # Turn parsing / validation (pure)
333
+ # --------------------------------------------------------------------------- #
334
+ def _normalize_speaker(raw: object) -> str | None:
335
+ """Map a raw speaker label to ``Host`` / ``Guest`` (or ``None`` if neither)."""
336
+ if not isinstance(raw, str):
337
+ return None
338
+ token = raw.strip().lower()
339
+ if token.startswith("host"):
340
+ return HOST
341
+ if token.startswith("guest"):
342
+ return GUEST
343
+ return None
344
+
345
+
346
+ def _parse_turns(response: dict[str, Any]) -> list[ScriptTurn]:
347
+ """Validate the model response into a list of :class:`ScriptTurn`.
348
+
349
+ Raises :class:`AudioError` (which the generator catches to trigger its one
350
+ retry) when ``turns`` is not a list of ``{"speaker", "text"}`` objects with a
351
+ resolvable speaker and non-empty text.
352
+ """
353
+ raw_turns = response.get("turns")
354
+ if not isinstance(raw_turns, list) or not raw_turns:
355
+ raise AudioError("script response 'turns' is not a non-empty list")
356
+ turns: list[ScriptTurn] = []
357
+ for item in raw_turns:
358
+ if not isinstance(item, dict):
359
+ raise AudioError(f"script turn is not an object: {item!r}")
360
+ speaker = _normalize_speaker(item.get("speaker"))
361
+ text = item.get("text")
362
+ if speaker is None:
363
+ raise AudioError(f"script turn has an invalid speaker: {item!r}")
364
+ if not isinstance(text, str) or not text.strip():
365
+ raise AudioError(f"script turn has empty/invalid text: {item!r}")
366
+ turns.append(ScriptTurn(speaker=speaker, text=text.strip()))
367
+ return turns
368
+
369
+
370
+ # --------------------------------------------------------------------------- #
371
+ # Script generator
372
+ # --------------------------------------------------------------------------- #
373
+ class ScriptGenerator:
374
+ """Generates a :class:`PodcastScript` from a :class:`SourceBundle`.
375
+
376
+ The LLM round-trip is injected as ``chat_fn`` (dependency inversion) so tests
377
+ drive it with a fake; production wires :func:`brain.chat.chat_json` bound to
378
+ ``cfg.audio_script_model`` via :func:`make_script_generator`. The generator
379
+ owns the prompt build, the structural validation + one retry, and the
380
+ surplus-turn truncation.
381
+ """
382
+
383
+ def __init__(
384
+ self,
385
+ *,
386
+ chat_fn: ChatFn,
387
+ model: str,
388
+ max_turns: int,
389
+ max_input_tokens: int,
390
+ count_tokens: TokenCounter,
391
+ num_predict: int = _DEFAULT_NUM_PREDICT,
392
+ ) -> None:
393
+ self._chat_fn = chat_fn
394
+ self._model = model
395
+ self._max_turns = max_turns
396
+ self._max_input_tokens = max_input_tokens
397
+ self._count_tokens = count_tokens
398
+ self._num_predict = num_predict
399
+
400
+ def generate(
401
+ self, bundle: SourceBundle, *, title: str, generated_at: str
402
+ ) -> PodcastScript:
403
+ """Produce a script for ``bundle`` (title + ``generated_at`` injected)."""
404
+ prompt = build_prompt(
405
+ bundle,
406
+ max_turns=self._max_turns,
407
+ max_input_tokens=self._max_input_tokens,
408
+ count_tokens=self._count_tokens,
409
+ )
410
+ turns = self._generate_turns(prompt)
411
+ if len(turns) > self._max_turns:
412
+ _logger.debug(
413
+ "audio: truncating %d turns to max_turns=%d",
414
+ len(turns),
415
+ self._max_turns,
416
+ )
417
+ turns = turns[: self._max_turns]
418
+ return PodcastScript(
419
+ title=title,
420
+ source_kind=bundle.kind,
421
+ source_person=bundle.person,
422
+ source_topic=bundle.topic,
423
+ theme_count=bundle.theme_count,
424
+ generated_at=generated_at,
425
+ model=self._model,
426
+ turns=turns,
427
+ )
428
+
429
+ def _generate_turns(self, prompt: str) -> list[ScriptTurn]:
430
+ """Call ``chat_fn`` and validate; retry once on a structural failure.
431
+
432
+ ``chat_fn`` (production: :func:`brain.chat.chat_json`) raises
433
+ :class:`OllamaUnavailable` on a transport failure — propagated so the CLI
434
+ maps it to its distinct "Ollama unavailable" path — and
435
+ :class:`EnrichmentError` when it exhausts its OWN retries on
436
+ malformed-JSON / schema-violating responses; that is unrecoverable for
437
+ audio, so it is converted to :class:`AudioError` (the Plan 04 clean
438
+ error contract — the CLI catches only ``OllamaUnavailable`` /
439
+ ``AudioError``). A structurally-invalid-but-parseable response
440
+ (:class:`AudioError` from :func:`_parse_turns`) is retried once.
441
+ """
442
+ last_error: AudioError | None = None
443
+ for _ in (1, 2):
444
+ try:
445
+ response = self._chat_fn(prompt, _TURNS_SCHEMA, self._num_predict)
446
+ except OllamaUnavailable:
447
+ # Transport failure — let the CLI map it distinctly. (Subclass of
448
+ # EnrichmentError, so this except MUST precede the next one.)
449
+ raise
450
+ except EnrichmentError as exc:
451
+ # chat_json already retried JSON parsing internally; a persistent
452
+ # failure is unrecoverable — surface as the audio error contract.
453
+ raise AudioError(f"script generation failed: {exc}") from exc
454
+ try:
455
+ return _parse_turns(response)
456
+ except AudioError as exc:
457
+ last_error = exc
458
+ continue
459
+ raise AudioError(
460
+ f"script generation failed after 2 attempts: {last_error}"
461
+ )
462
+
463
+
464
+ def _default_count_tokens(text: str) -> int:
465
+ """Count tokens via tiktoken ``cl100k_base`` (the project-wide tokenizer)."""
466
+ import tiktoken
467
+
468
+ encoder = tiktoken.get_encoding("cl100k_base")
469
+ return len(encoder.encode(text))
470
+
471
+
472
+ def make_script_generator(
473
+ cfg: Config, *, max_turns: int | None = None
474
+ ) -> ScriptGenerator:
475
+ """Build the production :class:`ScriptGenerator` from ``cfg``.
476
+
477
+ Wires :func:`brain.chat.chat_json` bound to ``cfg.audio_script_model`` (NOT
478
+ ``cfg.enrich_model``) as the ``chat_fn``, the tiktoken token counter, and the
479
+ audio caps. The chat helper handles JSON-mode + one transport-level retry;
480
+ ``ScriptGenerator`` layers its own structural validation + retry on top.
481
+
482
+ ``max_turns`` overrides ``cfg.audio_max_turns`` (the ``--turns`` CLI flag);
483
+ the caller is responsible for validating it is a positive even integer.
484
+ """
485
+
486
+ def _chat(
487
+ prompt: str, schema: dict[str, Any], num_predict: int
488
+ ) -> dict[str, Any]:
489
+ return chat_json(
490
+ prompt,
491
+ schema=schema,
492
+ cfg=cfg,
493
+ model=cfg.audio_script_model,
494
+ num_predict=num_predict,
495
+ )
496
+
497
+ return ScriptGenerator(
498
+ chat_fn=_chat,
499
+ model=cfg.audio_script_model,
500
+ max_turns=cfg.audio_max_turns if max_turns is None else max_turns,
501
+ max_input_tokens=cfg.audio_max_input_tokens,
502
+ count_tokens=_default_count_tokens,
503
+ )
504
+
505
+
506
+ # --------------------------------------------------------------------------- #
507
+ # TTS pluggability (Wave B)
508
+ # --------------------------------------------------------------------------- #
509
+ class TtsBackend(Protocol):
510
+ """Pluggable text-to-speech backend (mirrors the ``Embedder`` Protocol).
511
+
512
+ ``synthesize`` receives the in-memory script plus the path to the already
513
+ written ``.json`` artifact and the desired audio output path. Backends live
514
+ OUTSIDE this repo (Claude skills ``venice-audio-speech`` / ``speech`` /
515
+ ``fal-lip-sync``); :class:`ShellTtsBackend` is the shell bridge.
516
+ """
517
+
518
+ def synthesize(
519
+ self,
520
+ script: PodcastScript,
521
+ script_json_path: Path,
522
+ output_path: Path,
523
+ ) -> None: ...
524
+
525
+
526
+ # How long a synthesis subprocess may run before it is reaped (seconds).
527
+ _DEFAULT_TTS_TIMEOUT = 300.0
528
+
529
+ # The ``shell:`` spec prefix selects :class:`ShellTtsBackend`.
530
+ _SHELL_PREFIX = "shell:"
531
+
532
+
533
+ class ShellTtsBackend:
534
+ """Shells out to a user-supplied executable to synthesize audio.
535
+
536
+ The command (everything after ``shell:``) is split into an argv list via
537
+ :func:`shlex.split` at construction. At synthesis time the backend appends
538
+ two positional arguments — the script JSON path and the output path — and
539
+ runs the command with ``shell=False`` (the subprocess default), so paths
540
+ containing spaces are passed safely as discrete argv entries (no quoting
541
+ bugs, no shell injection).
542
+ """
543
+
544
+ def __init__(
545
+ self, command_template: str, timeout: float = _DEFAULT_TTS_TIMEOUT
546
+ ) -> None:
547
+ argv = shlex.split(command_template)
548
+ if not argv:
549
+ raise TtsError("TTS shell command is empty")
550
+ self._argv_template = argv
551
+ self._timeout = timeout
552
+
553
+ def synthesize(
554
+ self,
555
+ script: PodcastScript,
556
+ script_json_path: Path,
557
+ output_path: Path,
558
+ ) -> None:
559
+ """Run ``<command> <script_json_path> <output_path>`` (shell=False)."""
560
+ argv = [*self._argv_template, str(script_json_path), str(output_path)]
561
+ try:
562
+ subprocess.run(argv, check=True, timeout=self._timeout) # noqa: S603
563
+ except FileNotFoundError as exc:
564
+ raise TtsError(
565
+ f"TTS command not found: {self._argv_template[0]!r}"
566
+ ) from exc
567
+ except subprocess.CalledProcessError as exc:
568
+ raise TtsError(
569
+ f"TTS command failed (exit {exc.returncode}): "
570
+ f"{' '.join(self._argv_template)}"
571
+ ) from exc
572
+ except subprocess.TimeoutExpired as exc:
573
+ raise TtsError(
574
+ f"TTS command timed out after {self._timeout}s"
575
+ ) from exc
576
+
577
+
578
+ def make_tts_backend(
579
+ spec: str, *, timeout: float = _DEFAULT_TTS_TIMEOUT
580
+ ) -> TtsBackend:
581
+ """Build a :class:`TtsBackend` from a ``--tts`` spec string.
582
+
583
+ Currently only the ``shell:<command>`` form is supported. Future registered
584
+ aliases (e.g. ``venice``, ``elevenlabs``) can be added without touching the
585
+ Protocol. An unrecognized spec raises :class:`TtsError`.
586
+ """
587
+ if spec.startswith(_SHELL_PREFIX):
588
+ return ShellTtsBackend(spec[len(_SHELL_PREFIX) :], timeout=timeout)
589
+ raise TtsError(
590
+ f"unknown TTS backend spec: {spec!r} (expected 'shell:<command>')"
591
+ )
@@ -0,0 +1,12 @@
1
+ """One-shot data-hygiene utilities for the documents/sources/chunks tables."""
2
+
3
+ from . import search_extras as backfill_search
4
+ from .search_extras import BackfillReport as SearchExtrasBackfillReport
5
+ from .source_rows import BackfillReport, backfill_source_rows
6
+
7
+ __all__ = [
8
+ "BackfillReport",
9
+ "SearchExtrasBackfillReport",
10
+ "backfill_search",
11
+ "backfill_source_rows",
12
+ ]