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
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Global (community-based) graph retrieval (spec §6c — wave G3-d).
|
|
2
|
+
|
|
3
|
+
The third retrieval path alongside **local** (:mod:`brain.graph_rag.retrieve`)
|
|
4
|
+
and **themes-with-X** (:mod:`brain.graph_rag.themes`). Where local resolves
|
|
5
|
+
entity seeds and themes scopes a person, **global** ranks the tenant's
|
|
6
|
+
pre-detected **communities** (``graph_communities``) against a free-text query
|
|
7
|
+
and returns the top ones with their representative entities + documents. The
|
|
8
|
+
public dispatch entry :func:`brain.graph_rag.retrieve.graph_rag_search` calls
|
|
9
|
+
:func:`_retrieve_global` here when ``mode='global'`` (the router flip to route
|
|
10
|
+
*auto* there is wave G3-e; G3-d only adds the path + the explicit-mode dispatch).
|
|
11
|
+
|
|
12
|
+
The ranking (spec §17c Q4): the ranked unit is the **community**, not docs or
|
|
13
|
+
chunks. Two signals are fused with Reciprocal Rank Fusion
|
|
14
|
+
(:func:`brain.rank_fusion.rrf_contribution`, ``k=60`` matching
|
|
15
|
+
:mod:`brain.search`):
|
|
16
|
+
|
|
17
|
+
* **FTS leg** — ``ts_rank`` over ``graph_communities.summary_tsv`` for
|
|
18
|
+
communities with a non-NULL ``summary`` (reusing
|
|
19
|
+
:func:`brain.search._build_tsquery` so the tsquery shape matches hybrid
|
|
20
|
+
search rather than reinventing it).
|
|
21
|
+
* **Vector leg** — cosine distance over ``summary_embedding`` for communities
|
|
22
|
+
with a non-NULL embedding. The query is embedded ``input_type="query"`` via
|
|
23
|
+
the injected ``embedder`` instance (spec §17c Q9). The vector leg is the
|
|
24
|
+
**only reason global needs an embedder**; local/themes stay embedder-free.
|
|
25
|
+
|
|
26
|
+
``score = Σ 1/(60 + rank + 1)`` per ``community_key``; ties broken by
|
|
27
|
+
``community_key`` (deterministic). The top ``limit`` communities
|
|
28
|
+
(``cfg.graph_community_limit`` default) become :class:`CommunityGroup`s.
|
|
29
|
+
|
|
30
|
+
Design invariants (mirroring the sibling paths):
|
|
31
|
+
|
|
32
|
+
* **Never-raise (spec §17c Q9/Q10).** No communities / no summaries → an
|
|
33
|
+
empty-but-valid :class:`GraphContext` (never raises). A **missing**
|
|
34
|
+
``embedder`` OR a **failing** query-embedding call → a WARN + the vector
|
|
35
|
+
leg is skipped (FTS-only), never a hard failure — exactly the §6c "guarded
|
|
36
|
+
on non-null embedding" degradation.
|
|
37
|
+
* **Tenant-scoped (spec §4 D9).** Every read filters by ``tenant_id``.
|
|
38
|
+
* **Deterministic ordering.** Both legs order before truncation, the fusion
|
|
39
|
+
tie-breaks on ``community_key``, and the per-community entity/doc reads break
|
|
40
|
+
ties on stable keys, so repeated runs return byte-identical results.
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import logging
|
|
45
|
+
import uuid
|
|
46
|
+
from typing import TYPE_CHECKING, Any
|
|
47
|
+
|
|
48
|
+
import psycopg
|
|
49
|
+
|
|
50
|
+
from ..rank_fusion import rrf_contribution
|
|
51
|
+
from ._retrieval_common import _build_doc_results, _row_to_entity
|
|
52
|
+
from .router import GLOBAL_MODE
|
|
53
|
+
from .schema import CommunityGroup, GraphContext, GraphEntity, GraphExplanation
|
|
54
|
+
|
|
55
|
+
if TYPE_CHECKING:
|
|
56
|
+
from ..config import Config
|
|
57
|
+
from ..ingest import Embedder
|
|
58
|
+
from ..search import SearchResult
|
|
59
|
+
|
|
60
|
+
_logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
# RRF k constant — matches :data:`brain.search.RRF_K` (spec §17c Q4 keeps the
|
|
63
|
+
# global path and hybrid search on the same fusion constant via the shared
|
|
64
|
+
# :func:`brain.rank_fusion.rrf_contribution` helper).
|
|
65
|
+
_RRF_K = 60
|
|
66
|
+
|
|
67
|
+
# Per-leg candidate cap. The community universe is already bounded by the §17c Q8
|
|
68
|
+
# ``graph_community_max`` detection cap, but capping each ranking leg keeps the
|
|
69
|
+
# fusion work bounded under multi-tenant scale and the truncation deterministic
|
|
70
|
+
# (each leg orders before slicing).
|
|
71
|
+
_CANDIDATE_LIMIT = 200
|
|
72
|
+
|
|
73
|
+
# Representative member entities carried on each ``CommunityGroup`` (top by
|
|
74
|
+
# ``member_rank``). ``member_count`` carries the authoritative full size; this
|
|
75
|
+
# only caps the surfaced subset (mirrors
|
|
76
|
+
# :data:`brain.graph_rag.communities_summary._SUMMARY_ENTITY_LIMIT`).
|
|
77
|
+
_MEMBER_ENTITY_LIMIT = 20
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _retrieve_global(
|
|
81
|
+
conn: psycopg.Connection[Any],
|
|
82
|
+
cfg: Config,
|
|
83
|
+
query: str,
|
|
84
|
+
*,
|
|
85
|
+
tenant: str,
|
|
86
|
+
limit: int | None = None,
|
|
87
|
+
embedder: Embedder | None = None,
|
|
88
|
+
session_id: str | None = None,
|
|
89
|
+
) -> GraphContext:
|
|
90
|
+
"""Run global (community-based) retrieval + assemble its ``GraphContext``.
|
|
91
|
+
|
|
92
|
+
Ranks the tenant's communities by fusing an FTS leg (over ``summary_tsv``)
|
|
93
|
+
with a vector leg (over ``summary_embedding``, only when an embedder is
|
|
94
|
+
available) via RRF, takes the top ``limit`` (``cfg.graph_community_limit``
|
|
95
|
+
when ``None``), and populates each :class:`CommunityGroup` with its
|
|
96
|
+
representative member entities + documents (+ snippets via the shared
|
|
97
|
+
:func:`brain.graph_rag._retrieval_common._build_doc_results`).
|
|
98
|
+
|
|
99
|
+
``embedder`` is the **pre-warmed** :class:`brain.ingest.Embedder` instance
|
|
100
|
+
used to embed the *query* for the vector leg (spec §17c Q9 — local/themes
|
|
101
|
+
never need one). Pre-warmed because the caller (CLI/MCP/fuse) constructs
|
|
102
|
+
or reuses ONE long-lived instance and passes it in, so global mode never
|
|
103
|
+
re-builds the embedder per call (perf-T4 G5). A ``None`` embedder or a
|
|
104
|
+
failing ``embed()`` call logs a WARN and runs **FTS-only** (never-raise).
|
|
105
|
+
No communities / no summaries → an empty-but-valid context. ``session_id``
|
|
106
|
+
is generated when omitted (a fresh ``uuid4`` hex), mirroring the MCP
|
|
107
|
+
``{session_id, results}`` envelope.
|
|
108
|
+
"""
|
|
109
|
+
resolved_limit = cfg.graph_community_limit if limit is None else limit
|
|
110
|
+
resolved_session = uuid.uuid4().hex if session_id is None else session_id
|
|
111
|
+
|
|
112
|
+
fts_keys = _fts_ranked_keys(conn, tenant=tenant, query=query)
|
|
113
|
+
vector_keys, vector_arm_used = _vector_ranked_keys(
|
|
114
|
+
conn, tenant=tenant, query=query, embedder=embedder
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
fused = _fuse_rrf(fts_keys, vector_keys)
|
|
118
|
+
top = fused[:resolved_limit] if resolved_limit > 0 else []
|
|
119
|
+
|
|
120
|
+
communities, docs = _build_communities(conn, tenant, query, top, resolved_limit)
|
|
121
|
+
entities = _dedupe_entities(communities)
|
|
122
|
+
|
|
123
|
+
explanation = GraphExplanation(
|
|
124
|
+
mode=GLOBAL_MODE,
|
|
125
|
+
tenant_id=tenant,
|
|
126
|
+
seed_entity_ids=[],
|
|
127
|
+
person_keys=[],
|
|
128
|
+
depth=0,
|
|
129
|
+
frontier_cap=0,
|
|
130
|
+
min_edge_weight=0.0,
|
|
131
|
+
nodes_visited=len(communities),
|
|
132
|
+
edges_considered=0,
|
|
133
|
+
generic_df_cap=None,
|
|
134
|
+
matched_filters={
|
|
135
|
+
"query": query,
|
|
136
|
+
"fts_candidate_count": len(fts_keys),
|
|
137
|
+
"vector_candidate_count": len(vector_keys),
|
|
138
|
+
"vector_arm_used": vector_arm_used,
|
|
139
|
+
"community_count": len(communities),
|
|
140
|
+
"limit": resolved_limit,
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
return GraphContext(
|
|
144
|
+
session_id=resolved_session,
|
|
145
|
+
mode=GLOBAL_MODE,
|
|
146
|
+
query=query,
|
|
147
|
+
tenant_id=tenant,
|
|
148
|
+
person=None,
|
|
149
|
+
themes=[],
|
|
150
|
+
communities=communities,
|
|
151
|
+
entities=entities,
|
|
152
|
+
docs=docs,
|
|
153
|
+
explanation=explanation,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --------------------------------------------------------------------------- #
|
|
158
|
+
# Ranking legs (FTS + vector) → RRF fusion
|
|
159
|
+
# --------------------------------------------------------------------------- #
|
|
160
|
+
def _fts_ranked_keys(
|
|
161
|
+
conn: psycopg.Connection[Any], *, tenant: str, query: str
|
|
162
|
+
) -> list[str]:
|
|
163
|
+
"""Communities ranked by ``ts_rank`` over ``summary_tsv`` (FTS leg, §17c Q4).
|
|
164
|
+
|
|
165
|
+
Tenant-scoped, restricted to communities with a non-NULL ``summary``. Reuses
|
|
166
|
+
:func:`brain.search._build_tsquery` (late import — keeps :mod:`brain.graph_rag`
|
|
167
|
+
import-cheap, mirroring :func:`._retrieval_common._build_doc_results`) so the
|
|
168
|
+
compact-form tsquery expansion matches hybrid search. Orders by ``ts_rank``
|
|
169
|
+
DESC then ``community_key`` so the 0-indexed rank used for RRF is
|
|
170
|
+
deterministic; capped at :data:`_CANDIDATE_LIMIT`. An empty / pure-punctuation
|
|
171
|
+
query (empty tsquery) ranks nothing.
|
|
172
|
+
"""
|
|
173
|
+
from ..search import _build_tsquery
|
|
174
|
+
|
|
175
|
+
tsquery = _build_tsquery(conn, query)
|
|
176
|
+
if not tsquery:
|
|
177
|
+
return []
|
|
178
|
+
rows = conn.execute(
|
|
179
|
+
"SELECT community_key::text FROM graph_communities "
|
|
180
|
+
"WHERE tenant_id = %s AND summary IS NOT NULL "
|
|
181
|
+
"AND summary_tsv @@ to_tsquery('english', %s) "
|
|
182
|
+
"ORDER BY ts_rank(summary_tsv, to_tsquery('english', %s)) DESC, "
|
|
183
|
+
"community_key "
|
|
184
|
+
"LIMIT %s",
|
|
185
|
+
(tenant, tsquery, tsquery, _CANDIDATE_LIMIT),
|
|
186
|
+
).fetchall()
|
|
187
|
+
return [str(row[0]) for row in rows]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _vector_ranked_keys(
|
|
191
|
+
conn: psycopg.Connection[Any],
|
|
192
|
+
*,
|
|
193
|
+
tenant: str,
|
|
194
|
+
query: str,
|
|
195
|
+
embedder: Embedder | None,
|
|
196
|
+
) -> tuple[list[str], bool]:
|
|
197
|
+
"""Communities ranked by ``summary_embedding`` cosine (vector leg, §17c Q4/Q9).
|
|
198
|
+
|
|
199
|
+
Returns ``(keys, attempted)``. The vector leg requires an embedder (spec
|
|
200
|
+
§17c Q9 — global REQUIRES one; local/themes stay embedder-free): the
|
|
201
|
+
injected ``embedder`` is the caller's PRE-WARMED instance (perf-T4 G5 —
|
|
202
|
+
the caller constructs ONCE and reuses across calls / legs), used to embed
|
|
203
|
+
the *query* (``input_type="query"``). A ``None`` embedder OR **any**
|
|
204
|
+
failure in the vector arm — embedding the query OR executing the cosine
|
|
205
|
+
``<=>`` SQL (e.g. a dim mismatch when the backend was swapped without a
|
|
206
|
+
community rebuild, or a transient DB error) → a WARN and ``([], False)``
|
|
207
|
+
(FTS-only degradation; never-raise, matching §6c "guarded on non-null
|
|
208
|
+
embedding"). The cosine SQL is inside the guard precisely so a dim-mismatch
|
|
209
|
+
/ DB error in the ``<=>`` execution degrades to FTS-only rather than
|
|
210
|
+
breaking global retrieval. ``attempted=True`` means the full vector arm ran
|
|
211
|
+
(embed + cosine SQL succeeded) and the ranking is usable (even if zero
|
|
212
|
+
communities carry a ``summary_embedding`` — the leg simply contributes
|
|
213
|
+
nothing).
|
|
214
|
+
|
|
215
|
+
Tenant-scoped, restricted to ``summary_embedding IS NOT NULL``; ordered by
|
|
216
|
+
cosine distance ascending then ``community_key`` (deterministic), capped at
|
|
217
|
+
:data:`_CANDIDATE_LIMIT`. Runs under the caller's autocommit connection (the
|
|
218
|
+
CLI/MCP search paths set ``autocommit=True``), so a failed ``<=>`` statement
|
|
219
|
+
leaves the connection usable for the subsequent FTS-only assembly.
|
|
220
|
+
"""
|
|
221
|
+
if embedder is None:
|
|
222
|
+
_logger.warning(
|
|
223
|
+
"global retrieval: no embedder injected; vector leg skipped "
|
|
224
|
+
"(FTS-only)"
|
|
225
|
+
)
|
|
226
|
+
return [], False
|
|
227
|
+
try:
|
|
228
|
+
query_vec = embedder.embed([query], input_type="query")[0]
|
|
229
|
+
rows = conn.execute(
|
|
230
|
+
"SELECT community_key::text FROM graph_communities "
|
|
231
|
+
"WHERE tenant_id = %s AND summary_embedding IS NOT NULL "
|
|
232
|
+
"ORDER BY summary_embedding <=> %s::vector, community_key "
|
|
233
|
+
"LIMIT %s",
|
|
234
|
+
(tenant, query_vec, _CANDIDATE_LIMIT),
|
|
235
|
+
).fetchall()
|
|
236
|
+
except Exception as exc: # noqa: BLE001 — never-raise: degrade to FTS-only
|
|
237
|
+
_logger.warning(
|
|
238
|
+
"global retrieval: vector leg failed (%s); vector leg skipped "
|
|
239
|
+
"(FTS-only)",
|
|
240
|
+
exc,
|
|
241
|
+
)
|
|
242
|
+
return [], False
|
|
243
|
+
return [str(row[0]) for row in rows], True
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _fuse_rrf(
|
|
247
|
+
fts_keys: list[str], vector_keys: list[str]
|
|
248
|
+
) -> list[tuple[str, float]]:
|
|
249
|
+
"""Fuse the two ranked legs with RRF (spec §17c Q4).
|
|
250
|
+
|
|
251
|
+
Each leg contributes ``1 / (60 + rank + 1)`` per ``community_key`` (0-indexed
|
|
252
|
+
rank, via :func:`brain.rank_fusion.rrf_contribution`); contributions
|
|
253
|
+
accumulate across legs. Returns ``(community_key, score)`` sorted by score
|
|
254
|
+
DESC then ``community_key`` ASC (the deterministic tie-break).
|
|
255
|
+
"""
|
|
256
|
+
scores: dict[str, float] = {}
|
|
257
|
+
for rank, key in enumerate(fts_keys):
|
|
258
|
+
scores[key] = scores.get(key, 0.0) + rrf_contribution(rank, k=_RRF_K)
|
|
259
|
+
for rank, key in enumerate(vector_keys):
|
|
260
|
+
scores[key] = scores.get(key, 0.0) + rrf_contribution(rank, k=_RRF_K)
|
|
261
|
+
return sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# --------------------------------------------------------------------------- #
|
|
265
|
+
# Community population (entities + representative docs + snippets)
|
|
266
|
+
# --------------------------------------------------------------------------- #
|
|
267
|
+
def _build_communities(
|
|
268
|
+
conn: psycopg.Connection[Any],
|
|
269
|
+
tenant: str,
|
|
270
|
+
query: str,
|
|
271
|
+
ranked: list[tuple[str, float]],
|
|
272
|
+
limit: int,
|
|
273
|
+
) -> tuple[list[CommunityGroup], list[SearchResult]]:
|
|
274
|
+
"""Assemble ranked :class:`CommunityGroup`s + the context-level docs.
|
|
275
|
+
|
|
276
|
+
For the top communities (``ranked``, preserving fusion order), batch-load
|
|
277
|
+
each community's metadata (``level`` / ``member_count`` / ``summary``),
|
|
278
|
+
representative member entities (top by ``member_rank``), and representative
|
|
279
|
+
documents (those most mentioning the community's entities). The
|
|
280
|
+
context-level ``docs`` aggregate every contributing document's per-community
|
|
281
|
+
mention scores and reuse :func:`._retrieval_common._build_doc_results` for
|
|
282
|
+
the snippet path (so global docs match the local/themes/hybrid snippet
|
|
283
|
+
shape). An empty ``ranked`` returns ``([], [])`` (the never-raise empty case).
|
|
284
|
+
"""
|
|
285
|
+
if not ranked:
|
|
286
|
+
return [], []
|
|
287
|
+
keys = [key for key, _score in ranked]
|
|
288
|
+
score_by_key = dict(ranked)
|
|
289
|
+
|
|
290
|
+
meta_by_key = _community_metadata(conn, tenant, keys)
|
|
291
|
+
entities_by_key = _community_entities(conn, tenant, keys)
|
|
292
|
+
docs_by_key = _community_doc_scores(conn, tenant, keys)
|
|
293
|
+
|
|
294
|
+
communities: list[CommunityGroup] = []
|
|
295
|
+
context_doc_score: dict[str, float] = {}
|
|
296
|
+
for key in keys: # preserve fusion ranking order
|
|
297
|
+
level, member_count, summary = meta_by_key.get(key, (0, 0, None))
|
|
298
|
+
doc_scores = docs_by_key.get(key, [])
|
|
299
|
+
doc_ids = [doc_id for doc_id, _count in doc_scores[:limit]]
|
|
300
|
+
communities.append(
|
|
301
|
+
CommunityGroup(
|
|
302
|
+
community_key=key,
|
|
303
|
+
level=level,
|
|
304
|
+
member_count=member_count,
|
|
305
|
+
score=score_by_key[key],
|
|
306
|
+
summary=summary,
|
|
307
|
+
entities=entities_by_key.get(key, []),
|
|
308
|
+
doc_ids=doc_ids,
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
for doc_id, count in doc_scores:
|
|
312
|
+
context_doc_score[doc_id] = context_doc_score.get(doc_id, 0.0) + count
|
|
313
|
+
|
|
314
|
+
context_ranked = sorted(
|
|
315
|
+
context_doc_score.items(), key=lambda kv: (-kv[1], kv[0])
|
|
316
|
+
)[:limit]
|
|
317
|
+
return communities, _build_doc_results(conn, query, context_ranked)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _community_metadata(
|
|
321
|
+
conn: psycopg.Connection[Any], tenant: str, keys: list[str]
|
|
322
|
+
) -> dict[str, tuple[int, int, str | None]]:
|
|
323
|
+
"""Batch-load ``(level, member_count, summary)`` per community key."""
|
|
324
|
+
if not keys:
|
|
325
|
+
return {}
|
|
326
|
+
rows = conn.execute(
|
|
327
|
+
"SELECT community_key::text, level, member_count, summary "
|
|
328
|
+
"FROM graph_communities "
|
|
329
|
+
"WHERE tenant_id = %s AND community_key::text = ANY(%s)",
|
|
330
|
+
(tenant, keys),
|
|
331
|
+
).fetchall()
|
|
332
|
+
return {str(row[0]): (int(row[1]), int(row[2]), row[3]) for row in rows}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _community_entities(
|
|
336
|
+
conn: psycopg.Connection[Any], tenant: str, keys: list[str]
|
|
337
|
+
) -> dict[str, list[GraphEntity]]:
|
|
338
|
+
"""Batch-load each community's representative member entities (by member_rank).
|
|
339
|
+
|
|
340
|
+
Joins ``graph_community_members`` → ``graph_entities`` (tenant-scoped),
|
|
341
|
+
orders by ``member_rank`` ASC then ``canonical_key`` / ``id`` (deterministic),
|
|
342
|
+
groups by community key in Python, and caps each list at
|
|
343
|
+
:data:`_MEMBER_ENTITY_LIMIT`. Maps each row to a :class:`GraphEntity` via the
|
|
344
|
+
shared :func:`._retrieval_common._row_to_entity`.
|
|
345
|
+
"""
|
|
346
|
+
if not keys:
|
|
347
|
+
return {}
|
|
348
|
+
rows = conn.execute(
|
|
349
|
+
"SELECT cm.community_key::text, ge.id::text, ge.entity_type, ge.name, "
|
|
350
|
+
"ge.canonical_key, ge.description, ge.doc_count "
|
|
351
|
+
"FROM graph_community_members cm "
|
|
352
|
+
"JOIN graph_entities ge "
|
|
353
|
+
" ON ge.tenant_id = cm.tenant_id AND ge.id = cm.entity_id "
|
|
354
|
+
"WHERE cm.tenant_id = %s AND cm.community_key::text = ANY(%s) "
|
|
355
|
+
"ORDER BY cm.community_key, cm.member_rank ASC, ge.canonical_key ASC, "
|
|
356
|
+
"ge.id ASC",
|
|
357
|
+
(tenant, keys),
|
|
358
|
+
).fetchall()
|
|
359
|
+
out: dict[str, list[GraphEntity]] = {}
|
|
360
|
+
for row in rows:
|
|
361
|
+
key = str(row[0])
|
|
362
|
+
bucket = out.setdefault(key, [])
|
|
363
|
+
if len(bucket) >= _MEMBER_ENTITY_LIMIT:
|
|
364
|
+
continue
|
|
365
|
+
bucket.append(_row_to_entity(row[1:7], tenant))
|
|
366
|
+
return out
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _community_doc_scores(
|
|
370
|
+
conn: psycopg.Connection[Any], tenant: str, keys: list[str]
|
|
371
|
+
) -> dict[str, list[tuple[str, float]]]:
|
|
372
|
+
"""Batch-load each community's representative documents + mention scores.
|
|
373
|
+
|
|
374
|
+
Joins ``graph_community_members`` → ``graph_entity_mentions`` (tenant-scoped)
|
|
375
|
+
and counts, per community, how many of the community's entity-mention rows
|
|
376
|
+
hit each document. Returns ``community_key → [(document_id, count), …]``
|
|
377
|
+
ordered by count DESC then ``document_id`` ASC (deterministic). Mirrors the
|
|
378
|
+
document ranking in :func:`brain.graph_rag.communities_summary.
|
|
379
|
+
_representative_doc_titles` (count of community entities present), but keyed
|
|
380
|
+
by document id for the per-community ``doc_ids`` + the context-level fusion.
|
|
381
|
+
"""
|
|
382
|
+
if not keys:
|
|
383
|
+
return {}
|
|
384
|
+
rows = conn.execute(
|
|
385
|
+
"SELECT cm.community_key::text, m.document_id::text, COUNT(*) AS n "
|
|
386
|
+
"FROM graph_community_members cm "
|
|
387
|
+
"JOIN graph_entity_mentions m "
|
|
388
|
+
" ON m.tenant_id = cm.tenant_id AND m.entity_id = cm.entity_id "
|
|
389
|
+
"WHERE cm.tenant_id = %s AND cm.community_key::text = ANY(%s) "
|
|
390
|
+
"GROUP BY cm.community_key, m.document_id "
|
|
391
|
+
"ORDER BY cm.community_key, n DESC, m.document_id ASC",
|
|
392
|
+
(tenant, keys),
|
|
393
|
+
).fetchall()
|
|
394
|
+
out: dict[str, list[tuple[str, float]]] = {}
|
|
395
|
+
for row in rows:
|
|
396
|
+
out.setdefault(str(row[0]), []).append((str(row[1]), float(row[2])))
|
|
397
|
+
return out
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _dedupe_entities(communities: list[CommunityGroup]) -> list[GraphEntity]:
|
|
401
|
+
"""Flat, deduped, deterministically-sorted entity list across communities.
|
|
402
|
+
|
|
403
|
+
Mirrors :func:`brain.graph_rag.themes._theme_member_entities` so the
|
|
404
|
+
context-level ``entities`` shape matches the themes path.
|
|
405
|
+
"""
|
|
406
|
+
by_id: dict[str, GraphEntity] = {}
|
|
407
|
+
for community in communities:
|
|
408
|
+
for entity in community.entities:
|
|
409
|
+
by_id.setdefault(entity.id, entity)
|
|
410
|
+
return sorted(
|
|
411
|
+
by_id.values(), key=lambda e: (e.canonical_key, e.entity_type, e.id)
|
|
412
|
+
)
|