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/format.py ADDED
@@ -0,0 +1,783 @@
1
+ """Output formatting (human + JSON)."""
2
+ import json
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from rich.console import Console, Group, RenderableType
6
+ from rich.table import Table
7
+ from rich.text import Text
8
+
9
+ from .search import SearchExplanation, SearchResult
10
+
11
+ if TYPE_CHECKING:
12
+ from .eval.baseline import BaselineDiff
13
+ from .eval.runner import EvalReport
14
+ from .graph_rag.aliases import AliasResult
15
+ from .graph_rag.schema import (
16
+ CommunityGroup,
17
+ CommunityRecord,
18
+ EntitySummary,
19
+ GraphContext,
20
+ GraphEntity,
21
+ GraphExplanation,
22
+ GraphStats,
23
+ ThemeGroup,
24
+ )
25
+ from .resurface import ResurfaceItem
26
+ from .timeline import TimelineBucket, TimelineContext
27
+
28
+ # nDCG@5 delta threshold below which a query row is highlighted red in the
29
+ # diff table. Display-only — the CLI never exits non-zero based on this.
30
+ _EVAL_REGRESSION_THRESHOLD: float = -0.05
31
+
32
+ console = Console()
33
+
34
+
35
+ def emit_json(payload: Any) -> None:
36
+ """Print a JSON-serializable payload as pretty JSON via Rich."""
37
+ console.print_json(json.dumps(payload, default=str))
38
+
39
+
40
+ def search_table(results: list[SearchResult], *, title: str = "Search results") -> Table:
41
+ """Render hybrid-search results as a Rich table.
42
+
43
+ ``title`` defaults to ``"Search results"`` for ``brain search``; graph
44
+ retrieval reuses the same row shape for its document hits with a
45
+ ``"Documents"`` title (spec §4 D8 — graph ``docs`` reuse ``SearchResult``).
46
+ """
47
+ table = Table(title=title)
48
+ table.add_column("ID", style="dim")
49
+ table.add_column("Title")
50
+ table.add_column("Source", style="cyan")
51
+ table.add_column("Score", justify="right")
52
+ table.add_column("Snippet")
53
+ for r in results:
54
+ table.add_row(
55
+ r.document_id[:8],
56
+ r.title,
57
+ r.source_kind or "manual",
58
+ f"{r.score:.3f}",
59
+ r.snippet[:120].replace("\n", " "),
60
+ )
61
+ return table
62
+
63
+
64
+ def resurface_table(
65
+ items: "list[ResurfaceItem]", *, title: str = "Due for review"
66
+ ) -> Table:
67
+ """Render resurface items as a Rich table (Plan 02).
68
+
69
+ Mirrors the ``brain list`` row shape: short id + title, plus the three
70
+ resurfacing signals (score, age in days, days since last access). A
71
+ never-accessed doc shows ``never`` in the last column.
72
+ """
73
+ table = Table(title=title)
74
+ table.add_column("ID", style="dim")
75
+ table.add_column("Title")
76
+ table.add_column("Score", justify="right")
77
+ table.add_column("Age (d)", justify="right")
78
+ table.add_column("Last access (d ago)", justify="right")
79
+ for it in items:
80
+ last_access = (
81
+ "never" if it.last_access_days is None else f"{it.last_access_days:.0f}"
82
+ )
83
+ table.add_row(
84
+ it.id[:8],
85
+ it.title,
86
+ f"{it.score:.2f}",
87
+ f"{it.age_days:.0f}",
88
+ last_access,
89
+ )
90
+ return table
91
+
92
+
93
+ def _fmt_opt_int(val: int | None) -> str:
94
+ """Format an optional integer rank as a string, using '-' for None."""
95
+ return str(val) if val is not None else "-"
96
+
97
+
98
+ def _fmt_opt_float(val: float | None, precision: int = 4) -> str:
99
+ """Format an optional float, using '-' for None."""
100
+ return f"{val:.{precision}f}" if val is not None else "-"
101
+
102
+
103
+ def _fmt_filters(matched_filters: dict[str, Any]) -> str:
104
+ """Render matched_filters as a compact string, omitting None/False values."""
105
+ parts = []
106
+ for key, value in matched_filters.items():
107
+ if value is None or value is False:
108
+ continue
109
+ # Show boolean flags without the "=True" suffix for readability.
110
+ if isinstance(value, bool):
111
+ parts.append(key)
112
+ else:
113
+ parts.append(f"{key}={value}")
114
+ return " · ".join(parts) if parts else "(none)"
115
+
116
+
117
+ def explain_table(results: list[SearchResult], *, verbose: bool = False) -> Table:
118
+ """Render hybrid-search results with full ranking diagnostics as a Rich table.
119
+
120
+ Columns (default): ID / Title / Source / FTS# / Vec# / Vec-cos / RRF /
121
+ Recency / Final / Best-chunk#.
122
+ With ``verbose=True`` a Filters column is appended.
123
+
124
+ Results without an :class:`SearchExplanation` (``explain is None``) render
125
+ with ``-`` in all diagnostic columns — this should not happen in normal use
126
+ since ``brain explain`` always sets ``explain=True``.
127
+ """
128
+ table = Table(title="Explain results")
129
+ table.add_column("ID", style="dim")
130
+ table.add_column("Title")
131
+ table.add_column("Source", style="cyan")
132
+ table.add_column("FTS#", justify="right")
133
+ table.add_column("Vec#", justify="right")
134
+ table.add_column("Vec-cos", justify="right")
135
+ table.add_column("RRF", justify="right")
136
+ table.add_column("Recency", justify="right")
137
+ table.add_column("Final", justify="right")
138
+ table.add_column("Best-chunk#", justify="right")
139
+ if verbose:
140
+ table.add_column("Filters")
141
+
142
+ for r in results:
143
+ ex: SearchExplanation | None = r.explain
144
+ if ex is not None:
145
+ row = [
146
+ r.document_id[:8],
147
+ r.title,
148
+ r.source_kind or "manual",
149
+ _fmt_opt_int(ex.fts_rank),
150
+ _fmt_opt_int(ex.vector_rank),
151
+ _fmt_opt_float(ex.vector_cosine),
152
+ f"{ex.rrf_score:.5f}",
153
+ f"{ex.recency_boost:.4f}×",
154
+ f"{ex.final_score:.5f}",
155
+ f"#{ex.best_chunk_index}",
156
+ ]
157
+ if verbose:
158
+ row.append(_fmt_filters(ex.matched_filters))
159
+ else:
160
+ # Fallback for results that somehow lack an explanation.
161
+ row = [
162
+ r.document_id[:8],
163
+ r.title,
164
+ r.source_kind or "manual",
165
+ "-", "-", "-", f"{r.score:.5f}", "-", f"{r.score:.5f}", "-",
166
+ ]
167
+ if verbose:
168
+ row.append("-")
169
+ table.add_row(*row)
170
+
171
+ return table
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Eval report tables
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ def eval_report_table(report: "EvalReport") -> Table:
180
+ """Render an :class:`~brain.eval.runner.EvalReport` as a Rich table.
181
+
182
+ Columns: Category / Query / nDCG@5 / MRR / Recall@20.
183
+ A separator row is added at the bottom with the aggregate means.
184
+ """
185
+ table = Table(title="Eval results")
186
+ table.add_column("Category", style="cyan")
187
+ table.add_column("Query")
188
+ table.add_column("nDCG@5", justify="right")
189
+ table.add_column("MRR", justify="right")
190
+ table.add_column("Recall@20", justify="right")
191
+
192
+ for r in report.results:
193
+ table.add_row(
194
+ r.category,
195
+ r.query[:60],
196
+ f"{r.ndcg_at_5:.4f}",
197
+ f"{r.mrr:.4f}",
198
+ f"{r.recall_at_20:.4f}",
199
+ )
200
+
201
+ table.add_section()
202
+ table.add_row(
203
+ "[bold]mean[/bold]",
204
+ "",
205
+ f"[bold]{report.mean_ndcg_at_5:.4f}[/bold]",
206
+ f"[bold]{report.mean_mrr:.4f}[/bold]",
207
+ f"[bold]{report.mean_recall_at_20:.4f}[/bold]",
208
+ )
209
+ return table
210
+
211
+
212
+ def eval_diff_table(diff: "BaselineDiff") -> Table:
213
+ """Render a :class:`~brain.eval.baseline.BaselineDiff` as a Rich table.
214
+
215
+ Columns: Category / Query / ΔnDCG@5 / ΔMRR / ΔRecall@20.
216
+ Rows where ``ndcg_at_5_delta < _EVAL_REGRESSION_THRESHOLD`` are highlighted
217
+ red. If the config signature changed between baseline and current, a
218
+ caption is appended.
219
+ """
220
+ table = Table(title="Eval diff (current − baseline)")
221
+ table.add_column("Category", style="cyan")
222
+ table.add_column("Query")
223
+ table.add_column("ΔnDCG@5", justify="right")
224
+ table.add_column("ΔMRR", justify="right")
225
+ table.add_column("ΔRecall@20", justify="right")
226
+
227
+ for d in diff.per_query:
228
+ regressed = d.ndcg_at_5_delta < _EVAL_REGRESSION_THRESHOLD
229
+ style = "red" if regressed else ""
230
+ table.add_row(
231
+ d.category,
232
+ d.query[:60],
233
+ f"{d.ndcg_at_5_delta:+.4f}",
234
+ f"{d.mrr_delta:+.4f}",
235
+ f"{d.recall_at_20_delta:+.4f}",
236
+ style=style,
237
+ )
238
+
239
+ table.add_section()
240
+ agg_regressed = diff.mean_ndcg_at_5_delta < _EVAL_REGRESSION_THRESHOLD
241
+ agg_style = "red" if agg_regressed else ""
242
+ table.add_row(
243
+ "[bold]mean[/bold]",
244
+ "",
245
+ f"[bold]{diff.mean_ndcg_at_5_delta:+.4f}[/bold]",
246
+ f"[bold]{diff.mean_mrr_delta:+.4f}[/bold]",
247
+ f"[bold]{diff.mean_recall_at_20_delta:+.4f}[/bold]",
248
+ style=agg_style,
249
+ )
250
+
251
+ if diff.config_signature_changed:
252
+ table.caption = "⚠ config_signature changed between baseline and current run"
253
+
254
+ return table
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # GraphRAG retrieval output (wave G2-h) — human renderable + JSON serializer
259
+ # for the ``GraphContext`` wire shape (spec §4 D8, §6, §9). NEVER exposes raw
260
+ # Cypher: the renderer reads only the structured value object.
261
+ # ---------------------------------------------------------------------------
262
+
263
+
264
+ def _entity_json(entity: "GraphEntity") -> dict[str, Any]:
265
+ """Serialize one :class:`~brain.graph_rag.schema.GraphEntity` (read-side)."""
266
+ return {
267
+ "id": entity.id,
268
+ "entity_type": entity.entity_type,
269
+ "name": entity.name,
270
+ "canonical_key": entity.canonical_key,
271
+ "tenant_id": entity.tenant_id,
272
+ "description": entity.description,
273
+ "doc_count": entity.doc_count,
274
+ # Person-scoped count (themes mode only); None in local/global/entity modes.
275
+ # Always emitted for a stable wire shape (A3).
276
+ "scoped_doc_count": entity.scoped_doc_count,
277
+ }
278
+
279
+
280
+ def _graph_doc_json(doc: SearchResult) -> dict[str, Any]:
281
+ """Serialize one graph document hit (a reused :class:`SearchResult`)."""
282
+ return {
283
+ "id": doc.document_id,
284
+ "title": doc.title,
285
+ "source_kind": doc.source_kind,
286
+ "snippet": doc.snippet,
287
+ "score": doc.score,
288
+ "content_type": doc.content_type,
289
+ "tags": doc.tags,
290
+ }
291
+
292
+
293
+ def _theme_json(theme: "ThemeGroup") -> dict[str, Any]:
294
+ """Serialize one :class:`~brain.graph_rag.schema.ThemeGroup`."""
295
+ return {
296
+ "group_id": theme.group_id,
297
+ "score": theme.score,
298
+ "summary": theme.summary,
299
+ "entities": [_entity_json(e) for e in theme.entities],
300
+ "doc_ids": list(theme.doc_ids),
301
+ }
302
+
303
+
304
+ def _community_json(community: "CommunityGroup") -> dict[str, Any]:
305
+ """Serialize one :class:`~brain.graph_rag.schema.CommunityGroup` (global mode).
306
+
307
+ The wire shape for the top-level ``communities`` key (spec §17c Q5):
308
+ ``community_key`` / ``level`` / ``member_count`` / ``score`` / ``summary`` +
309
+ the representative ``entities`` (reusing :func:`_entity_json`) and
310
+ ``doc_ids``. Mirrors :func:`_theme_json` for the themes path.
311
+ """
312
+ return {
313
+ "community_key": community.community_key,
314
+ "level": community.level,
315
+ "member_count": community.member_count,
316
+ "score": community.score,
317
+ "summary": community.summary,
318
+ "entities": [_entity_json(e) for e in community.entities],
319
+ "doc_ids": list(community.doc_ids),
320
+ }
321
+
322
+
323
+ def _explanation_json(explanation: "GraphExplanation | None") -> dict[str, Any] | None:
324
+ """Serialize the :class:`~brain.graph_rag.schema.GraphExplanation` diagnostic."""
325
+ if explanation is None:
326
+ return None
327
+ return {
328
+ "mode": explanation.mode,
329
+ "tenant_id": explanation.tenant_id,
330
+ "seed_entity_ids": list(explanation.seed_entity_ids),
331
+ "person_keys": list(explanation.person_keys),
332
+ "depth": explanation.depth,
333
+ "frontier_cap": explanation.frontier_cap,
334
+ "min_edge_weight": explanation.min_edge_weight,
335
+ "nodes_visited": explanation.nodes_visited,
336
+ "edges_considered": explanation.edges_considered,
337
+ "generic_df_cap": explanation.generic_df_cap,
338
+ "matched_filters": explanation.matched_filters,
339
+ }
340
+
341
+
342
+ def graph_context_json(ctx: "GraphContext") -> dict[str, Any]:
343
+ """Serialize a :class:`~brain.graph_rag.schema.GraphContext` for ``--json``.
344
+
345
+ The full structured wire shape (spec §9): the resolved/requested mode +
346
+ degradation signals, the scoped person, the ranked ``themes`` (themes mode),
347
+ ``communities`` (global mode; spec §17c Q5), and ``entities`` (local mode),
348
+ the document hits, and the ranking ``explanation``. For ``fuse`` mode (wave
349
+ G4-c; spec §17d Q1) the fused doc list is the standard ``docs`` array (no new
350
+ field — wire-stable) and the per-doc leg provenance rides inside
351
+ ``explanation.matched_filters['fuse_doc_provenance']`` (serialized verbatim
352
+ here). Raw Cypher is never present — only the value object's fields.
353
+ """
354
+ return {
355
+ "session_id": ctx.session_id,
356
+ "mode": ctx.mode,
357
+ "query": ctx.query,
358
+ "tenant_id": ctx.tenant_id,
359
+ "person": ctx.person,
360
+ "requested_mode": ctx.requested_mode,
361
+ "degraded_from": ctx.degraded_from,
362
+ "degradation_reason": ctx.degradation_reason,
363
+ "themes": [_theme_json(t) for t in ctx.themes],
364
+ "communities": [_community_json(c) for c in ctx.communities],
365
+ "entities": [_entity_json(e) for e in ctx.entities],
366
+ "docs": [_graph_doc_json(d) for d in ctx.docs],
367
+ "explanation": _explanation_json(ctx.explanation),
368
+ }
369
+
370
+
371
+ def _graph_header(ctx: "GraphContext") -> Text:
372
+ """Build the one-line ``GraphContext`` header (mode / person / tenant)."""
373
+ parts = [f"mode={ctx.mode}"]
374
+ if ctx.person:
375
+ parts.append(f"person={ctx.person}")
376
+ parts.append(f"tenant={ctx.tenant_id}")
377
+ return Text("Graph RAG · " + " · ".join(parts), style="bold")
378
+
379
+
380
+ def _graph_themes_table(themes: "list[ThemeGroup]") -> Table:
381
+ """Render the ranked theme groups (themes mode)."""
382
+ table = Table(title="Themes")
383
+ table.add_column("#", style="dim", justify="right")
384
+ table.add_column("Entities (scoped docs)")
385
+ table.add_column("Score", justify="right")
386
+ table.add_column("Docs", justify="right")
387
+ table.add_column("Summary")
388
+ for theme in themes:
389
+ entities_cell = ", ".join(
390
+ f"{e.name} ({e.scoped_doc_count})"
391
+ if e.scoped_doc_count is not None
392
+ else e.name
393
+ for e in theme.entities
394
+ )
395
+ table.add_row(
396
+ str(theme.group_id),
397
+ entities_cell,
398
+ f"{theme.score:.3f}",
399
+ str(len(theme.doc_ids)),
400
+ (theme.summary or "").replace("\n", " "),
401
+ )
402
+ return table
403
+
404
+
405
+ def _graph_communities_table(communities: "list[CommunityGroup]") -> Table:
406
+ """Render the ranked community groups (global mode; spec §17c Q5).
407
+
408
+ Columns: # (1-based rank) / Key (short ``community_key``) / Entities (the
409
+ representative member names) / Members (full ``member_count``) / Score (fused
410
+ RRF) / Summary. Mirrors :func:`_graph_themes_table`.
411
+ """
412
+ table = Table(title="Communities")
413
+ table.add_column("#", style="dim", justify="right")
414
+ table.add_column("Key", style="dim")
415
+ table.add_column("Entities")
416
+ table.add_column("Members", justify="right")
417
+ table.add_column("Score", justify="right")
418
+ table.add_column("Summary")
419
+ for rank, community in enumerate(communities, start=1):
420
+ table.add_row(
421
+ str(rank),
422
+ community.community_key[:8],
423
+ ", ".join(e.name for e in community.entities),
424
+ str(community.member_count),
425
+ f"{community.score:.4f}",
426
+ (community.summary or "").replace("\n", " "),
427
+ )
428
+ return table
429
+
430
+
431
+ def _graph_entities_table(entities: "list[GraphEntity]") -> Table:
432
+ """Render the seed + reached entity neighbourhood (local mode)."""
433
+ table = Table(title="Entities")
434
+ table.add_column("Type", style="cyan")
435
+ table.add_column("Name")
436
+ table.add_column("Key", style="dim")
437
+ table.add_column("Docs", justify="right")
438
+ for entity in entities:
439
+ table.add_row(
440
+ entity.entity_type,
441
+ entity.name,
442
+ entity.canonical_key,
443
+ str(entity.doc_count),
444
+ )
445
+ return table
446
+
447
+
448
+ def graph_context_renderable(ctx: "GraphContext") -> RenderableType:
449
+ """Render a :class:`~brain.graph_rag.schema.GraphContext` for the terminal.
450
+
451
+ A header line (mode / person / tenant), a degradation note when the auto
452
+ router degraded ``global → local`` (spec §17b decision 4 — kept dormant in
453
+ G3), the ranked ``communities`` (global mode; spec §17c Q5), ``themes``
454
+ (themes mode), or the ``entities`` neighbourhood (local mode), and the
455
+ document hits (reusing the search-result table shape). For ``fuse`` mode
456
+ (wave G4-c; spec §17d Q1) the graph leg's ``entities`` render above the fused
457
+ ``docs`` table (the per-doc leg provenance is in the ``--json`` explanation).
458
+ An all-empty context renders the header + a ``(no graph results)`` line. Raw
459
+ Cypher is never shown — only the structured value object.
460
+ """
461
+ blocks: list[RenderableType] = [_graph_header(ctx)]
462
+ if ctx.degraded_from is not None:
463
+ blocks.append(
464
+ Text(
465
+ f"note: requested {ctx.requested_mode!r} degraded "
466
+ f"{ctx.degraded_from}→{ctx.mode} ({ctx.degradation_reason})",
467
+ style="yellow",
468
+ )
469
+ )
470
+ if ctx.communities:
471
+ blocks.append(_graph_communities_table(ctx.communities))
472
+ elif ctx.themes:
473
+ blocks.append(_graph_themes_table(ctx.themes))
474
+ elif ctx.entities:
475
+ blocks.append(_graph_entities_table(ctx.entities))
476
+ if ctx.docs:
477
+ blocks.append(search_table(ctx.docs, title="Documents"))
478
+ if not ctx.communities and not ctx.themes and not ctx.entities and not ctx.docs:
479
+ blocks.append(Text("(no graph results)", style="dim"))
480
+ return Group(*blocks)
481
+
482
+
483
+ # ---------------------------------------------------------------------------
484
+ # Community admin listing (`brain graphrag communities list`, wave G3-f).
485
+ # A persisted-row view (NOT a ranked retrieval group) — mirrors the stored
486
+ # ``graph_communities`` rows for an operator. Distinct from the global-mode
487
+ # ``CommunityGroup`` rendering above (which is per-query, RRF-ranked).
488
+ # ---------------------------------------------------------------------------
489
+
490
+
491
+ def _summary_preview(summary: str | None, limit: int = 80) -> str:
492
+ """One-line summary preview for the admin table (NULL → ``"(none)"``)."""
493
+ if not summary:
494
+ return "(none)"
495
+ flattened = summary.replace("\n", " ").strip()
496
+ return flattened if len(flattened) <= limit else flattened[: limit - 1] + "…"
497
+
498
+
499
+ def community_record_json(record: "CommunityRecord") -> dict[str, Any]:
500
+ """Serialize one :class:`~brain.graph_rag.schema.CommunityRecord` (admin view).
501
+
502
+ The wire shape for ``brain graphrag communities list --json``: the stored
503
+ community's identity + aggregate stats + summary metadata. Like the other
504
+ read-side serializers it omits the raw ``summary_embedding`` vector (a
505
+ storage handle, not a wire value).
506
+ """
507
+ return {
508
+ "community_key": record.community_key,
509
+ "level": record.level,
510
+ "build_version": record.build_version,
511
+ "member_count": record.member_count,
512
+ "edge_count": record.edge_count,
513
+ "total_weight": record.total_weight,
514
+ "summary": record.summary,
515
+ "summary_model": record.summary_model,
516
+ "summary_at": record.summary_at.isoformat() if record.summary_at else None,
517
+ }
518
+
519
+
520
+ def community_records_table(records: "list[CommunityRecord]") -> Table:
521
+ """Render stored communities as a Rich table (admin listing; wave G3-f).
522
+
523
+ Columns: Key (short ``community_key``) / Members / Edges / Weight / Summary
524
+ (preview). An empty list still renders the (header-only) table.
525
+ """
526
+ table = Table(title="Communities")
527
+ table.add_column("Key", style="dim")
528
+ table.add_column("Members", justify="right")
529
+ table.add_column("Edges", justify="right")
530
+ table.add_column("Weight", justify="right")
531
+ table.add_column("Summary")
532
+ for record in records:
533
+ table.add_row(
534
+ record.community_key[:8],
535
+ str(record.member_count),
536
+ str(record.edge_count),
537
+ f"{record.total_weight:.3f}",
538
+ _summary_preview(record.summary),
539
+ )
540
+ return table
541
+
542
+
543
+ # ---------------------------------------------------------------------------
544
+ # Entity listing renderers (admin view; plan 2026-05-23)
545
+ # Mirrors the community_records_table / community_record_json pair above.
546
+ # ---------------------------------------------------------------------------
547
+
548
+ # Max characters shown for the description preview in the entity table.
549
+ _ENTITY_DESC_PREVIEW = 60
550
+
551
+
552
+ def entity_summaries_json(row: "EntitySummary") -> dict[str, Any]:
553
+ """Serialize one :class:`~brain.graph_rag.schema.EntitySummary` (admin view).
554
+
555
+ The per-entity wire shape for ``brain graphrag entities --json`` and the
556
+ ``brain_graphrag_entities`` MCP tool. Mirrors :func:`community_record_json`.
557
+ """
558
+ return {
559
+ "entity_type": row.entity_type,
560
+ "name": row.name,
561
+ "canonical_key": row.canonical_key,
562
+ "doc_count": row.doc_count,
563
+ "description": row.description,
564
+ }
565
+
566
+
567
+ def entity_summaries_table(rows: "list[EntitySummary]") -> Table:
568
+ """Render entity summaries as a Rich table (admin listing).
569
+
570
+ Columns: Type / Name / Docs / Description (preview). An empty list still
571
+ renders the (header-only) table.
572
+ """
573
+ table = Table(title="Entities")
574
+ table.add_column("Type", style="cyan")
575
+ table.add_column("Name")
576
+ table.add_column("Docs", justify="right")
577
+ table.add_column("Description")
578
+ for row in rows:
579
+ table.add_row(
580
+ row.entity_type,
581
+ row.name,
582
+ str(row.doc_count),
583
+ _summary_preview(row.description, limit=_ENTITY_DESC_PREVIEW),
584
+ )
585
+ return table
586
+
587
+
588
+ def graph_stats_json(stats: "GraphStats") -> dict[str, Any]:
589
+ """Serialize a :class:`~brain.graph_rag.schema.GraphStats` (admin view).
590
+
591
+ The wire shape for ``brain graphrag stats --json`` and the
592
+ ``brain_graphrag_stats`` MCP tool.
593
+ """
594
+ return {
595
+ "counts_by_type": dict(stats.counts_by_type),
596
+ "total_entities": stats.total_entities,
597
+ "total_relationships": stats.total_relationships,
598
+ "total_communities": stats.total_communities,
599
+ "top_entities": [entity_summaries_json(e) for e in stats.top_entities],
600
+ }
601
+
602
+
603
+ def graph_stats_table(stats: "GraphStats") -> Table:
604
+ """Render a graph overview as a Rich table (admin view; ``brain graphrag stats``).
605
+
606
+ Columns: Metric / Value. Rows: total entities, total relationships,
607
+ total communities, then one row per entity type (sorted alphabetically).
608
+ The top-entities slice is rendered separately by the CLI via
609
+ :func:`entity_summaries_table`.
610
+ """
611
+ table = Table(title="Graph Statistics")
612
+ table.add_column("Metric")
613
+ table.add_column("Value", justify="right")
614
+ table.add_row("Total entities", str(stats.total_entities))
615
+ table.add_row("Total relationships", str(stats.total_relationships))
616
+ table.add_row("Total communities", str(stats.total_communities))
617
+ for entity_type in sorted(stats.counts_by_type):
618
+ table.add_row(f" {entity_type}", str(stats.counts_by_type[entity_type]))
619
+ return table
620
+
621
+
622
+ # ---------------------------------------------------------------------------
623
+ # Phase C — alias/merge admin renderers (``brain graphrag aliases apply``
624
+ # + ``brain_graphrag_aliases_apply``). All 7 :class:`AliasResult` fields are
625
+ # emitted verbatim so the JSON wire shape stays stable across CLI / MCP.
626
+ # ---------------------------------------------------------------------------
627
+
628
+
629
+ def alias_result_json(res: "AliasResult") -> dict[str, Any]:
630
+ """Serialize one :class:`~brain.graph_rag.aliases.AliasResult` (admin view).
631
+
632
+ The wire shape for ``brain graphrag aliases apply --json`` and the
633
+ ``brain_graphrag_aliases_apply`` MCP tool. Emits ALL 7 fields verbatim so a
634
+ dry-run and a real apply are introspectable side-by-side.
635
+ """
636
+ return {
637
+ "tenant_id": res.tenant_id,
638
+ "rules_total": res.rules_total,
639
+ "rules_applied": res.rules_applied,
640
+ "mentions_repointed": res.mentions_repointed,
641
+ "contributions_repointed": res.contributions_repointed,
642
+ "sources_orphaned": res.sources_orphaned,
643
+ "dry_run": res.dry_run,
644
+ }
645
+
646
+
647
+ def alias_result_summary(res: "AliasResult") -> str:
648
+ """One-line human summary of an :class:`AliasResult` (the CLI footer).
649
+
650
+ Mirrors the verb-leading style of the ``graphrag build`` / ``refresh``
651
+ footers ("graphrag build: N processed …") so the three admin commands read
652
+ consistently. Prepends ``"(dry-run) "`` on a dry-run so the no-write nature
653
+ of the call is obvious at a glance.
654
+ """
655
+ prefix = "(dry-run) " if res.dry_run else ""
656
+ return (
657
+ f"{prefix}graphrag aliases apply: "
658
+ f"{res.rules_applied}/{res.rules_total} rule(s) applied, "
659
+ f"{res.mentions_repointed} mention(s) repointed, "
660
+ f"{res.contributions_repointed} contribution(s) repointed, "
661
+ f"{res.sources_orphaned} source(s) orphaned "
662
+ f"(tenant {res.tenant_id!r})"
663
+ )
664
+
665
+
666
+ # ---------------------------------------------------------------------------
667
+ # `brain timeline` (Plan 05) — temporal evolution of a theme/entity.
668
+ # JSON is a serialized TimelineContext (the wire shape shared by CLI --json +
669
+ # the brain_timeline MCP tool); the renderable is an ASCII bar chart.
670
+ # ---------------------------------------------------------------------------
671
+
672
+ # Max width (in `█` cells) of the densest bucket's bar in the terminal chart.
673
+ _TIMELINE_BAR_WIDTH = 24
674
+ # Max representative doc titles listed under a bucket in the terminal view.
675
+ _TIMELINE_TITLES_SHOWN = 3
676
+ # Max matched entity names shown in the human header before collapsing the rest
677
+ # into a "(+N more matched entities)" note. A broad hub theme can ILIKE-match
678
+ # dozens of aliases; dumping them all floods the header (and can surface
679
+ # sensitive alias strings). ``--json`` keeps the full ``entity_names`` list.
680
+ _TIMELINE_HEADER_ENTITIES_SHOWN = 3
681
+
682
+
683
+ def _timeline_bucket_json(bucket: "TimelineBucket") -> dict[str, Any]:
684
+ """Serialize one :class:`~brain.timeline.TimelineBucket` (ISO bucket start)."""
685
+ return {
686
+ "bucket": bucket.bucket,
687
+ "bucket_start": bucket.bucket_start.isoformat(),
688
+ "doc_count": bucket.doc_count,
689
+ "mention_count": bucket.mention_count,
690
+ "doc_ids": list(bucket.doc_ids),
691
+ "doc_titles": list(bucket.doc_titles),
692
+ "cotopics": list(bucket.cotopics),
693
+ "synthesis": bucket.synthesis,
694
+ }
695
+
696
+
697
+ def timeline_context_json(ctx: "TimelineContext") -> dict[str, Any]:
698
+ """Serialize a :class:`~brain.timeline.TimelineContext` for ``--json`` / MCP.
699
+
700
+ The full structured wire shape (spec §3e): the query, resolved tenant +
701
+ entity names, the scope person (when ``--person``), the bucket count trimmed
702
+ by ``--limit`` (``buckets_omitted``), and the ascending ``buckets`` series.
703
+ """
704
+ return {
705
+ "query": ctx.query,
706
+ "tenant_id": ctx.tenant_id,
707
+ "granularity": ctx.granularity,
708
+ "granularity_auto": ctx.granularity_auto,
709
+ "entity_names": list(ctx.entity_names),
710
+ "person": ctx.person,
711
+ "buckets_omitted": ctx.buckets_omitted,
712
+ "buckets": [_timeline_bucket_json(b) for b in ctx.buckets],
713
+ }
714
+
715
+
716
+ def _timeline_entity_label(ctx: "TimelineContext") -> str:
717
+ """Build the header's entity label, capped at the first N matched names.
718
+
719
+ With no matched entities the label is the raw ``query``. Otherwise it joins
720
+ the first :data:`_TIMELINE_HEADER_ENTITIES_SHOWN` names; when more matched,
721
+ a ``(+N more matched entities)`` note replaces the overflow — keeping a broad
722
+ hub theme's header readable instead of dumping every alias. The full list
723
+ still goes out over ``--json`` via :func:`timeline_context_json`.
724
+ """
725
+ names = ctx.entity_names
726
+ if not names:
727
+ return ctx.query
728
+ shown = ", ".join(names[:_TIMELINE_HEADER_ENTITIES_SHOWN])
729
+ overflow = len(names) - _TIMELINE_HEADER_ENTITIES_SHOWN
730
+ if overflow > 0:
731
+ return f"{shown} (+{overflow} more matched entities)"
732
+ return shown
733
+
734
+
735
+ def _timeline_header(ctx: "TimelineContext") -> Text:
736
+ """Build the one-line timeline header (query / granularity / bucket count).
737
+
738
+ The granularity part shows ``"<width> (auto)"`` when the width was chosen
739
+ automatically (``granularity_auto``), so the user can see that ``auto``
740
+ landed on, e.g., month — and could override with ``--granularity`` if wanted.
741
+ """
742
+ label = _timeline_entity_label(ctx)
743
+ gran_part = f"{ctx.granularity} (auto)" if ctx.granularity_auto else ctx.granularity
744
+ parts = [label, gran_part]
745
+ if ctx.person:
746
+ parts.append(f"with {ctx.person}")
747
+ suffix = f" — {len(ctx.buckets)} bucket(s)" if ctx.buckets else ""
748
+ return Text("Timeline · " + " · ".join(parts) + suffix, style="bold")
749
+
750
+
751
+ def timeline_renderable(ctx: "TimelineContext") -> RenderableType:
752
+ """Render a :class:`~brain.timeline.TimelineContext` for the terminal.
753
+
754
+ A header line followed by one block per bucket: an ASCII bar proportional to
755
+ ``doc_count`` (relative to the densest bucket), a co-topic chip line, the top
756
+ representative doc titles, and the optional ``--synthesize`` summary. An
757
+ empty context renders the header + a ``(no timeline buckets)`` line.
758
+ """
759
+ blocks: list[RenderableType] = [_timeline_header(ctx)]
760
+ if not ctx.buckets:
761
+ blocks.append(Text("(no timeline buckets)", style="dim"))
762
+ return Group(*blocks)
763
+ max_docs = max(b.doc_count for b in ctx.buckets) or 1
764
+ for bucket in ctx.buckets:
765
+ bar_len = max(1, round(_TIMELINE_BAR_WIDTH * bucket.doc_count / max_docs))
766
+ line = Text()
767
+ line.append(f"{bucket.bucket:<8} ", style="cyan")
768
+ line.append("█" * bar_len, style="green")
769
+ line.append(f" {bucket.doc_count} docs")
770
+ if bucket.cotopics:
771
+ line.append(f" co-topics: {', '.join(bucket.cotopics)}", style="dim")
772
+ blocks.append(line)
773
+ for title in bucket.doc_titles[:_TIMELINE_TITLES_SHOWN]:
774
+ blocks.append(Text(f" · {title}".replace("\n", " "), style="dim"))
775
+ if bucket.synthesis:
776
+ blocks.append(
777
+ Text(f" ✦ {bucket.synthesis}".replace("\n", " "), style="italic")
778
+ )
779
+ if ctx.buckets_omitted:
780
+ blocks.append(
781
+ Text(f"({ctx.buckets_omitted} bucket(s) omitted by --limit)", style="dim")
782
+ )
783
+ return Group(*blocks)