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,372 @@
|
|
|
1
|
+
"""Scoped-subgraph theme grouping — pure-Python CC + bridge guard (wave G2-e).
|
|
2
|
+
|
|
3
|
+
Pure logic, no DB, no I/O. Implements spec §6b step 3 / §17b Q1 (Codex-ruled):
|
|
4
|
+
group the small *scoped* subgraph of theme-eligible entities into
|
|
5
|
+
:class:`~brain.graph_rag.schema.ThemeGroup`s using
|
|
6
|
+
|
|
7
|
+
1. **Edge threshold** — keep a scoped edge iff its ``normalized_lift`` weight
|
|
8
|
+
``>= min_edge_weight`` (cfg ``graph_min_edge_weight``, default ``0.20``).
|
|
9
|
+
2. **Bridge guard** — over the thresholded graph, drop a graph-theoretic *bridge*
|
|
10
|
+
edge IFF removing it leaves two sides EACH with ``>= 2`` theme-eligible
|
|
11
|
+
entities AND the bridge ``weight < bridge_keep_weight`` (``0.50``). KEEP
|
|
12
|
+
bridges with ``weight >= bridge_keep_weight``; KEEP *leaf* bridges (either
|
|
13
|
+
side of size ``1``). Non-bridge edges are never dropped here.
|
|
14
|
+
3. **Connected components** — over the post-guard graph; each component (incl.
|
|
15
|
+
isolated theme-eligible entities as singleton groups) becomes one
|
|
16
|
+
:class:`ThemeGroup`.
|
|
17
|
+
4. **Deterministic ranking** — groups ordered by size DESC, then internal
|
|
18
|
+
cohesion (sum of surviving in-group edge weights) DESC, then the
|
|
19
|
+
lexicographic member key. ``theme_limit`` truncates the ranked list.
|
|
20
|
+
|
|
21
|
+
**networkx is intentionally NOT used.** The spec pulls it into G2 only if this
|
|
22
|
+
pure implementation of the same threshold/bridge/CC rules fails the acceptance
|
|
23
|
+
fixture (``tests/test_graphrag_grouping.py``) — it does not. Bridge detection is
|
|
24
|
+
an iterative Tarjan low-link sweep; components are an iterative BFS.
|
|
25
|
+
|
|
26
|
+
Inputs are the existing schema value objects: ``entities`` are the scoped,
|
|
27
|
+
already-theme-eligible :class:`~brain.graph_rag.schema.GraphEntity` rows (the
|
|
28
|
+
*caller* — G2-f — excludes the seed X / owner / generic entities, spec §17.5),
|
|
29
|
+
and ``edges`` are the in-scope :class:`~brain.graph_rag.schema.Edge` rows whose
|
|
30
|
+
``weight`` is the in-scope normalized lift. The function reads only ``id`` /
|
|
31
|
+
``canonical_key`` / ``entity_type`` from entities and ``src_id`` / ``dst_id`` /
|
|
32
|
+
``weight`` from edges; it never mutates the caller's sequences.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from collections import deque
|
|
37
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
38
|
+
|
|
39
|
+
from ..errors import GroupingError
|
|
40
|
+
from .schema import Edge, GraphEntity, ThemeGroup
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"DEFAULT_BRIDGE_KEEP_WEIGHT",
|
|
44
|
+
"DEFAULT_MIN_EDGE_WEIGHT",
|
|
45
|
+
"group_themes",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# Normalized-lift floor for keeping a scoped edge. Mirrors
|
|
49
|
+
# ``config.DEFAULT_GRAPH_MIN_EDGE_WEIGHT`` / ``cfg.graph_min_edge_weight``
|
|
50
|
+
# (spec §17b Q1/Q5). Defined locally (like ``cooccur.DEFAULT_COOCCUR_WINDOW``
|
|
51
|
+
# and ``weighting.DEFAULT_GENERIC_DF``) so the pure-logic layer carries no
|
|
52
|
+
# runtime dependency on :mod:`brain.config`; production passes the cfg value.
|
|
53
|
+
DEFAULT_MIN_EDGE_WEIGHT = 0.20
|
|
54
|
+
|
|
55
|
+
# Bridge weight at/above which a bridge is always kept (spec §17b Q1). A bridge
|
|
56
|
+
# below this is dropped only when both sides have ``>= 2`` theme-eligible
|
|
57
|
+
# entities (a leaf bridge — one side of size 1 — is always kept).
|
|
58
|
+
DEFAULT_BRIDGE_KEEP_WEIGHT = 0.50
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def group_themes(
|
|
62
|
+
entities: Sequence[GraphEntity],
|
|
63
|
+
edges: Sequence[Edge],
|
|
64
|
+
*,
|
|
65
|
+
min_edge_weight: float = DEFAULT_MIN_EDGE_WEIGHT,
|
|
66
|
+
bridge_keep_weight: float = DEFAULT_BRIDGE_KEEP_WEIGHT,
|
|
67
|
+
theme_limit: int | None = None,
|
|
68
|
+
) -> list[ThemeGroup]:
|
|
69
|
+
"""Group a scoped subgraph into ranked :class:`ThemeGroup`s (spec §6b/§17b Q1).
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
entities: The scoped, already theme-eligible entities. Each defines a
|
|
73
|
+
node; every entity lands in exactly one group (an entity with no
|
|
74
|
+
surviving edge forms a singleton group). Duplicate ids are collapsed
|
|
75
|
+
(first occurrence wins). The sequence is not mutated.
|
|
76
|
+
edges: In-scope undirected weighted edges (``weight`` = normalized lift
|
|
77
|
+
in ``(0, 1]``). Edges referencing an entity absent from ``entities``,
|
|
78
|
+
and self-loops, are ignored. A duplicate endpoint pair keeps its
|
|
79
|
+
maximum weight (deterministic). The sequence is not mutated.
|
|
80
|
+
min_edge_weight: Keep an edge iff ``weight >= min_edge_weight``
|
|
81
|
+
(default :data:`DEFAULT_MIN_EDGE_WEIGHT`). Must be in ``[0.0, 1.0]``.
|
|
82
|
+
bridge_keep_weight: Bridges with ``weight >= bridge_keep_weight`` are
|
|
83
|
+
always kept (default :data:`DEFAULT_BRIDGE_KEEP_WEIGHT`). Must be in
|
|
84
|
+
``[0.0, 1.0]``.
|
|
85
|
+
theme_limit: When set, return only the top ``theme_limit`` ranked groups
|
|
86
|
+
(cfg ``graph_theme_limit``, default 5 in production). ``None`` keeps
|
|
87
|
+
all groups. Must be ``>= 1`` when set.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Ranked ``ThemeGroup``s: ``group_id`` is the 0-based rank, ``entities`` is
|
|
91
|
+
the group's members (sorted), ``score`` is the group's internal cohesion
|
|
92
|
+
(sum of surviving in-group edge weights), ``doc_ids`` / ``summary`` are
|
|
93
|
+
left at their defaults (populated by the G2-f caller). Membership and
|
|
94
|
+
ordering are deterministic — invariant under input reordering.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
GroupingError: ``min_edge_weight`` / ``bridge_keep_weight`` outside
|
|
98
|
+
``[0.0, 1.0]``, or ``theme_limit`` set ``< 1``.
|
|
99
|
+
"""
|
|
100
|
+
_validate_params(min_edge_weight, bridge_keep_weight, theme_limit)
|
|
101
|
+
|
|
102
|
+
# First-occurrence-wins dedup so a stray duplicate entity row is deterministic.
|
|
103
|
+
entity_by_id: dict[str, GraphEntity] = {}
|
|
104
|
+
for entity in entities:
|
|
105
|
+
entity_by_id.setdefault(entity.id, entity)
|
|
106
|
+
|
|
107
|
+
weights = _thresholded_edge_weights(edges, entity_by_id.keys(), min_edge_weight)
|
|
108
|
+
adjacency = _build_adjacency(entity_by_id.keys(), weights)
|
|
109
|
+
|
|
110
|
+
bridges = _find_bridges(entity_by_id.keys(), adjacency)
|
|
111
|
+
dropped = _bridges_to_drop(bridges, weights, adjacency, bridge_keep_weight)
|
|
112
|
+
surviving = {pair: w for pair, w in weights.items() if pair not in dropped}
|
|
113
|
+
final_adjacency = _build_adjacency(entity_by_id.keys(), surviving)
|
|
114
|
+
|
|
115
|
+
components = _connected_components(entity_by_id.keys(), final_adjacency)
|
|
116
|
+
groups = _build_groups(components, entity_by_id, surviving)
|
|
117
|
+
if theme_limit is not None:
|
|
118
|
+
return groups[:theme_limit]
|
|
119
|
+
return groups
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --------------------------------------------------------------------------- #
|
|
123
|
+
# Validation
|
|
124
|
+
# --------------------------------------------------------------------------- #
|
|
125
|
+
def _validate_params(
|
|
126
|
+
min_edge_weight: float, bridge_keep_weight: float, theme_limit: int | None
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Fail fast on degenerate grouping knobs (caller bug, no DB touched)."""
|
|
129
|
+
if not 0.0 <= min_edge_weight <= 1.0:
|
|
130
|
+
raise GroupingError(
|
|
131
|
+
f"min_edge_weight must be in [0.0, 1.0] (got {min_edge_weight})"
|
|
132
|
+
)
|
|
133
|
+
if not 0.0 <= bridge_keep_weight <= 1.0:
|
|
134
|
+
raise GroupingError(
|
|
135
|
+
f"bridge_keep_weight must be in [0.0, 1.0] (got {bridge_keep_weight})"
|
|
136
|
+
)
|
|
137
|
+
if theme_limit is not None and theme_limit < 1:
|
|
138
|
+
raise GroupingError(
|
|
139
|
+
f"theme_limit must be a positive integer or None (got {theme_limit})"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --------------------------------------------------------------------------- #
|
|
144
|
+
# Graph construction (canonical, deduped, threshold-filtered)
|
|
145
|
+
# --------------------------------------------------------------------------- #
|
|
146
|
+
def _canonical(a: str, b: str) -> tuple[str, str]:
|
|
147
|
+
"""Order an endpoint pair ``(lo, hi)`` so each undirected edge has one key."""
|
|
148
|
+
return (a, b) if a < b else (b, a)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _thresholded_edge_weights(
|
|
152
|
+
edges: Sequence[Edge], node_ids: Iterable[str], min_edge_weight: float
|
|
153
|
+
) -> dict[tuple[str, str], float]:
|
|
154
|
+
"""Canonical-pair → weight for edges that survive the threshold.
|
|
155
|
+
|
|
156
|
+
Drops self-loops, edges with an endpoint outside the node set, and edges
|
|
157
|
+
below ``min_edge_weight``. A duplicate pair keeps its maximum weight so the
|
|
158
|
+
result is independent of input order.
|
|
159
|
+
"""
|
|
160
|
+
nodes = set(node_ids)
|
|
161
|
+
weights: dict[tuple[str, str], float] = {}
|
|
162
|
+
for edge in edges:
|
|
163
|
+
if edge.src_id == edge.dst_id:
|
|
164
|
+
continue # no self loops
|
|
165
|
+
if edge.src_id not in nodes or edge.dst_id not in nodes:
|
|
166
|
+
continue # endpoint not in the scoped node set
|
|
167
|
+
if edge.weight < min_edge_weight:
|
|
168
|
+
continue
|
|
169
|
+
pair = _canonical(edge.src_id, edge.dst_id)
|
|
170
|
+
prior = weights.get(pair)
|
|
171
|
+
if prior is None or edge.weight > prior:
|
|
172
|
+
weights[pair] = edge.weight
|
|
173
|
+
return weights
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _build_adjacency(
|
|
177
|
+
node_ids: Iterable[str], weights: dict[tuple[str, str], float]
|
|
178
|
+
) -> dict[str, list[str]]:
|
|
179
|
+
"""Undirected adjacency over all nodes; lists sorted for stable traversal."""
|
|
180
|
+
adjacency: dict[str, set[str]] = {node: set() for node in node_ids}
|
|
181
|
+
for src, dst in weights:
|
|
182
|
+
adjacency[src].add(dst)
|
|
183
|
+
adjacency[dst].add(src)
|
|
184
|
+
return {node: sorted(neighbours) for node, neighbours in adjacency.items()}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --------------------------------------------------------------------------- #
|
|
188
|
+
# Bridge detection (iterative Tarjan low-link; deduped ⇒ simple parent skip)
|
|
189
|
+
# --------------------------------------------------------------------------- #
|
|
190
|
+
def _find_bridges(
|
|
191
|
+
node_ids: Iterable[str], adjacency: dict[str, list[str]]
|
|
192
|
+
) -> set[tuple[str, str]]:
|
|
193
|
+
"""Return the canonical pairs that are graph-theoretic bridges.
|
|
194
|
+
|
|
195
|
+
A *bridge* is an edge whose removal increases the connected-component count.
|
|
196
|
+
Iterative DFS computes discovery times ``disc`` and low-link values ``low``;
|
|
197
|
+
a tree edge ``(parent, child)`` is a bridge iff ``low[child] > disc[parent]``.
|
|
198
|
+
Edges are deduped to a single edge per pair upstream, so skipping one
|
|
199
|
+
parent-edge occurrence is correct (no parallel-edge confusion). The bridge
|
|
200
|
+
set is a graph invariant — independent of the DFS start/visit order.
|
|
201
|
+
"""
|
|
202
|
+
disc: dict[str, int] = {}
|
|
203
|
+
low: dict[str, int] = {}
|
|
204
|
+
bridges: set[tuple[str, str]] = set()
|
|
205
|
+
timer = 0
|
|
206
|
+
|
|
207
|
+
for start in sorted(node_ids):
|
|
208
|
+
if start in disc:
|
|
209
|
+
continue
|
|
210
|
+
timer = _dfs_bridges(start, adjacency, disc, low, bridges, timer)
|
|
211
|
+
return bridges
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _dfs_bridges(
|
|
215
|
+
start: str,
|
|
216
|
+
adjacency: dict[str, list[str]],
|
|
217
|
+
disc: dict[str, int],
|
|
218
|
+
low: dict[str, int],
|
|
219
|
+
bridges: set[tuple[str, str]],
|
|
220
|
+
timer: int,
|
|
221
|
+
) -> int:
|
|
222
|
+
"""Iterative DFS from ``start`` populating ``disc``/``low``/``bridges``.
|
|
223
|
+
|
|
224
|
+
Returns the advanced ``timer``. The explicit stack holds
|
|
225
|
+
``(node, parent, neighbour-iterator)`` frames so recursion depth is bounded
|
|
226
|
+
by the heap, not the Python call stack (safe for long path-graphs).
|
|
227
|
+
"""
|
|
228
|
+
disc[start] = low[start] = timer
|
|
229
|
+
timer += 1
|
|
230
|
+
stack: list[tuple[str, str | None, Iterator[str]]] = [
|
|
231
|
+
(start, None, iter(adjacency[start]))
|
|
232
|
+
]
|
|
233
|
+
while stack:
|
|
234
|
+
node, parent, neighbours = stack[-1]
|
|
235
|
+
descended = False
|
|
236
|
+
for nbr in neighbours:
|
|
237
|
+
if nbr == parent:
|
|
238
|
+
continue # the single edge back to the DFS parent
|
|
239
|
+
if nbr not in disc:
|
|
240
|
+
disc[nbr] = low[nbr] = timer
|
|
241
|
+
timer += 1
|
|
242
|
+
stack.append((nbr, node, iter(adjacency[nbr])))
|
|
243
|
+
descended = True
|
|
244
|
+
break
|
|
245
|
+
low[node] = min(low[node], disc[nbr])
|
|
246
|
+
if descended:
|
|
247
|
+
continue
|
|
248
|
+
stack.pop()
|
|
249
|
+
if stack:
|
|
250
|
+
par = stack[-1][0]
|
|
251
|
+
low[par] = min(low[par], low[node])
|
|
252
|
+
if low[node] > disc[par]:
|
|
253
|
+
bridges.add(_canonical(par, node))
|
|
254
|
+
return timer
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _bridges_to_drop(
|
|
258
|
+
bridges: set[tuple[str, str]],
|
|
259
|
+
weights: dict[tuple[str, str], float],
|
|
260
|
+
adjacency: dict[str, list[str]],
|
|
261
|
+
bridge_keep_weight: float,
|
|
262
|
+
) -> set[tuple[str, str]]:
|
|
263
|
+
"""Apply the §17b Q1 bridge guard; return the bridge pairs to drop.
|
|
264
|
+
|
|
265
|
+
Each bridge is evaluated against the *thresholded* graph (only that one
|
|
266
|
+
bridge removed): dropped iff its weight ``< bridge_keep_weight`` AND both
|
|
267
|
+
resulting sides hold ``>= 2`` theme-eligible entities. Bridges at/above the
|
|
268
|
+
keep weight, and leaf bridges (a side of size 1), are kept. All qualifying
|
|
269
|
+
bridges are dropped together.
|
|
270
|
+
"""
|
|
271
|
+
dropped: set[tuple[str, str]] = set()
|
|
272
|
+
for pair in bridges:
|
|
273
|
+
if weights[pair] >= bridge_keep_weight:
|
|
274
|
+
continue # strong bridge — always kept
|
|
275
|
+
src, dst = pair
|
|
276
|
+
src_side = _reachable_excluding_edge(adjacency, src, pair)
|
|
277
|
+
dst_side = _reachable_excluding_edge(adjacency, dst, pair)
|
|
278
|
+
if len(src_side) >= 2 and len(dst_side) >= 2:
|
|
279
|
+
dropped.add(pair)
|
|
280
|
+
# else: at least one leaf side — keep the bridge.
|
|
281
|
+
return dropped
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _reachable_excluding_edge(
|
|
285
|
+
adjacency: dict[str, list[str]], start: str, excluded: tuple[str, str]
|
|
286
|
+
) -> set[str]:
|
|
287
|
+
"""Nodes reachable from ``start`` without traversing the ``excluded`` edge.
|
|
288
|
+
|
|
289
|
+
Used to size the two sides a bridge separates. For a true bridge the start
|
|
290
|
+
side and the other endpoint's side are disjoint and partition the component.
|
|
291
|
+
"""
|
|
292
|
+
seen = {start}
|
|
293
|
+
queue = deque([start])
|
|
294
|
+
while queue:
|
|
295
|
+
node = queue.popleft()
|
|
296
|
+
for nbr in adjacency[node]:
|
|
297
|
+
if _canonical(node, nbr) == excluded:
|
|
298
|
+
continue # the bridge under evaluation is removed
|
|
299
|
+
if nbr not in seen:
|
|
300
|
+
seen.add(nbr)
|
|
301
|
+
queue.append(nbr)
|
|
302
|
+
return seen
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# --------------------------------------------------------------------------- #
|
|
306
|
+
# Connected components + group assembly (deterministic ranking)
|
|
307
|
+
# --------------------------------------------------------------------------- #
|
|
308
|
+
def _connected_components(
|
|
309
|
+
node_ids: Iterable[str], adjacency: dict[str, list[str]]
|
|
310
|
+
) -> list[list[str]]:
|
|
311
|
+
"""Connected components over ``adjacency``; isolated nodes are singletons.
|
|
312
|
+
|
|
313
|
+
Iterative BFS seeded in sorted node order, so component discovery order is
|
|
314
|
+
deterministic (final group ordering is re-sorted by rank regardless).
|
|
315
|
+
"""
|
|
316
|
+
seen: set[str] = set()
|
|
317
|
+
components: list[list[str]] = []
|
|
318
|
+
for start in sorted(node_ids):
|
|
319
|
+
if start in seen:
|
|
320
|
+
continue
|
|
321
|
+
component: list[str] = []
|
|
322
|
+
queue = deque([start])
|
|
323
|
+
seen.add(start)
|
|
324
|
+
while queue:
|
|
325
|
+
node = queue.popleft()
|
|
326
|
+
component.append(node)
|
|
327
|
+
for nbr in adjacency[node]:
|
|
328
|
+
if nbr not in seen:
|
|
329
|
+
seen.add(nbr)
|
|
330
|
+
queue.append(nbr)
|
|
331
|
+
components.append(component)
|
|
332
|
+
return components
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _build_groups(
|
|
336
|
+
components: list[list[str]],
|
|
337
|
+
entity_by_id: dict[str, GraphEntity],
|
|
338
|
+
surviving: dict[tuple[str, str], float],
|
|
339
|
+
) -> list[ThemeGroup]:
|
|
340
|
+
"""Build ranked ``ThemeGroup``s from components + surviving-edge cohesion.
|
|
341
|
+
|
|
342
|
+
Each group's ``score`` is the sum of surviving edge weights internal to it
|
|
343
|
+
(singletons score ``0.0``). Groups rank by size DESC, score DESC, then the
|
|
344
|
+
lexicographic member key ``(canonical_key, entity_type, id)`` ASC — a total
|
|
345
|
+
order over disjoint membership, so ranking is fully deterministic.
|
|
346
|
+
``group_id`` is the resulting 0-based rank.
|
|
347
|
+
"""
|
|
348
|
+
component_of = {
|
|
349
|
+
node: idx for idx, component in enumerate(components) for node in component
|
|
350
|
+
}
|
|
351
|
+
# Accumulate in sorted-pair order so the float sum is byte-identical
|
|
352
|
+
# regardless of the caller's edge ordering (determinism contract).
|
|
353
|
+
scores = [0.0] * len(components)
|
|
354
|
+
for (src, _dst), weight in sorted(surviving.items()):
|
|
355
|
+
scores[component_of[src]] += weight
|
|
356
|
+
|
|
357
|
+
ranked: list[tuple[float, list[GraphEntity]]] = []
|
|
358
|
+
sort_keys: list[tuple[int, float, tuple[tuple[str, str, str], ...]]] = []
|
|
359
|
+
for idx, component in enumerate(components):
|
|
360
|
+
members = sorted(
|
|
361
|
+
(entity_by_id[node] for node in component),
|
|
362
|
+
key=lambda e: (e.canonical_key, e.entity_type, e.id),
|
|
363
|
+
)
|
|
364
|
+
member_key = tuple((e.canonical_key, e.entity_type, e.id) for e in members)
|
|
365
|
+
sort_keys.append((-len(members), -scores[idx], member_key))
|
|
366
|
+
ranked.append((scores[idx], members))
|
|
367
|
+
|
|
368
|
+
order = sorted(range(len(ranked)), key=lambda i: sort_keys[i])
|
|
369
|
+
return [
|
|
370
|
+
ThemeGroup(group_id=rank, entities=ranked[i][1], score=ranked[i][0])
|
|
371
|
+
for rank, i in enumerate(order)
|
|
372
|
+
]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Default person resolver for the graph people aspect (spec §3 reuse map).
|
|
2
|
+
|
|
3
|
+
Extracted from :mod:`brain.graph_rag.reconcile` (Phase 1, 2026-05-23) to keep
|
|
4
|
+
that module under the 800-line cap. Holds the :class:`ResolvedPerson` value
|
|
5
|
+
object and :func:`default_person_resolver` — the production
|
|
6
|
+
:class:`~brain.graph_rag.reconcile.PersonResolver` implementation that derives a
|
|
7
|
+
document's person set from the existing People-Hub pipeline so the graph's
|
|
8
|
+
person roster for a document can never drift from its People-Hub roster.
|
|
9
|
+
|
|
10
|
+
Both are re-exported from :mod:`brain.graph_rag.reconcile` for backwards
|
|
11
|
+
compatibility, so existing imports
|
|
12
|
+
(``from brain.graph_rag.reconcile import ResolvedPerson, default_person_resolver``)
|
|
13
|
+
keep working.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
import psycopg
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from ..wiki.build_people import _DirectoryIndex
|
|
24
|
+
from .reconcile import PersonResolver
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ResolvedPerson",
|
|
28
|
+
"default_person_resolver",
|
|
29
|
+
"prebuilt_directory_resolver",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ResolvedPerson:
|
|
35
|
+
"""One person resolved for a document — the input to entity upsert.
|
|
36
|
+
|
|
37
|
+
``canonical_key`` is the normalized lowercase display name (the People-Hub
|
|
38
|
+
canonical identity, unique per ``(tenant_id, entity_type, canonical_key)``);
|
|
39
|
+
``display_name`` is its humanized presentation form (stored as
|
|
40
|
+
``graph_entities.name``).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
canonical_key: str
|
|
44
|
+
display_name: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def default_person_resolver(
|
|
48
|
+
conn: psycopg.Connection[Any],
|
|
49
|
+
document_id: str,
|
|
50
|
+
*,
|
|
51
|
+
owner_keys: frozenset[str] = frozenset(),
|
|
52
|
+
sender_denylist: frozenset[str] = frozenset(),
|
|
53
|
+
directory: _DirectoryIndex | None = None,
|
|
54
|
+
) -> list[ResolvedPerson]:
|
|
55
|
+
"""Derive a document's person set from the existing People-Hub pipeline.
|
|
56
|
+
|
|
57
|
+
Reuses :mod:`brain.wiki.build_people`'s directory index + per-doc participant
|
|
58
|
+
extraction + key resolution (and the shared
|
|
59
|
+
:mod:`brain.wiki._person_name` normalizer) so the graph's person roster for
|
|
60
|
+
a document is identical to its People-Hub roster (spec §3 reuse map). Owner
|
|
61
|
+
keys are expanded (first-name-only + email-local-part variants) then
|
|
62
|
+
stripped both before resolution (raw participant key) and after (resolved
|
|
63
|
+
canonical key), matching ``aggregate_people``'s owner filter. Automated /
|
|
64
|
+
org senders are dropped by the directory index (``sender_denylist`` adds
|
|
65
|
+
``BRAIN_GRAPH_SENDER_DENYLIST`` extras to the always-on generic heuristic).
|
|
66
|
+
Returns the deduplicated persons sorted by ``canonical_key`` for
|
|
67
|
+
determinism.
|
|
68
|
+
|
|
69
|
+
``directory`` (perf Fix B, 2026-05-24) is an optional prebuilt
|
|
70
|
+
:class:`brain.wiki.build_people._DirectoryIndex`. The directory is
|
|
71
|
+
corpus-wide and does NOT change mid-build, so the batch
|
|
72
|
+
:func:`brain.graph_rag.build.build_graph` builds it ONCE and passes it in
|
|
73
|
+
(via :func:`prebuilt_directory_resolver`) instead of paying the ~1.2k-row
|
|
74
|
+
``SELECT`` + dict build on every document. When ``None`` (the incremental
|
|
75
|
+
ingest hook's default) the index is built here from ``directory_entries`` for
|
|
76
|
+
this single call — preserving the per-document path. A caller supplying
|
|
77
|
+
``directory`` is responsible for having built it with the same
|
|
78
|
+
``sender_denylist`` it passes here (``build_graph`` does, from the one shared
|
|
79
|
+
config); the passed ``sender_denylist`` is otherwise unused once a prebuilt
|
|
80
|
+
index is supplied.
|
|
81
|
+
|
|
82
|
+
Returns an empty list when the document does not exist or has no resolvable
|
|
83
|
+
participants (e.g. a manual note, or a Gmail header with no directory match).
|
|
84
|
+
"""
|
|
85
|
+
# Late import keeps :mod:`brain.graph_rag` import-cheap and avoids a cycle
|
|
86
|
+
# with the wiki package, mirroring ``queries.resolve_person_to_keys``.
|
|
87
|
+
from ..wiki._person_name import expand_owner_keys
|
|
88
|
+
from ..wiki.build_people import (
|
|
89
|
+
_build_directory_index,
|
|
90
|
+
_doc_participant_keys,
|
|
91
|
+
_resolve_key_to_person,
|
|
92
|
+
humanize_display_name,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
row = conn.execute(
|
|
96
|
+
"SELECT s.kind, d.metadata FROM documents d "
|
|
97
|
+
"JOIN sources s ON s.id = d.source_id WHERE d.id = %s",
|
|
98
|
+
(document_id,),
|
|
99
|
+
).fetchone()
|
|
100
|
+
if row is None:
|
|
101
|
+
return []
|
|
102
|
+
source_kind, raw_metadata = row
|
|
103
|
+
metadata: dict[str, Any] = dict(raw_metadata) if raw_metadata else {}
|
|
104
|
+
|
|
105
|
+
expanded_owner = expand_owner_keys(owner_keys)
|
|
106
|
+
if directory is None:
|
|
107
|
+
directory = _build_directory_index(conn, sender_denylist=sender_denylist)
|
|
108
|
+
raw_keys = _doc_participant_keys(source_kind=source_kind, metadata=metadata)
|
|
109
|
+
|
|
110
|
+
resolved: dict[str, str] = {}
|
|
111
|
+
for key in raw_keys:
|
|
112
|
+
if key.strip().lower() in expanded_owner:
|
|
113
|
+
continue
|
|
114
|
+
canonical = _resolve_key_to_person(key, directory=directory)
|
|
115
|
+
if canonical is None or canonical in expanded_owner:
|
|
116
|
+
continue
|
|
117
|
+
# Record-level owner filter (mirrors ``aggregate_people``): drop a person
|
|
118
|
+
# whose primary email is an owner key even when only the email — not the
|
|
119
|
+
# display name — was listed, since the display-name participant key would
|
|
120
|
+
# otherwise resolve the owner back in.
|
|
121
|
+
primary_email = directory.primary_email_by_key.get(canonical)
|
|
122
|
+
if primary_email is not None and primary_email.lower() in expanded_owner:
|
|
123
|
+
continue
|
|
124
|
+
resolved[canonical] = humanize_display_name(canonical)
|
|
125
|
+
|
|
126
|
+
return [
|
|
127
|
+
ResolvedPerson(canonical_key=key, display_name=name)
|
|
128
|
+
for key, name in sorted(resolved.items())
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def prebuilt_directory_resolver(
|
|
133
|
+
directory: _DirectoryIndex,
|
|
134
|
+
) -> PersonResolver:
|
|
135
|
+
"""Wrap :func:`default_person_resolver` to reuse a prebuilt directory index.
|
|
136
|
+
|
|
137
|
+
Perf seam for the batch corpus build (Fix B, 2026-05-24). The People-Hub
|
|
138
|
+
directory index is corpus-wide and does NOT change during a build, so
|
|
139
|
+
:func:`brain.graph_rag.build.build_graph` builds it ONCE and wraps it here,
|
|
140
|
+
then passes the returned callable through reconcile's ``person_resolver`` DI
|
|
141
|
+
seam — so every per-document reconcile reuses the same index instead of
|
|
142
|
+
rebuilding the ~1.2k-row directory on each document. The returned callable
|
|
143
|
+
conforms to the :class:`brain.graph_rag.reconcile.PersonResolver` protocol
|
|
144
|
+
``(conn, document_id, *, owner_keys, sender_denylist)``; it forwards
|
|
145
|
+
``owner_keys`` (per-document owner filtering still applies) but uses the
|
|
146
|
+
captured ``directory`` regardless of the ``sender_denylist`` argument, since
|
|
147
|
+
that denylist was already baked into the prebuilt index. The incremental
|
|
148
|
+
ingest hook keeps calling :func:`default_person_resolver` directly (which
|
|
149
|
+
builds its own one-call index), so its single-document path is unchanged.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def _resolver(
|
|
153
|
+
conn: psycopg.Connection[Any],
|
|
154
|
+
document_id: str,
|
|
155
|
+
*,
|
|
156
|
+
owner_keys: frozenset[str] = frozenset(),
|
|
157
|
+
sender_denylist: frozenset[str] = frozenset(),
|
|
158
|
+
) -> list[ResolvedPerson]:
|
|
159
|
+
return default_person_resolver(
|
|
160
|
+
conn,
|
|
161
|
+
document_id,
|
|
162
|
+
owner_keys=owner_keys,
|
|
163
|
+
sender_denylist=sender_denylist,
|
|
164
|
+
directory=directory,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return _resolver
|