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,526 @@
|
|
|
1
|
+
"""Entity-centric (local) graph retrieval + the auto-router dispatch (``graph_rag_search``).
|
|
2
|
+
|
|
3
|
+
This module owns the **public dispatch entry** :func:`graph_rag_search`, the
|
|
4
|
+
**local** retrieval path, and seed resolution. The **themes-with-X** path (spec
|
|
5
|
+
§6b) lives in the sibling :mod:`brain.graph_rag.themes`, and the entity-row +
|
|
6
|
+
doc-result helpers shared by both paths live in
|
|
7
|
+
:mod:`brain.graph_rag._retrieval_common` — the G2 wave-boundary file-size split
|
|
8
|
+
(mirroring the G1 :mod:`brain.graph_rag.aggregates` / G2-c
|
|
9
|
+
:mod:`brain.graph_rag.relational` extractions). Behavior is unchanged by the
|
|
10
|
+
split; ``graph_rag_search`` and the mode constants keep their import paths.
|
|
11
|
+
|
|
12
|
+
Two retrieval paths share ``GraphContext`` assembly + snippet reuse:
|
|
13
|
+
|
|
14
|
+
* **Local** (spec §6a; wave G2-d): resolve a free-text query to one or more seed
|
|
15
|
+
``Entity`` vertices in the relational catalog, run a bounded ``CO_OCCURS``
|
|
16
|
+
traversal from each seed via the injected
|
|
17
|
+
:class:`~brain.graph_rag.backends.base.GraphBackend`, map the reached entities
|
|
18
|
+
back to documents through the tenant-scoped ``graph_entity_mentions`` mirror,
|
|
19
|
+
rank those documents deterministically, and attach a best snippet per document
|
|
20
|
+
by reusing the existing FTS snippet path (:mod:`brain.search`).
|
|
21
|
+
* **Themes with X** (spec §6b; wave G2-f — the HEADLINE): implemented in
|
|
22
|
+
:mod:`brain.graph_rag.themes` and dispatched here. It scopes to a person ``X``
|
|
23
|
+
via :meth:`~brain.graph_rag.backends.base.GraphBackend.scope_person`, computes
|
|
24
|
+
the **in-scope normalized lift**, suppresses generic entities, excludes the
|
|
25
|
+
seed X + the owner, groups the scoped subgraph
|
|
26
|
+
(:func:`brain.graph_rag.grouping.group_themes`), and returns ranked
|
|
27
|
+
:class:`~brain.graph_rag.schema.ThemeGroup`s.
|
|
28
|
+
|
|
29
|
+
Scope (spec §12, waves G2-d/G2-f/G2-g/G3-e/G4-c): the LOCAL path + the
|
|
30
|
+
**auto-router** (wave G2-g; the pure :func:`brain.graph_rag.router.route`) live
|
|
31
|
+
here; ``route`` dispatches ``mode='auto'`` to local, themes, or — after the
|
|
32
|
+
**G3-e flip** (spec §17c Q6) — **global**: a thematic query with no resolvable
|
|
33
|
+
person now routes to the real community path
|
|
34
|
+
(:func:`brain.graph_rag.global_._retrieve_global`), not the former G2
|
|
35
|
+
``global→local`` degradation. An **explicit** ``mode='global'`` dispatches to
|
|
36
|
+
that same global path. An **explicit** ``mode='fuse'`` (wave G4-c; spec §17d Q1)
|
|
37
|
+
dispatches to :func:`brain.graph_rag.fuse._retrieve_fuse`, which RRF-merges the
|
|
38
|
+
local doc leg with the vector/FTS hybrid leg; ``fuse`` is explicit-only — the
|
|
39
|
+
auto router never targets it. The G2 degradation signals (``requested_mode`` /
|
|
40
|
+
``degraded_from`` / ``degradation_reason`` on the :class:`GraphContext`) are
|
|
41
|
+
KEEP-DORMANT — defined but never populated.
|
|
42
|
+
|
|
43
|
+
Design invariants (mirroring the rest of the GraphRAG layer):
|
|
44
|
+
|
|
45
|
+
* **Tenant-scoped (spec §4 D9).** ``tenant_id`` is resolved once via
|
|
46
|
+
:func:`brain.graph_rag.tenancy.resolve_tenant` and injected into every query:
|
|
47
|
+
seed resolution, the backend traversal, and the mention→document mapping all
|
|
48
|
+
filter by it, so a second tenant's entities/docs can never leak into a result.
|
|
49
|
+
(``documents`` is not tenantized in G0, but the doc id set is derived solely
|
|
50
|
+
from tenant-scoped ``graph_entity_mentions`` rows, so it is inherently scoped.)
|
|
51
|
+
* **Caps injected (spec §6/§10).** ``depth`` / ``frontier_cap`` /
|
|
52
|
+
``min_edge_weight`` default from :class:`~brain.config.Config`
|
|
53
|
+
(``graph_depth`` / ``graph_frontier_cap`` / ``graph_min_edge_weight``) and are
|
|
54
|
+
overridable per call.
|
|
55
|
+
* **Deterministic ordering.** Seed resolution, reached-entity merge, document
|
|
56
|
+
ranking, and snippet selection all break ties on a stable key so repeated runs
|
|
57
|
+
return byte-identical results.
|
|
58
|
+
* **Never-raise on empty.** An unresolvable query (no matching seed entity)
|
|
59
|
+
returns an empty-but-valid ``GraphContext`` rather than raising. A backend
|
|
60
|
+
cap-exceed (:class:`~brain.errors.GraphBackendError`) is *not* swallowed — the
|
|
61
|
+
backend's complete-or-loud-failure contract is respected (spec §6a).
|
|
62
|
+
"""
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import uuid
|
|
66
|
+
from typing import TYPE_CHECKING, Any
|
|
67
|
+
|
|
68
|
+
import psycopg
|
|
69
|
+
|
|
70
|
+
from ._retrieval_common import _build_doc_results, _fetch_entities, _row_to_entity
|
|
71
|
+
from .fuse import _retrieve_fuse
|
|
72
|
+
from .global_ import _retrieve_global
|
|
73
|
+
from .router import (
|
|
74
|
+
AUTO_MODE,
|
|
75
|
+
FUSE_MODE,
|
|
76
|
+
GLOBAL_MODE,
|
|
77
|
+
LOCAL_MODE,
|
|
78
|
+
THEMES_MODE,
|
|
79
|
+
KnownPerson,
|
|
80
|
+
route,
|
|
81
|
+
)
|
|
82
|
+
from .schema import GraphContext, GraphEntity, GraphExplanation
|
|
83
|
+
from .tenancy import resolve_tenant
|
|
84
|
+
from .themes import _GroupSummarizer, _retrieve_themes
|
|
85
|
+
|
|
86
|
+
if TYPE_CHECKING:
|
|
87
|
+
from ..config import Config
|
|
88
|
+
from ..ingest import Embedder
|
|
89
|
+
from .backends.base import GraphBackend
|
|
90
|
+
|
|
91
|
+
# ``LOCAL_MODE`` / ``THEMES_MODE`` (and ``AUTO_MODE`` / ``GLOBAL_MODE``) are
|
|
92
|
+
# defined in :mod:`brain.graph_rag.router` (the mode vocabulary's canonical home)
|
|
93
|
+
# and re-exported here so existing ``from brain.graph_rag import LOCAL_MODE``
|
|
94
|
+
# imports keep working.
|
|
95
|
+
__all__ = [
|
|
96
|
+
"AUTO_MODE",
|
|
97
|
+
"FUSE_MODE",
|
|
98
|
+
"GLOBAL_MODE",
|
|
99
|
+
"LOCAL_MODE",
|
|
100
|
+
"THEMES_MODE",
|
|
101
|
+
"graph_rag_search",
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
# Upper bound on seed entities resolved from one query. Exact matches are
|
|
105
|
+
# naturally few; a substring query (e.g. a short prefix) can match many, so the
|
|
106
|
+
# winning tier is capped — by rank — to keep the per-seed traversal fan-out
|
|
107
|
+
# bounded. Deterministic: the cap slices an already-ordered candidate list.
|
|
108
|
+
_MAX_SEED_ENTITIES = 10
|
|
109
|
+
|
|
110
|
+
# Default number of ranked documents returned when the caller passes no ``limit``.
|
|
111
|
+
_DEFAULT_DOC_LIMIT = 10
|
|
112
|
+
|
|
113
|
+
# Document-ranking weight assigned to a *seed* entity's mentions. Seeds are the
|
|
114
|
+
# query's direct subject, so a document mentioning a seed always outranks one
|
|
115
|
+
# reached only through a co-occurrence neighbour (whose weight is its path
|
|
116
|
+
# affinity ∈ (0, 1]).
|
|
117
|
+
_SEED_WEIGHT = 1.0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def graph_rag_search(
|
|
121
|
+
conn: psycopg.Connection[Any],
|
|
122
|
+
cfg: Config,
|
|
123
|
+
query: str,
|
|
124
|
+
*,
|
|
125
|
+
backend: GraphBackend,
|
|
126
|
+
tenant: str | None = None,
|
|
127
|
+
depth: int | None = None,
|
|
128
|
+
frontier_cap: int | None = None,
|
|
129
|
+
min_edge_weight: float | None = None,
|
|
130
|
+
limit: int | None = None,
|
|
131
|
+
mode: str = LOCAL_MODE,
|
|
132
|
+
session_id: str | None = None,
|
|
133
|
+
person: str | None = None,
|
|
134
|
+
theme_limit: int | None = None,
|
|
135
|
+
synthesize: bool = False,
|
|
136
|
+
enricher: _GroupSummarizer | None = None,
|
|
137
|
+
embedder: Embedder | None = None,
|
|
138
|
+
) -> GraphContext:
|
|
139
|
+
"""Graph retrieval (spec §6; ``local`` G2-d, ``themes`` G2-f, router G2-g).
|
|
140
|
+
|
|
141
|
+
``mode='auto'`` (the router default for the CLI/MCP surfaces) runs the pure
|
|
142
|
+
heuristic :func:`brain.graph_rag.router.route`: a **thematic** query (closed
|
|
143
|
+
regex grammar) with a resolvable person (explicit ``person`` first, else a
|
|
144
|
+
token-boundary scan of the tenant's known person entities) dispatches to
|
|
145
|
+
``themes``; a thematic query with **no** resolvable person dispatches to the
|
|
146
|
+
real ``global`` community path (the **G3-e flip**, spec §17c Q6 — no longer
|
|
147
|
+
the G2 ``global→local`` degradation, so no degradation signals are stamped);
|
|
148
|
+
a non-thematic query dispatches to ``local``.
|
|
149
|
+
|
|
150
|
+
``mode='global'`` (explicit) and the auto thematic-no-person branch both
|
|
151
|
+
dispatch to :func:`brain.graph_rag.global_._retrieve_global` (wave G3-d/G3-e;
|
|
152
|
+
spec §6c) via one shared call site: it ranks the tenant's pre-detected
|
|
153
|
+
**communities** (``graph_communities``) via RRF over an FTS leg
|
|
154
|
+
(``summary_tsv``) fused with a vector leg (``summary_embedding``), and returns
|
|
155
|
+
a :class:`GraphContext` with ranked ``communities`` (each a
|
|
156
|
+
:class:`CommunityGroup`) + representative ``docs``. The community count
|
|
157
|
+
defaults from ``cfg.graph_community_limit``. The vector leg needs an embedder:
|
|
158
|
+
the caller passes a PRE-WARMED ``embedder`` instance (perf-T4 G5 — the only
|
|
159
|
+
path that uses it; local/themes stay embedder-free) used to embed the query;
|
|
160
|
+
a missing/failed embed logs a WARN and runs FTS-only (never-raise; spec §17c
|
|
161
|
+
Q9).
|
|
162
|
+
|
|
163
|
+
``mode='fuse'`` (explicit only — never an auto target; wave G4-c, spec §17d
|
|
164
|
+
Q1) dispatches to :func:`brain.graph_rag.fuse._retrieve_fuse`: it runs the
|
|
165
|
+
``local`` doc leg + the vector/FTS :func:`brain.search.hybrid_search` leg and
|
|
166
|
+
RRF-merges their **document-id rankings** (``k=60``, the same constant as the
|
|
167
|
+
other rankers), returning a :class:`GraphContext` (``mode='fuse'``) whose
|
|
168
|
+
``docs`` are the fused :class:`~brain.search.SearchResult`s (wire-stable) with
|
|
169
|
+
per-doc leg provenance in ``explanation.matched_filters['fuse_doc_provenance']``.
|
|
170
|
+
The pre-warmed ``embedder`` instance feeds the hybrid leg's vector arm; a
|
|
171
|
+
missing/failed embedder degrades it to FTS-only and a fully-dead hybrid leg
|
|
172
|
+
degrades fuse to graph-only (never-raise). Hybrid search's ranking is
|
|
173
|
+
consumed unchanged (spec §4 D7).
|
|
174
|
+
|
|
175
|
+
``mode='local'`` resolves ``query`` to seed entities, traverses
|
|
176
|
+
``CO_OCCURS`` from each seed via ``backend``, maps the reached entities to
|
|
177
|
+
documents, and returns a :class:`GraphContext` with the seed + reached
|
|
178
|
+
``entities`` and ranked ``docs`` (each a :class:`brain.search.SearchResult`
|
|
179
|
+
with a best snippet).
|
|
180
|
+
|
|
181
|
+
``mode='themes'`` is the scope-first "themes in my conversations with X"
|
|
182
|
+
headline (spec §6b; implemented in :mod:`brain.graph_rag.themes`): it
|
|
183
|
+
**requires** ``person`` (the ``X`` to scope to), scopes via
|
|
184
|
+
:meth:`GraphBackend.scope_person`, computes the in-scope normalized lift,
|
|
185
|
+
suppresses generic entities, **excludes X and the owner**, groups the scoped
|
|
186
|
+
subgraph, and returns ranked ``themes`` (each a :class:`ThemeGroup` with
|
|
187
|
+
representative X-docs + snippets). ``theme_limit`` defaults from
|
|
188
|
+
``cfg.graph_theme_limit``. ``synthesize=True`` attaches a best-effort
|
|
189
|
+
per-group Ollama summary via the injected ``enricher`` (default OFF; never
|
|
190
|
+
required for retrieval — a missing/failed Ollama yields ``summary=None`` + a
|
|
191
|
+
WARN, spec §17b decision 7).
|
|
192
|
+
|
|
193
|
+
Caps (``depth`` / ``frontier_cap`` / ``min_edge_weight``) default from
|
|
194
|
+
``cfg`` and are overridable; ``limit`` caps the returned documents
|
|
195
|
+
(default :data:`_DEFAULT_DOC_LIMIT`). ``tenant`` overrides the configured
|
|
196
|
+
tenant (resolved via :func:`brain.graph_rag.tenancy.resolve_tenant`).
|
|
197
|
+
``session_id`` is generated when omitted (a fresh ``uuid4`` hex), mirroring
|
|
198
|
+
the MCP ``{session_id, results}`` envelope.
|
|
199
|
+
|
|
200
|
+
An unresolvable query / empty scope returns an empty-but-valid
|
|
201
|
+
``GraphContext`` (never raises). A traversal/scope that exceeds the backend's
|
|
202
|
+
safe bound surfaces as :class:`~brain.errors.GraphBackendError`
|
|
203
|
+
(complete-or-loud-failure, not silent truncation).
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
ValueError: ``mode='themes'`` without a resolvable ``person``, or an
|
|
207
|
+
unrecognized ``mode`` (caller bug, surfaced by the router).
|
|
208
|
+
PersonNotFound / PersonAmbiguous: ``person`` does not resolve to exactly
|
|
209
|
+
one directory person (themes mode; surfaced for the CLI/MCP to map).
|
|
210
|
+
GraphTenantError: the effective tenant id resolves empty (caller bug).
|
|
211
|
+
"""
|
|
212
|
+
tenant_id = resolve_tenant(cfg, tenant)
|
|
213
|
+
resolved_depth = cfg.graph_depth if depth is None else depth
|
|
214
|
+
resolved_frontier = cfg.graph_frontier_cap if frontier_cap is None else frontier_cap
|
|
215
|
+
resolved_min_weight = (
|
|
216
|
+
cfg.graph_min_edge_weight if min_edge_weight is None else min_edge_weight
|
|
217
|
+
)
|
|
218
|
+
resolved_limit = _DEFAULT_DOC_LIMIT if limit is None else limit
|
|
219
|
+
resolved_session = uuid.uuid4().hex if session_id is None else session_id
|
|
220
|
+
|
|
221
|
+
# Route. ``known_persons`` (the token-boundary-scan candidates) are only
|
|
222
|
+
# needed for the auto path; explicit modes ignore them, so skip the DB read.
|
|
223
|
+
# The router honors explicit modes (incl. ``global`` after the G3-e flip) and
|
|
224
|
+
# decides the auto branches purely; the DB-derived person list is injected so
|
|
225
|
+
# the router itself stays DB-free (dependency inversion).
|
|
226
|
+
known_persons = (
|
|
227
|
+
_fetch_known_persons(conn, tenant_id) if mode == AUTO_MODE else []
|
|
228
|
+
)
|
|
229
|
+
decision = route(query, mode=mode, person=person, known_persons=known_persons)
|
|
230
|
+
|
|
231
|
+
# Global (community-based) path (spec §6c; waves G3-d/G3-e). An EXPLICIT
|
|
232
|
+
# ``mode='global'`` AND the auto thematic-no-person branch both resolve to
|
|
233
|
+
# GLOBAL_MODE and share this single dispatch to :func:`_retrieve_global` —
|
|
234
|
+
# the only path that uses ``embedder`` (spec §17c Q9; local/themes stay
|
|
235
|
+
# embedder-free). The community limit defaults from
|
|
236
|
+
# ``cfg.graph_community_limit`` (resolved inside ``_retrieve_global``), not
|
|
237
|
+
# the doc ``limit``. The G3-e flip routes BOTH global cases here; the G2
|
|
238
|
+
# ``global→local`` degradation is gone (spec §17c Q6).
|
|
239
|
+
if decision.executed_mode == GLOBAL_MODE:
|
|
240
|
+
return _retrieve_global(
|
|
241
|
+
conn,
|
|
242
|
+
cfg,
|
|
243
|
+
query,
|
|
244
|
+
tenant=tenant_id,
|
|
245
|
+
limit=limit,
|
|
246
|
+
embedder=embedder,
|
|
247
|
+
session_id=resolved_session,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Fuse (graph ⊕ hybrid doc-leg RRF) path (spec §17d Q1; wave G4-c). Honored
|
|
251
|
+
# ONLY for an EXPLICIT ``mode='fuse'`` — the auto router never targets it
|
|
252
|
+
# (auto stays local/themes/global). Runs the local doc leg + the hybrid leg
|
|
253
|
+
# and RRF-merges their doc-id rankings; the pre-warmed ``embedder`` feeds the
|
|
254
|
+
# hybrid leg's vector arm (a missing/failed embedder degrades it to FTS-only
|
|
255
|
+
# — never-raise). Local/themes/global/auto are unchanged.
|
|
256
|
+
if decision.executed_mode == FUSE_MODE:
|
|
257
|
+
return _retrieve_fuse(
|
|
258
|
+
conn,
|
|
259
|
+
cfg,
|
|
260
|
+
query,
|
|
261
|
+
backend=backend,
|
|
262
|
+
tenant=tenant_id,
|
|
263
|
+
depth=resolved_depth,
|
|
264
|
+
frontier_cap=resolved_frontier,
|
|
265
|
+
min_edge_weight=resolved_min_weight,
|
|
266
|
+
limit=resolved_limit,
|
|
267
|
+
embedder=embedder,
|
|
268
|
+
session_id=resolved_session,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
if decision.executed_mode == THEMES_MODE:
|
|
272
|
+
resolved_person = (
|
|
273
|
+
decision.resolved_person.display_name
|
|
274
|
+
if decision.resolved_person is not None
|
|
275
|
+
else None
|
|
276
|
+
)
|
|
277
|
+
if resolved_person is None or not resolved_person.strip():
|
|
278
|
+
raise ValueError(
|
|
279
|
+
"mode='themes' requires a non-empty 'person' (the X to scope to)"
|
|
280
|
+
)
|
|
281
|
+
resolved_theme_limit = (
|
|
282
|
+
cfg.graph_theme_limit if theme_limit is None else theme_limit
|
|
283
|
+
)
|
|
284
|
+
return _retrieve_themes(
|
|
285
|
+
conn,
|
|
286
|
+
person=resolved_person,
|
|
287
|
+
query=query,
|
|
288
|
+
backend=backend,
|
|
289
|
+
tenant_id=tenant_id,
|
|
290
|
+
frontier_cap=resolved_frontier,
|
|
291
|
+
min_edge_weight=resolved_min_weight,
|
|
292
|
+
generic_df_ratio=cfg.graph_generic_df_ratio,
|
|
293
|
+
owner_keys=cfg.owner_participants,
|
|
294
|
+
theme_limit=resolved_theme_limit,
|
|
295
|
+
limit=resolved_limit,
|
|
296
|
+
session_id=resolved_session,
|
|
297
|
+
synthesize=synthesize,
|
|
298
|
+
enricher=enricher,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Local path (explicit ``local`` OR the auto non-thematic branch). After the
|
|
302
|
+
# G3-e flip the thematic-no-person case routes to ``global`` above, so no
|
|
303
|
+
# path reaching here ever degrades — the G2 degradation signals stay dormant
|
|
304
|
+
# (``None``) on the returned context (spec §17c Q6).
|
|
305
|
+
return _retrieve_local(
|
|
306
|
+
conn,
|
|
307
|
+
query,
|
|
308
|
+
backend=backend,
|
|
309
|
+
tenant_id=tenant_id,
|
|
310
|
+
depth=resolved_depth,
|
|
311
|
+
frontier_cap=resolved_frontier,
|
|
312
|
+
min_edge_weight=resolved_min_weight,
|
|
313
|
+
limit=resolved_limit,
|
|
314
|
+
session_id=resolved_session,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# --------------------------------------------------------------------------- #
|
|
319
|
+
# Local-path orchestration (the single seam G2-f/G2-g compose over)
|
|
320
|
+
# --------------------------------------------------------------------------- #
|
|
321
|
+
def _retrieve_local(
|
|
322
|
+
conn: psycopg.Connection[Any],
|
|
323
|
+
query: str,
|
|
324
|
+
*,
|
|
325
|
+
backend: GraphBackend,
|
|
326
|
+
tenant_id: str,
|
|
327
|
+
depth: int,
|
|
328
|
+
frontier_cap: int,
|
|
329
|
+
min_edge_weight: float,
|
|
330
|
+
limit: int,
|
|
331
|
+
session_id: str,
|
|
332
|
+
) -> GraphContext:
|
|
333
|
+
"""Run the resolved local retrieval and assemble its ``GraphContext``.
|
|
334
|
+
|
|
335
|
+
Single code path for the populated and the empty (no-seed) cases: when no
|
|
336
|
+
seed resolves, the traversal loop and every downstream query are skipped on
|
|
337
|
+
empty inputs, yielding an empty-but-valid context (never-raise).
|
|
338
|
+
"""
|
|
339
|
+
seeds = _resolve_seeds(conn, tenant_id, query)
|
|
340
|
+
seed_ids = {seed.id for seed in seeds}
|
|
341
|
+
|
|
342
|
+
# Traverse from each seed; keep the best (highest-affinity) path per reached
|
|
343
|
+
# entity across all seeds. The seed set itself is excluded (a seed reached
|
|
344
|
+
# from another seed stays a seed, never a neighbour).
|
|
345
|
+
reached: dict[str, float] = {}
|
|
346
|
+
for seed in seeds:
|
|
347
|
+
hits = backend.traverse(
|
|
348
|
+
conn,
|
|
349
|
+
tenant_id,
|
|
350
|
+
seed.id,
|
|
351
|
+
depth=depth,
|
|
352
|
+
frontier_cap=frontier_cap,
|
|
353
|
+
min_edge_weight=min_edge_weight,
|
|
354
|
+
)
|
|
355
|
+
for hit in hits:
|
|
356
|
+
if hit.entity_uuid in seed_ids:
|
|
357
|
+
continue
|
|
358
|
+
prior = reached.get(hit.entity_uuid)
|
|
359
|
+
if prior is None or hit.affinity > prior:
|
|
360
|
+
reached[hit.entity_uuid] = hit.affinity
|
|
361
|
+
|
|
362
|
+
reached_entities = _fetch_entities(conn, tenant_id, list(reached))
|
|
363
|
+
ordered_reached = sorted(
|
|
364
|
+
reached_entities,
|
|
365
|
+
key=lambda e: (-reached[e.id], e.canonical_key, e.id),
|
|
366
|
+
)
|
|
367
|
+
entities = [*seeds, *ordered_reached]
|
|
368
|
+
|
|
369
|
+
# Document scoring: a doc accumulates the weight of every seed (1.0) /
|
|
370
|
+
# reached (path affinity) entity it mentions, so docs co-mentioning multiple
|
|
371
|
+
# relevant entities rank highest. Ties break on document_id for determinism.
|
|
372
|
+
weights: dict[str, float] = {seed.id: _SEED_WEIGHT for seed in seeds}
|
|
373
|
+
for entity_uuid, affinity in reached.items():
|
|
374
|
+
weights.setdefault(entity_uuid, affinity)
|
|
375
|
+
ranked = _rank_documents(conn, tenant_id, weights, limit)
|
|
376
|
+
docs = _build_doc_results(conn, query, ranked)
|
|
377
|
+
|
|
378
|
+
explanation = GraphExplanation(
|
|
379
|
+
mode=LOCAL_MODE,
|
|
380
|
+
tenant_id=tenant_id,
|
|
381
|
+
seed_entity_ids=[seed.id for seed in seeds],
|
|
382
|
+
person_keys=[],
|
|
383
|
+
depth=depth,
|
|
384
|
+
frontier_cap=frontier_cap,
|
|
385
|
+
min_edge_weight=min_edge_weight,
|
|
386
|
+
nodes_visited=len(seeds) + len(reached),
|
|
387
|
+
# The AGE backend's TraversalHit surfaces reached entities + path affinity
|
|
388
|
+
# but not a per-edge visit count, so edges_considered stays 0 in G2-d.
|
|
389
|
+
edges_considered=0,
|
|
390
|
+
generic_df_cap=None,
|
|
391
|
+
matched_filters={
|
|
392
|
+
"seed_query": query,
|
|
393
|
+
"seed_count": len(seeds),
|
|
394
|
+
"reached_count": len(reached),
|
|
395
|
+
"depth": depth,
|
|
396
|
+
"frontier_cap": frontier_cap,
|
|
397
|
+
"min_edge_weight": min_edge_weight,
|
|
398
|
+
"limit": limit,
|
|
399
|
+
},
|
|
400
|
+
)
|
|
401
|
+
return GraphContext(
|
|
402
|
+
session_id=session_id,
|
|
403
|
+
mode=LOCAL_MODE,
|
|
404
|
+
query=query,
|
|
405
|
+
tenant_id=tenant_id,
|
|
406
|
+
person=None,
|
|
407
|
+
themes=[],
|
|
408
|
+
entities=entities,
|
|
409
|
+
docs=docs,
|
|
410
|
+
explanation=explanation,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# --------------------------------------------------------------------------- #
|
|
415
|
+
# Seed resolution (relational catalog; tenant-scoped; deterministic)
|
|
416
|
+
# --------------------------------------------------------------------------- #
|
|
417
|
+
def _resolve_seeds(
|
|
418
|
+
conn: psycopg.Connection[Any], tenant_id: str, query: str
|
|
419
|
+
) -> list[GraphEntity]:
|
|
420
|
+
"""Resolve a query to seed ``graph_entities`` rows (spec §6a seed step).
|
|
421
|
+
|
|
422
|
+
Tenant-scoped. Exact ``canonical_key``/``name`` matches (case-insensitive)
|
|
423
|
+
win; only when none exist does it fall back to a case-insensitive substring
|
|
424
|
+
match. Either tier is ordered by ``doc_count`` DESC then ``canonical_key`` /
|
|
425
|
+
``entity_type`` ASC and capped at :data:`_MAX_SEED_ENTITIES`, so the seed set
|
|
426
|
+
is deterministic. An empty / whitespace-only query resolves to no seeds.
|
|
427
|
+
|
|
428
|
+
Vector pre-match (spec §6a "or vector pre-match") is intentionally NOT used
|
|
429
|
+
in G2-d — exact+substring covers entity-name queries and keeps the path
|
|
430
|
+
free of an embedder dependency (YAGNI; can be added later behind the same
|
|
431
|
+
function).
|
|
432
|
+
"""
|
|
433
|
+
needle = query.strip().lower()
|
|
434
|
+
if not needle:
|
|
435
|
+
return []
|
|
436
|
+
|
|
437
|
+
columns = "id::text, entity_type, name, canonical_key, description, doc_count"
|
|
438
|
+
exact_rows = conn.execute(
|
|
439
|
+
f"SELECT {columns} FROM graph_entities "
|
|
440
|
+
"WHERE tenant_id = %s AND (lower(canonical_key) = %s OR lower(name) = %s) "
|
|
441
|
+
"ORDER BY doc_count DESC, canonical_key ASC, entity_type ASC",
|
|
442
|
+
(tenant_id, needle, needle),
|
|
443
|
+
).fetchall()
|
|
444
|
+
rows = exact_rows
|
|
445
|
+
if not rows:
|
|
446
|
+
pattern = _like_contains(needle)
|
|
447
|
+
rows = conn.execute(
|
|
448
|
+
f"SELECT {columns} FROM graph_entities "
|
|
449
|
+
"WHERE tenant_id = %s "
|
|
450
|
+
"AND (lower(name) LIKE %s OR lower(canonical_key) LIKE %s) "
|
|
451
|
+
"ORDER BY doc_count DESC, canonical_key ASC, entity_type ASC",
|
|
452
|
+
(tenant_id, pattern, pattern),
|
|
453
|
+
).fetchall()
|
|
454
|
+
return [_row_to_entity(row, tenant_id) for row in rows[:_MAX_SEED_ENTITIES]]
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _like_contains(needle: str) -> str:
|
|
458
|
+
"""Build a case-insensitive ``LIKE`` contains-pattern, escaping wildcards.
|
|
459
|
+
|
|
460
|
+
Postgres ``LIKE`` defaults to backslash as the escape character, so a
|
|
461
|
+
literal ``%`` / ``_`` / ``\\`` in the (already lower-cased) needle is
|
|
462
|
+
backslash-escaped before being wrapped in ``%...%``.
|
|
463
|
+
"""
|
|
464
|
+
escaped = needle.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
465
|
+
return f"%{escaped}%"
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _fetch_known_persons(
|
|
469
|
+
conn: psycopg.Connection[Any], tenant_id: str
|
|
470
|
+
) -> list[KnownPerson]:
|
|
471
|
+
"""Fetch tenant-scoped known person entities for the router's name scan.
|
|
472
|
+
|
|
473
|
+
Returns every ``person`` ``graph_entities`` row's
|
|
474
|
+
``(canonical_key, display_name, doc_count)`` so the pure
|
|
475
|
+
:func:`brain.graph_rag.router.route` can token-boundary-scan the query
|
|
476
|
+
without any DB access (dependency inversion — the DB-derived candidate list
|
|
477
|
+
is injected). Ordered by ``canonical_key`` for a reproducible input; the
|
|
478
|
+
router's tie-break is order-independent, but a stable order keeps the call
|
|
479
|
+
deterministic regardless of physical row order.
|
|
480
|
+
"""
|
|
481
|
+
rows = conn.execute(
|
|
482
|
+
"SELECT canonical_key, name, doc_count FROM graph_entities "
|
|
483
|
+
"WHERE tenant_id = %s AND entity_type = 'person' "
|
|
484
|
+
"ORDER BY canonical_key",
|
|
485
|
+
(tenant_id,),
|
|
486
|
+
).fetchall()
|
|
487
|
+
return [
|
|
488
|
+
KnownPerson(
|
|
489
|
+
canonical_key=str(row[0]),
|
|
490
|
+
display_name=str(row[1]),
|
|
491
|
+
doc_count=int(row[2]),
|
|
492
|
+
)
|
|
493
|
+
for row in rows
|
|
494
|
+
]
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# --------------------------------------------------------------------------- #
|
|
498
|
+
# Reached entities → ranked documents (tenant-scoped mention mirror)
|
|
499
|
+
# --------------------------------------------------------------------------- #
|
|
500
|
+
def _rank_documents(
|
|
501
|
+
conn: psycopg.Connection[Any],
|
|
502
|
+
tenant_id: str,
|
|
503
|
+
weights: dict[str, float],
|
|
504
|
+
limit: int,
|
|
505
|
+
) -> list[tuple[str, float]]:
|
|
506
|
+
"""Map weighted entities to documents and rank them (spec §6a doc step).
|
|
507
|
+
|
|
508
|
+
Reads the tenant-scoped ``graph_entity_mentions`` mirror for the seed +
|
|
509
|
+
reached entity ids and sums each document's entity weights. Returns the top
|
|
510
|
+
``limit`` ``(document_id, score)`` pairs ordered by score DESC, document_id
|
|
511
|
+
ASC (deterministic). An empty entity/weight set returns no documents.
|
|
512
|
+
"""
|
|
513
|
+
if not weights or limit <= 0:
|
|
514
|
+
return []
|
|
515
|
+
rows = conn.execute(
|
|
516
|
+
"SELECT entity_id::text, document_id::text FROM graph_entity_mentions "
|
|
517
|
+
"WHERE tenant_id = %s AND entity_id = ANY(%s)",
|
|
518
|
+
(tenant_id, list(weights)),
|
|
519
|
+
).fetchall()
|
|
520
|
+
doc_scores: dict[str, float] = {}
|
|
521
|
+
for entity_id, document_id in rows:
|
|
522
|
+
doc_scores[str(document_id)] = doc_scores.get(str(document_id), 0.0) + weights[
|
|
523
|
+
str(entity_id)
|
|
524
|
+
]
|
|
525
|
+
ranked = sorted(doc_scores.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
526
|
+
return ranked[:limit]
|