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,501 @@
|
|
|
1
|
+
"""Scope-first "themes with X" graph retrieval (spec §6b — the HEADLINE feature).
|
|
2
|
+
|
|
3
|
+
Extracted from :mod:`brain.graph_rag.retrieve` (the G2 wave-boundary file-size
|
|
4
|
+
split, mirroring the G1 :mod:`brain.graph_rag.aggregates` / G2-c
|
|
5
|
+
:mod:`brain.graph_rag.relational` extractions). The public dispatch entry
|
|
6
|
+
:func:`brain.graph_rag.retrieve.graph_rag_search` calls :func:`_retrieve_themes`
|
|
7
|
+
here when the router resolves ``mode='themes'``; the shared entity-row +
|
|
8
|
+
doc-result helpers live in :mod:`brain.graph_rag._retrieval_common`. This is a
|
|
9
|
+
pure move — no behavior change.
|
|
10
|
+
|
|
11
|
+
The algorithm (spec §6b): scope to a person ``X`` via
|
|
12
|
+
:meth:`~brain.graph_rag.backends.base.GraphBackend.scope_person`, compute the
|
|
13
|
+
**in-scope normalized lift** over ``graph_edge_contributions`` restricted to X's
|
|
14
|
+
documents, suppress entities that are **generic across the whole tenant** (the
|
|
15
|
+
corpus-wide df vs the corpus cap — matching derive-time suppression in
|
|
16
|
+
:func:`brain.graph_rag.aggregates._recompute_aggregates`), **exclude the seed X
|
|
17
|
+
and the owner**, group the scoped subgraph into ranked
|
|
18
|
+
:class:`~brain.graph_rag.schema.ThemeGroup`s
|
|
19
|
+
(:func:`brain.graph_rag.grouping.group_themes`), populate each group with its
|
|
20
|
+
representative X-documents + snippets, and (opt-in ``synthesize``) attach a
|
|
21
|
+
best-effort Ollama group summary. Cypher provides the scoping; SQL provides the
|
|
22
|
+
weighting/evidence; Python shapes the ``GraphContext`` (spec §6b closing note).
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from dataclasses import replace
|
|
28
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
29
|
+
|
|
30
|
+
import psycopg
|
|
31
|
+
|
|
32
|
+
from ._retrieval_common import _build_doc_results, _fetch_entities
|
|
33
|
+
from .grouping import group_themes
|
|
34
|
+
from .router import THEMES_MODE
|
|
35
|
+
from .schema import Edge, GraphContext, GraphEntity, GraphExplanation, ThemeGroup
|
|
36
|
+
from .weighting import generic_df_cap, is_generic_entity, normalized_lift
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from ..search import SearchResult
|
|
40
|
+
from .backends.base import GraphBackend
|
|
41
|
+
|
|
42
|
+
_logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _GroupSummarizer(Protocol):
|
|
46
|
+
"""Structural type for the optional ``--synthesize`` enricher (DI seam).
|
|
47
|
+
|
|
48
|
+
The themes path depends only on this one method, so production injects a
|
|
49
|
+
:class:`brain.enrichment.OllamaEnricher` and tests inject a fake — neither is
|
|
50
|
+
imported here. ``summarize_group`` is best-effort and **never raises**
|
|
51
|
+
(returns ``None`` + a WARN on Ollama failure), so the themes retrieval never
|
|
52
|
+
becomes a hard live-Ollama dependency (spec §17b decision 7).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def summarize_group(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
person: str | None,
|
|
59
|
+
entity_names: list[str],
|
|
60
|
+
doc_titles: list[str],
|
|
61
|
+
) -> str | None: ...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --------------------------------------------------------------------------- #
|
|
65
|
+
# Themes-with-X orchestration (spec §6b — the HEADLINE feature)
|
|
66
|
+
# --------------------------------------------------------------------------- #
|
|
67
|
+
def _retrieve_themes(
|
|
68
|
+
conn: psycopg.Connection[Any],
|
|
69
|
+
*,
|
|
70
|
+
person: str,
|
|
71
|
+
query: str,
|
|
72
|
+
backend: GraphBackend,
|
|
73
|
+
tenant_id: str,
|
|
74
|
+
frontier_cap: int,
|
|
75
|
+
min_edge_weight: float,
|
|
76
|
+
generic_df_ratio: float,
|
|
77
|
+
owner_keys: frozenset[str],
|
|
78
|
+
theme_limit: int,
|
|
79
|
+
limit: int,
|
|
80
|
+
session_id: str,
|
|
81
|
+
synthesize: bool,
|
|
82
|
+
enricher: _GroupSummarizer | None,
|
|
83
|
+
) -> GraphContext:
|
|
84
|
+
"""Run scope-first "themes with X" retrieval + assemble its ``GraphContext``.
|
|
85
|
+
|
|
86
|
+
Scope X (:meth:`GraphBackend.scope_person`), compute the in-scope normalized
|
|
87
|
+
lift over ``graph_edge_contributions`` restricted to X's documents, suppress
|
|
88
|
+
generic entities, exclude X + owner, group the scoped subgraph, populate each
|
|
89
|
+
group's representative X-docs + snippets, and optionally synthesize a summary.
|
|
90
|
+
|
|
91
|
+
Empty at any stage (no person vertex in the graph, an empty scope, no
|
|
92
|
+
eligible entities) flows through to an empty-but-valid context — never-raise,
|
|
93
|
+
mirroring the local no-seed path. ``PersonNotFound`` / ``PersonAmbiguous``
|
|
94
|
+
from the resolver are *not* swallowed (the CLI/MCP map them in G2-h/i).
|
|
95
|
+
"""
|
|
96
|
+
# Late import: brain.queries → wiki.build_people pulls a heavier subtree;
|
|
97
|
+
# resolving lazily keeps brain.graph_rag import-cheap (mirrors the
|
|
98
|
+
# brain.search late import in _build_doc_results).
|
|
99
|
+
from ..queries import resolve_person_to_keys
|
|
100
|
+
|
|
101
|
+
# 1. Resolve the person string → canonical participant keys (deterministic;
|
|
102
|
+
# raises PersonNotFound / PersonAmbiguous for the CLI/MCP to map).
|
|
103
|
+
match = resolve_person_to_keys(conn, person)
|
|
104
|
+
person_display = match.display_name
|
|
105
|
+
|
|
106
|
+
# 2. Map the resolved person → seed person ENTITY ids, and the owner → its
|
|
107
|
+
# entity ids; both sets are excluded from the theme-eligible set (§17.5).
|
|
108
|
+
seed_entity_ids = _person_entity_ids(conn, tenant_id, match.keys)
|
|
109
|
+
owner_entity_ids = _person_entity_ids(
|
|
110
|
+
conn, tenant_id, sorted(key.lower() for key in owner_keys)
|
|
111
|
+
)
|
|
112
|
+
seed_set = set(seed_entity_ids)
|
|
113
|
+
owner_set = set(owner_entity_ids)
|
|
114
|
+
|
|
115
|
+
# 3. Scope: union each seed's co-mentioned entities + connecting documents.
|
|
116
|
+
scope_entity_uuids: set[str] = set()
|
|
117
|
+
scope_doc_uuids: set[str] = set()
|
|
118
|
+
for seed_id in seed_entity_ids:
|
|
119
|
+
scope = backend.scope_person(
|
|
120
|
+
conn, tenant_id, seed_id, frontier_cap=frontier_cap
|
|
121
|
+
)
|
|
122
|
+
scope_entity_uuids.update(scope.entity_uuids)
|
|
123
|
+
scope_doc_uuids.update(scope.document_uuids)
|
|
124
|
+
scope_doc_ids = sorted(scope_doc_uuids)
|
|
125
|
+
|
|
126
|
+
# 4. Candidate theme entities: co-mentioned, minus X (every seed) + owner.
|
|
127
|
+
candidate_ids = sorted(scope_entity_uuids - seed_set - owner_set)
|
|
128
|
+
|
|
129
|
+
# 5. Generic suppression uses the CORPUS-WIDE document frequency vs the corpus
|
|
130
|
+
# cap — matching the derive-time suppression in
|
|
131
|
+
# ``aggregates._recompute_aggregates`` (spec §6b step 2 / §7). The in-scope
|
|
132
|
+
# df (max = |X's docs|) almost never reaches the corpus-sized cap, so a
|
|
133
|
+
# person/concept that is generic across the whole tenant but co-occurs with
|
|
134
|
+
# X in only a few of X's docs would wrongly survive into X's themes. The
|
|
135
|
+
# in-scope df is retained ONLY as the lift normalizer in ``_in_scope_edges``
|
|
136
|
+
# (the ``eid in in_scope_df`` guard also ensures every eligible endpoint has
|
|
137
|
+
# a normalizer value there).
|
|
138
|
+
in_scope_df = _in_scope_df(conn, tenant_id, scope_doc_ids, candidate_ids)
|
|
139
|
+
corpus_df = _corpus_df(conn, tenant_id, candidate_ids)
|
|
140
|
+
cap = generic_df_cap(_corpus_doc_count(conn, tenant_id), generic_df_ratio)
|
|
141
|
+
eligible_ids = [
|
|
142
|
+
eid
|
|
143
|
+
for eid in candidate_ids
|
|
144
|
+
if eid in in_scope_df and not is_generic_entity(corpus_df.get(eid, 0), cap)
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
# 6. In-scope normalized-lift edges among eligible entities → grouping.
|
|
148
|
+
# Thread ``in_scope_df`` (person-scoped distinct-doc count) onto each entity
|
|
149
|
+
# before grouping so callers can show "N docs with X" per entity (A2).
|
|
150
|
+
# Every eligible_id is guaranteed present in in_scope_df (the eligibility
|
|
151
|
+
# guard at step 5 requires ``eid in in_scope_df``), so ``.get(e.id, 0)`` is
|
|
152
|
+
# purely defensive and is never reached in production.
|
|
153
|
+
edges = _in_scope_edges(conn, tenant_id, scope_doc_ids, eligible_ids, in_scope_df)
|
|
154
|
+
entities = [
|
|
155
|
+
replace(e, scoped_doc_count=in_scope_df.get(e.id, 0))
|
|
156
|
+
for e in _fetch_entities(conn, tenant_id, eligible_ids)
|
|
157
|
+
]
|
|
158
|
+
groups = group_themes(
|
|
159
|
+
entities, edges, min_edge_weight=min_edge_weight, theme_limit=theme_limit
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# 7. Representative X-docs + snippets per group (and the context-level docs).
|
|
163
|
+
groups, doc_results = _populate_theme_docs(
|
|
164
|
+
conn, tenant_id, query, groups, scope_doc_ids, limit
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# 8. Optional best-effort group synthesis (never required for retrieval).
|
|
168
|
+
if synthesize:
|
|
169
|
+
groups = _synthesize_groups(conn, groups, person_display, enricher)
|
|
170
|
+
|
|
171
|
+
explanation = GraphExplanation(
|
|
172
|
+
mode=THEMES_MODE,
|
|
173
|
+
tenant_id=tenant_id,
|
|
174
|
+
seed_entity_ids=seed_entity_ids,
|
|
175
|
+
person_keys=list(match.keys),
|
|
176
|
+
depth=0, # themes scopes person→docs→co-entities; no depth traversal
|
|
177
|
+
frontier_cap=frontier_cap,
|
|
178
|
+
min_edge_weight=min_edge_weight,
|
|
179
|
+
nodes_visited=len(eligible_ids),
|
|
180
|
+
edges_considered=len(edges),
|
|
181
|
+
generic_df_cap=cap,
|
|
182
|
+
matched_filters={
|
|
183
|
+
"person": person_display,
|
|
184
|
+
"person_keys": list(match.keys),
|
|
185
|
+
"seed_entity_count": len(seed_entity_ids),
|
|
186
|
+
"scope_doc_count": len(scope_doc_ids),
|
|
187
|
+
"scope_entity_count": len(scope_entity_uuids),
|
|
188
|
+
"eligible_entity_count": len(eligible_ids),
|
|
189
|
+
"generic_df_cap": cap,
|
|
190
|
+
"theme_count": len(groups),
|
|
191
|
+
"theme_limit": theme_limit,
|
|
192
|
+
"min_edge_weight": min_edge_weight,
|
|
193
|
+
"frontier_cap": frontier_cap,
|
|
194
|
+
"synthesize": synthesize,
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
return GraphContext(
|
|
198
|
+
session_id=session_id,
|
|
199
|
+
mode=THEMES_MODE,
|
|
200
|
+
query=query,
|
|
201
|
+
tenant_id=tenant_id,
|
|
202
|
+
person=person_display,
|
|
203
|
+
themes=groups,
|
|
204
|
+
entities=_theme_member_entities(groups),
|
|
205
|
+
docs=doc_results,
|
|
206
|
+
explanation=explanation,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _person_entity_ids(
|
|
211
|
+
conn: psycopg.Connection[Any], tenant_id: str, keys: list[str]
|
|
212
|
+
) -> list[str]:
|
|
213
|
+
"""Resolve participant ``keys`` to ``person`` entity ids (tenant-scoped).
|
|
214
|
+
|
|
215
|
+
A person ``graph_entities`` row's ``canonical_key`` is the lowercased
|
|
216
|
+
People-Hub display name; ``keys`` (from ``resolve_person_to_keys`` / the
|
|
217
|
+
owner key set) carry that same lowercased form alongside emails, so the
|
|
218
|
+
name key matches and the email keys harmlessly miss. Ordered by id for a
|
|
219
|
+
deterministic seed list. An empty ``keys`` resolves to no entities.
|
|
220
|
+
"""
|
|
221
|
+
if not keys:
|
|
222
|
+
return []
|
|
223
|
+
rows = conn.execute(
|
|
224
|
+
"SELECT id::text FROM graph_entities "
|
|
225
|
+
"WHERE tenant_id = %s AND entity_type = 'person' "
|
|
226
|
+
"AND lower(canonical_key) = ANY(%s) ORDER BY id",
|
|
227
|
+
(tenant_id, keys),
|
|
228
|
+
).fetchall()
|
|
229
|
+
return [str(row[0]) for row in rows]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _corpus_doc_count(conn: psycopg.Connection[Any], tenant_id: str) -> int:
|
|
233
|
+
"""Tenant corpus N = distinct documents with any mention (spec §6b step 2).
|
|
234
|
+
|
|
235
|
+
Identical definition to the reconcile recompute path
|
|
236
|
+
(:func:`brain.graph_rag.aggregates._recompute_aggregates`) so the themes
|
|
237
|
+
generic cap matches the derive-time suppression cap — never recomputed
|
|
238
|
+
differently.
|
|
239
|
+
"""
|
|
240
|
+
row = conn.execute(
|
|
241
|
+
"SELECT COUNT(DISTINCT document_id) FROM graph_entity_mentions "
|
|
242
|
+
"WHERE tenant_id = %s",
|
|
243
|
+
(tenant_id,),
|
|
244
|
+
).fetchone()
|
|
245
|
+
return int(row[0]) if row is not None else 0
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _corpus_df(
|
|
249
|
+
conn: psycopg.Connection[Any],
|
|
250
|
+
tenant_id: str,
|
|
251
|
+
entity_ids: list[str],
|
|
252
|
+
) -> dict[str, int]:
|
|
253
|
+
"""Per-entity CORPUS-WIDE document frequency (``graph_entities.doc_count``).
|
|
254
|
+
|
|
255
|
+
The maintained tenant-wide df refreshed by
|
|
256
|
+
:func:`brain.graph_rag.aggregates._recompute_aggregates`, so the themes
|
|
257
|
+
generic-suppression filter (:func:`_retrieve_themes` step 5) uses the SAME df
|
|
258
|
+
scope as derive-time suppression — corpus-wide df vs the corpus cap — instead
|
|
259
|
+
of the in-scope df (whose max is ``|X's docs|`` and so would leave the
|
|
260
|
+
corpus-sized cap effectively unreachable, never suppressing a tenant-generic
|
|
261
|
+
entity that merely co-occurs with X). An empty entity set yields an empty map;
|
|
262
|
+
an entity with no catalog row is simply absent (the caller treats a miss as
|
|
263
|
+
df 0, i.e. not generic — defensive, never reached for catalog ids).
|
|
264
|
+
"""
|
|
265
|
+
if not entity_ids:
|
|
266
|
+
return {}
|
|
267
|
+
rows = conn.execute(
|
|
268
|
+
"SELECT id::text, doc_count FROM graph_entities "
|
|
269
|
+
"WHERE tenant_id = %s AND id = ANY(%s)",
|
|
270
|
+
(tenant_id, entity_ids),
|
|
271
|
+
).fetchall()
|
|
272
|
+
return {str(entity_id): int(count) for entity_id, count in rows}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _in_scope_df(
|
|
276
|
+
conn: psycopg.Connection[Any],
|
|
277
|
+
tenant_id: str,
|
|
278
|
+
doc_ids: list[str],
|
|
279
|
+
entity_ids: list[str],
|
|
280
|
+
) -> dict[str, int]:
|
|
281
|
+
"""Per-entity document frequency WITHIN X's scoped document set.
|
|
282
|
+
|
|
283
|
+
``df_in_scope[e]`` = distinct ``doc_ids`` (X's docs) that mention ``e``. The
|
|
284
|
+
normalizer + generic-suppression input for the in-scope lift (spec §6b
|
|
285
|
+
step 2). An empty doc/entity set yields an empty map.
|
|
286
|
+
"""
|
|
287
|
+
if not doc_ids or not entity_ids:
|
|
288
|
+
return {}
|
|
289
|
+
rows = conn.execute(
|
|
290
|
+
"SELECT entity_id::text, COUNT(DISTINCT document_id) "
|
|
291
|
+
"FROM graph_entity_mentions "
|
|
292
|
+
"WHERE tenant_id = %s AND document_id = ANY(%s) AND entity_id = ANY(%s) "
|
|
293
|
+
"GROUP BY entity_id",
|
|
294
|
+
(tenant_id, doc_ids, entity_ids),
|
|
295
|
+
).fetchall()
|
|
296
|
+
return {str(entity_id): int(count) for entity_id, count in rows}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _in_scope_edges(
|
|
300
|
+
conn: psycopg.Connection[Any],
|
|
301
|
+
tenant_id: str,
|
|
302
|
+
doc_ids: list[str],
|
|
303
|
+
eligible_ids: list[str],
|
|
304
|
+
in_scope_df: dict[str, int],
|
|
305
|
+
) -> list[Edge]:
|
|
306
|
+
"""In-scope normalized-lift edges among eligible entities (spec §6b step 2).
|
|
307
|
+
|
|
308
|
+
For each canonical pair of eligible entities, ``co_doc`` = distinct X-docs in
|
|
309
|
+
which both co-occur (``graph_edge_contributions`` restricted to ``doc_ids``);
|
|
310
|
+
the weight is ``normalized_lift(co_doc, df_src_in_scope, df_dst_in_scope)``.
|
|
311
|
+
Both endpoints are constrained to ``eligible_ids`` so a suppressed/excluded
|
|
312
|
+
entity never forms an edge. Fewer than two eligible entities → no edges.
|
|
313
|
+
|
|
314
|
+
**Relational-mirror-drift tolerance.** ``co_doc`` (from
|
|
315
|
+
``graph_edge_contributions``) and each endpoint's in-scope df (from
|
|
316
|
+
``graph_entity_mentions``) come from two separate relational tables that can
|
|
317
|
+
fall out of sync — e.g. an edge-contribution row whose corresponding mention
|
|
318
|
+
row was never written. When that happens ``co_doc`` can exceed the rarer
|
|
319
|
+
endpoint's in-scope df, which ``normalized_lift`` rejects with a
|
|
320
|
+
``WeightingError`` that would otherwise abort the ENTIRE ``themes --person``
|
|
321
|
+
query. To keep one drifted edge from killing the whole query, a drifted edge
|
|
322
|
+
is clamped to a valid weight (``co_doc → min(co_doc, min(df))``) with a WARN;
|
|
323
|
+
an edge missing an in-scope normalizer entirely (df 0 — no valid lift) is
|
|
324
|
+
skip-logged instead.
|
|
325
|
+
"""
|
|
326
|
+
if not doc_ids or len(eligible_ids) < 2:
|
|
327
|
+
return []
|
|
328
|
+
rows = conn.execute(
|
|
329
|
+
"SELECT src_id::text, dst_id::text, COUNT(DISTINCT document_id) "
|
|
330
|
+
"FROM graph_edge_contributions "
|
|
331
|
+
"WHERE tenant_id = %s AND document_id = ANY(%s) "
|
|
332
|
+
"AND src_id = ANY(%s) AND dst_id = ANY(%s) "
|
|
333
|
+
"GROUP BY src_id, dst_id",
|
|
334
|
+
(tenant_id, doc_ids, eligible_ids, eligible_ids),
|
|
335
|
+
).fetchall()
|
|
336
|
+
edges: list[Edge] = []
|
|
337
|
+
for src_id, dst_id, co_doc in rows:
|
|
338
|
+
src = str(src_id)
|
|
339
|
+
dst = str(dst_id)
|
|
340
|
+
df_src = in_scope_df.get(src, 0)
|
|
341
|
+
df_dst = in_scope_df.get(dst, 0)
|
|
342
|
+
min_df = min(df_src, df_dst)
|
|
343
|
+
co = int(co_doc)
|
|
344
|
+
if min_df < 1 or co < 1:
|
|
345
|
+
# Mirror drift: an endpoint has no in-scope mention row (df 0), so the
|
|
346
|
+
# edge has no valid normalizer. Skip it — a single drifted edge must
|
|
347
|
+
# never abort the whole themes query.
|
|
348
|
+
_logger.warning(
|
|
349
|
+
"themes: skipping drifted in-scope edge (%s, %s): missing "
|
|
350
|
+
"normalizer (df_src=%d df_dst=%d co_doc=%d) — relational mirror "
|
|
351
|
+
"drift between graph_edge_contributions and graph_entity_mentions",
|
|
352
|
+
src, dst, df_src, df_dst, co,
|
|
353
|
+
)
|
|
354
|
+
continue
|
|
355
|
+
if co > min_df:
|
|
356
|
+
# Mirror drift: the edge's in-scope co-document count exceeds the
|
|
357
|
+
# rarer endpoint's in-scope df. Clamp to the valid maximum so
|
|
358
|
+
# normalized_lift never raises (co_doc → min(co_doc, min(df))).
|
|
359
|
+
_logger.warning(
|
|
360
|
+
"themes: clamping drifted in-scope edge (%s, %s): co_doc %d "
|
|
361
|
+
"exceeds min in-scope df %d — relational mirror drift between "
|
|
362
|
+
"graph_edge_contributions and graph_entity_mentions",
|
|
363
|
+
src, dst, co, min_df,
|
|
364
|
+
)
|
|
365
|
+
co = min_df
|
|
366
|
+
weight = normalized_lift(co, df_src, df_dst)
|
|
367
|
+
edges.append(Edge(src_id=src, dst_id=dst, weight=weight, tenant_id=tenant_id))
|
|
368
|
+
return edges
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _docs_by_entity(
|
|
372
|
+
conn: psycopg.Connection[Any],
|
|
373
|
+
tenant_id: str,
|
|
374
|
+
doc_ids: list[str],
|
|
375
|
+
entity_ids: list[str],
|
|
376
|
+
) -> dict[str, set[str]]:
|
|
377
|
+
"""Map each entity id → the set of X-docs (in ``doc_ids``) mentioning it."""
|
|
378
|
+
if not doc_ids or not entity_ids:
|
|
379
|
+
return {}
|
|
380
|
+
rows = conn.execute(
|
|
381
|
+
"SELECT entity_id::text, document_id::text FROM graph_entity_mentions "
|
|
382
|
+
"WHERE tenant_id = %s AND document_id = ANY(%s) AND entity_id = ANY(%s)",
|
|
383
|
+
(tenant_id, doc_ids, entity_ids),
|
|
384
|
+
).fetchall()
|
|
385
|
+
out: dict[str, set[str]] = {}
|
|
386
|
+
for entity_id, document_id in rows:
|
|
387
|
+
out.setdefault(str(entity_id), set()).add(str(document_id))
|
|
388
|
+
return out
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _populate_theme_docs(
|
|
392
|
+
conn: psycopg.Connection[Any],
|
|
393
|
+
tenant_id: str,
|
|
394
|
+
query: str,
|
|
395
|
+
groups: list[ThemeGroup],
|
|
396
|
+
scope_doc_ids: list[str],
|
|
397
|
+
limit: int,
|
|
398
|
+
) -> tuple[list[ThemeGroup], list[SearchResult]]:
|
|
399
|
+
"""Attach representative X-docs to each group + build the context docs.
|
|
400
|
+
|
|
401
|
+
A group's representative docs are X's scoped documents that mention the
|
|
402
|
+
group's entities, ranked by the count of the group's entities present
|
|
403
|
+
(DESC), then document id (ASC) — deterministic — and capped at ``limit``.
|
|
404
|
+
The context-level ``docs`` rank every contributing X-doc by the count of
|
|
405
|
+
eligible entities it mentions and reuse :func:`_build_doc_results` for the
|
|
406
|
+
snippet path (so themes docs match the local/hybrid snippet shape).
|
|
407
|
+
"""
|
|
408
|
+
if not groups:
|
|
409
|
+
return [], []
|
|
410
|
+
member_ids = sorted({entity.id for group in groups for entity in group.entities})
|
|
411
|
+
docs_by_entity = _docs_by_entity(conn, tenant_id, scope_doc_ids, member_ids)
|
|
412
|
+
|
|
413
|
+
populated: list[ThemeGroup] = []
|
|
414
|
+
global_doc_score: dict[str, float] = {}
|
|
415
|
+
for group in groups:
|
|
416
|
+
doc_score: dict[str, int] = {}
|
|
417
|
+
for entity in group.entities:
|
|
418
|
+
for doc_id in docs_by_entity.get(entity.id, set()):
|
|
419
|
+
doc_score[doc_id] = doc_score.get(doc_id, 0) + 1
|
|
420
|
+
ranked = sorted(doc_score.items(), key=lambda kv: (-kv[1], kv[0]))[:limit]
|
|
421
|
+
populated.append(replace(group, doc_ids=[doc_id for doc_id, _ in ranked]))
|
|
422
|
+
for doc_id, count in doc_score.items():
|
|
423
|
+
global_doc_score[doc_id] = global_doc_score.get(doc_id, 0.0) + count
|
|
424
|
+
|
|
425
|
+
global_ranked = sorted(
|
|
426
|
+
global_doc_score.items(), key=lambda kv: (-kv[1], kv[0])
|
|
427
|
+
)[:limit]
|
|
428
|
+
return populated, _build_doc_results(conn, query, global_ranked)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _synthesize_groups(
|
|
432
|
+
conn: psycopg.Connection[Any],
|
|
433
|
+
groups: list[ThemeGroup],
|
|
434
|
+
person_display: str,
|
|
435
|
+
enricher: _GroupSummarizer | None,
|
|
436
|
+
) -> list[ThemeGroup]:
|
|
437
|
+
"""Attach a best-effort Ollama summary to each group (spec §17b decision 7).
|
|
438
|
+
|
|
439
|
+
Opt-in (``--synthesize``); ``summarize_group`` is best-effort and never
|
|
440
|
+
raises (returns ``None`` + a WARN on Ollama failure), so retrieval succeeds
|
|
441
|
+
regardless. A ``synthesize=True`` call with no injected ``enricher`` is a
|
|
442
|
+
caller wiring gap — logged WARN, summaries left ``None`` (still never-raise).
|
|
443
|
+
"""
|
|
444
|
+
if not groups:
|
|
445
|
+
return groups
|
|
446
|
+
if enricher is None:
|
|
447
|
+
_logger.warning(
|
|
448
|
+
"themes synthesize=True but no enricher injected; summaries stay None"
|
|
449
|
+
)
|
|
450
|
+
return groups
|
|
451
|
+
all_doc_ids = sorted({doc_id for group in groups for doc_id in group.doc_ids})
|
|
452
|
+
title_by_doc = _fetch_doc_titles(conn, all_doc_ids)
|
|
453
|
+
summarized: list[ThemeGroup] = []
|
|
454
|
+
for group in groups:
|
|
455
|
+
titles = [
|
|
456
|
+
title_by_doc[doc_id] for doc_id in group.doc_ids if doc_id in title_by_doc
|
|
457
|
+
]
|
|
458
|
+
try:
|
|
459
|
+
summary = enricher.summarize_group(
|
|
460
|
+
person=person_display,
|
|
461
|
+
entity_names=[entity.name for entity in group.entities],
|
|
462
|
+
doc_titles=titles,
|
|
463
|
+
)
|
|
464
|
+
except Exception as exc: # noqa: BLE001 -- best-effort: never fail retrieval
|
|
465
|
+
# summarize_group already returns None on Ollama down/timeout/invalid
|
|
466
|
+
# (spec §17b decision 7); this guard is defence-in-depth at the
|
|
467
|
+
# optional-side-work boundary (mirrors GraphSyncer) so even a
|
|
468
|
+
# misbehaving injected enricher can never turn themes retrieval into
|
|
469
|
+
# a hard live-Ollama dependency.
|
|
470
|
+
_logger.warning(
|
|
471
|
+
"themes synthesize: group %d summary failed (%s); summary=None",
|
|
472
|
+
group.group_id,
|
|
473
|
+
exc,
|
|
474
|
+
)
|
|
475
|
+
summary = None
|
|
476
|
+
summarized.append(replace(group, summary=summary))
|
|
477
|
+
return summarized
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _fetch_doc_titles(
|
|
481
|
+
conn: psycopg.Connection[Any], doc_ids: list[str]
|
|
482
|
+
) -> dict[str, str]:
|
|
483
|
+
"""Map document id → title for the group-synthesis prompt (no body content)."""
|
|
484
|
+
if not doc_ids:
|
|
485
|
+
return {}
|
|
486
|
+
rows = conn.execute(
|
|
487
|
+
"SELECT id::text, title FROM documents WHERE id = ANY(%s)",
|
|
488
|
+
(doc_ids,),
|
|
489
|
+
).fetchall()
|
|
490
|
+
return {str(row[0]): str(row[1]) for row in rows}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _theme_member_entities(groups: list[ThemeGroup]) -> list[GraphEntity]:
|
|
494
|
+
"""Flat, deduped, deterministically-sorted entity list across all groups."""
|
|
495
|
+
by_id: dict[str, GraphEntity] = {}
|
|
496
|
+
for group in groups:
|
|
497
|
+
for entity in group.entities:
|
|
498
|
+
by_id.setdefault(entity.id, entity)
|
|
499
|
+
return sorted(
|
|
500
|
+
by_id.values(), key=lambda e: (e.canonical_key, e.entity_type, e.id)
|
|
501
|
+
)
|