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.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- 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)
|