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,272 @@
|
|
|
1
|
+
"""Storage/traversal backend Protocol for the GraphRAG layer (spec §4 D10).
|
|
2
|
+
|
|
3
|
+
A :class:`GraphBackend` is the narrow, tenant-scoped interface the rest of the
|
|
4
|
+
GraphRAG layer (reconcile in G1, retrieval in G2) depends on. The default
|
|
5
|
+
implementation is :class:`brain.graph_rag.backends.age.AgeBackend` (Apache AGE
|
|
6
|
+
inside Postgres, openCypher). Keeping the surface narrow is what makes the
|
|
7
|
+
Neo4j/Memgraph kill-switch (spec §16) a drop-in replacement: a second backend
|
|
8
|
+
conforms to this Protocol and the callers do not change.
|
|
9
|
+
|
|
10
|
+
Every method is **tenant-scoped** — ``tenant_id`` is a required argument on each
|
|
11
|
+
one (spec §4 D9). AGE has no native tenant isolation, so the AGE implementation
|
|
12
|
+
enforces the tenant on every matched vertex *and* edge; a backend that cannot
|
|
13
|
+
make that guarantee must not claim conformance.
|
|
14
|
+
|
|
15
|
+
The graph mirrors the relational source-of-truth (migration 012); the durable
|
|
16
|
+
app identity of an entity is its ``graph_entities.id`` UUID, carried on the AGE
|
|
17
|
+
vertex as the ``entity_uuid`` property. Backend methods speak ``entity_uuid`` /
|
|
18
|
+
``document_uuid`` (both equal the relational UUIDs), never AGE-internal IDs.
|
|
19
|
+
|
|
20
|
+
This module defines only the Protocol and its return value objects; it imports
|
|
21
|
+
no DB driver beyond the connection type, so it stays a pure contract that fakes
|
|
22
|
+
can satisfy in unit tests.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Mapping, Sequence
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Any, Protocol, runtime_checkable
|
|
29
|
+
|
|
30
|
+
import psycopg
|
|
31
|
+
|
|
32
|
+
from ..schema import EntityMention, GraphEntity
|
|
33
|
+
|
|
34
|
+
__all__ = ["GraphBackend", "PersonScope", "TraversalHit"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class TraversalHit:
|
|
39
|
+
"""One entity reached by :meth:`GraphBackend.traverse`, with its best path.
|
|
40
|
+
|
|
41
|
+
``entity_uuid`` is the reached entity's durable id (= ``graph_entities.id``).
|
|
42
|
+
``affinity`` is the product of the winning path's edge weights
|
|
43
|
+
(normalized lift ∈ (0, 1]; a 1-hop neighbour's affinity is just that edge's
|
|
44
|
+
weight). ``hops`` is the length of the winning (highest-affinity) path. The
|
|
45
|
+
seed itself is never returned. ``tenant_id`` records the scope the traversal
|
|
46
|
+
ran under — every element of the path was verified to carry it.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
entity_uuid: str
|
|
50
|
+
affinity: float
|
|
51
|
+
hops: int
|
|
52
|
+
tenant_id: str = "default"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class PersonScope:
|
|
57
|
+
"""The scoped subgraph for "themes with X" expansion (spec §6b step 1).
|
|
58
|
+
|
|
59
|
+
Produced by :meth:`GraphBackend.scope_person`: starting from a seed person,
|
|
60
|
+
follow ``MENTIONED_IN`` into the person's documents and back out to the
|
|
61
|
+
entities co-mentioned in those documents. ``entity_uuids`` is the distinct
|
|
62
|
+
set of co-mentioned entities (the seed excluded); ``document_uuids`` is the
|
|
63
|
+
distinct set of connecting documents. Both are tenant-scoped. The grouping
|
|
64
|
+
stage (G2) consumes this set; SQL provides the lift weights and evidence.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
seed_entity_uuid: str
|
|
68
|
+
entity_uuids: tuple[str, ...]
|
|
69
|
+
document_uuids: tuple[str, ...]
|
|
70
|
+
tenant_id: str = "default"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@runtime_checkable
|
|
74
|
+
class GraphBackend(Protocol):
|
|
75
|
+
"""Tenant-scoped graph storage + bounded traversal (spec §4 D10, §8).
|
|
76
|
+
|
|
77
|
+
Implementations own a single named graph and translate these calls into
|
|
78
|
+
their engine's native operations. All write methods are idempotent: calling
|
|
79
|
+
them twice with the same arguments converges to the same graph state (the
|
|
80
|
+
relational source-of-truth is authoritative; the graph is a recomputable
|
|
81
|
+
mirror, spec §7).
|
|
82
|
+
|
|
83
|
+
Connection contract: every method takes the caller's ``conn`` so the caller
|
|
84
|
+
owns the transaction (reconcile in G1 wraps the relational rewrite and the
|
|
85
|
+
graph sync in one unit). The connection MUST already have the engine's
|
|
86
|
+
session bootstrap applied (for AGE: ``LOAD 'age'`` via
|
|
87
|
+
:func:`brain.db.connect_age` / :func:`brain.db.load_age`). :meth:`bootstrap`
|
|
88
|
+
additionally requires an autocommit connection (graph/label DDL).
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def bootstrap(self, conn: psycopg.Connection[Any]) -> None:
|
|
92
|
+
"""Idempotently provision the graph, its labels, and property indexes.
|
|
93
|
+
|
|
94
|
+
Ensures the graph exists, creates the vertex labels (``Entity``,
|
|
95
|
+
``Document``) and edge labels (``MENTIONED_IN``, ``CO_OCCURS``), and
|
|
96
|
+
builds the property indexes (spec §5b). Safe to re-run. Requires an
|
|
97
|
+
autocommit connection (catalog DDL).
|
|
98
|
+
"""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
def upsert_entities(
|
|
102
|
+
self,
|
|
103
|
+
conn: psycopg.Connection[Any],
|
|
104
|
+
tenant_id: str,
|
|
105
|
+
entities: Sequence[GraphEntity],
|
|
106
|
+
) -> int:
|
|
107
|
+
"""MERGE ``Entity`` vertices keyed on ``(tenant_id, entity_uuid)``.
|
|
108
|
+
|
|
109
|
+
Creates missing vertices and updates the mutable properties
|
|
110
|
+
(``name``/``entity_type``/``canonical_key``) of existing ones.
|
|
111
|
+
Embeddings stay relational (spec §5) and are never written here. Returns
|
|
112
|
+
the number of entities processed.
|
|
113
|
+
"""
|
|
114
|
+
...
|
|
115
|
+
|
|
116
|
+
def upsert_mention_edges(
|
|
117
|
+
self,
|
|
118
|
+
conn: psycopg.Connection[Any],
|
|
119
|
+
tenant_id: str,
|
|
120
|
+
document_id: str,
|
|
121
|
+
mentions: Sequence[EntityMention],
|
|
122
|
+
*,
|
|
123
|
+
document_props: Mapping[str, Any] | None = None,
|
|
124
|
+
) -> int:
|
|
125
|
+
"""Rebuild one document's ``MENTIONED_IN`` edges (split MERGE; spec §7.3).
|
|
126
|
+
|
|
127
|
+
MERGEs the ``Document`` vertex and each mentioned ``Entity`` vertex on
|
|
128
|
+
their own, then deletes and recreates this document's ``MENTIONED_IN``
|
|
129
|
+
edges. Never MERGEs the whole ``(e)-[:MENTIONED_IN]->(d)`` pattern in
|
|
130
|
+
one clause — AGE re-creates the endpoint vertices when it does. Returns
|
|
131
|
+
the number of mention edges written.
|
|
132
|
+
"""
|
|
133
|
+
...
|
|
134
|
+
|
|
135
|
+
def refresh_cooccur_edges(
|
|
136
|
+
self,
|
|
137
|
+
conn: psycopg.Connection[Any],
|
|
138
|
+
tenant_id: str,
|
|
139
|
+
) -> int:
|
|
140
|
+
"""Rematerialize the tenant's ``CO_OCCURS`` edges from the relational mirror.
|
|
141
|
+
|
|
142
|
+
Deletes the tenant's existing ``CO_OCCURS`` edges and recreates them
|
|
143
|
+
from ``graph_relationships`` (the derived aggregate mirror, recomputed
|
|
144
|
+
from contributions upstream; spec §5/§7). Full recompute is correct
|
|
145
|
+
because aggregates derive from the source-of-truth. Returns the number
|
|
146
|
+
of aggregate edges written.
|
|
147
|
+
"""
|
|
148
|
+
...
|
|
149
|
+
|
|
150
|
+
def traverse(
|
|
151
|
+
self,
|
|
152
|
+
conn: psycopg.Connection[Any],
|
|
153
|
+
tenant_id: str,
|
|
154
|
+
seed_entity_uuid: str,
|
|
155
|
+
*,
|
|
156
|
+
depth: int,
|
|
157
|
+
frontier_cap: int,
|
|
158
|
+
min_edge_weight: float = 0.0,
|
|
159
|
+
) -> list[TraversalHit]:
|
|
160
|
+
"""Bounded variable-length ``CO_OCCURS`` traversal from a seed (spec §6a).
|
|
161
|
+
|
|
162
|
+
Walks ``CO_OCCURS`` paths of length 1..``depth`` from the seed, enforces
|
|
163
|
+
``tenant_id`` on every vertex and edge, scores each reached entity by
|
|
164
|
+
path affinity (product of edge weights) keeping the best path per
|
|
165
|
+
entity, drops paths with any edge below ``min_edge_weight``, and caps
|
|
166
|
+
the frontier. The seed itself is never returned. Returns hits ordered by
|
|
167
|
+
affinity descending, ties broken deterministically.
|
|
168
|
+
|
|
169
|
+
**Complete-or-failure contract:** the cap is applied *after* affinity
|
|
170
|
+
scoring, so an implementation must score every within-depth path rather
|
|
171
|
+
than truncate before scoring. To stay bounded it MUST detect when more
|
|
172
|
+
paths exist than it can safely score and raise :class:`GraphBackendError`
|
|
173
|
+
rather than returning a silently-truncated (possibly wrong) result — a
|
|
174
|
+
result is always either complete/correct or a loud failure.
|
|
175
|
+
"""
|
|
176
|
+
...
|
|
177
|
+
|
|
178
|
+
def scope_person(
|
|
179
|
+
self,
|
|
180
|
+
conn: psycopg.Connection[Any],
|
|
181
|
+
tenant_id: str,
|
|
182
|
+
seed_entity_uuid: str,
|
|
183
|
+
*,
|
|
184
|
+
frontier_cap: int,
|
|
185
|
+
) -> PersonScope:
|
|
186
|
+
"""Scope a person to co-mentioned entities + documents (spec §6b step 1).
|
|
187
|
+
|
|
188
|
+
Tenant-scoped: ``person -> MENTIONED_IN -> Document <- MENTIONED_IN <-
|
|
189
|
+
co-entity``, excluding the seed. Returns the distinct co-mentioned
|
|
190
|
+
entity set and the connecting document set, deterministically ordered.
|
|
191
|
+
|
|
192
|
+
**Bounded ranked-truncation contract:** when more than ``frontier_cap``
|
|
193
|
+
co-mention rows exist the implementation MUST keep the STRONGEST
|
|
194
|
+
``frontier_cap`` worth of scope — co-entities ranked by co-mention
|
|
195
|
+
frequency with the seed (ties → newest shared document → entity id, a
|
|
196
|
+
total order for reproducibility) — rather than crash, so the headline
|
|
197
|
+
themes/audio surfaces stay usable for hub people. The truncation MUST
|
|
198
|
+
stay within the same bound the cap guaranteed (≤ ``frontier_cap`` rows
|
|
199
|
+
reach downstream) and MUST log a single actionable WARNING naming the
|
|
200
|
+
``BRAIN_GRAPH_FRONTIER_CAP`` knob — never a silent partial result.
|
|
201
|
+
"""
|
|
202
|
+
...
|
|
203
|
+
|
|
204
|
+
def detach_delete_entities(
|
|
205
|
+
self,
|
|
206
|
+
conn: psycopg.Connection[Any],
|
|
207
|
+
tenant_id: str,
|
|
208
|
+
entity_uuids: Sequence[str],
|
|
209
|
+
) -> int:
|
|
210
|
+
"""``DETACH DELETE`` specific ``Entity`` vertices for a tenant (spec §7.4).
|
|
211
|
+
|
|
212
|
+
The orphan-GC primitive the reconcile layer (G1) calls after the
|
|
213
|
+
relational source-of-truth has dropped a person's last mention: the
|
|
214
|
+
catalog row is gone, so its now-bare AGE vertex (no remaining
|
|
215
|
+
``MENTIONED_IN`` / ``CO_OCCURS`` edges) must be removed too, or the
|
|
216
|
+
graph drifts from the relational mirror. Only the listed
|
|
217
|
+
``entity_uuids`` carrying ``tenant_id`` are removed; an empty sequence
|
|
218
|
+
is a no-op. Returns the number of vertices actually deleted (reflects
|
|
219
|
+
reality — a uuid with no matching vertex does not inflate the count).
|
|
220
|
+
Idempotent: re-deleting an already-gone vertex matches nothing and
|
|
221
|
+
contributes zero.
|
|
222
|
+
"""
|
|
223
|
+
...
|
|
224
|
+
|
|
225
|
+
def detach_delete_documents(
|
|
226
|
+
self,
|
|
227
|
+
conn: psycopg.Connection[Any],
|
|
228
|
+
tenant_id: str,
|
|
229
|
+
document_uuids: Sequence[str],
|
|
230
|
+
) -> int:
|
|
231
|
+
"""``DETACH DELETE`` specific ``Document`` vertices for a tenant (spec §7).
|
|
232
|
+
|
|
233
|
+
The document-removal primitive the reconcile layer (G1) calls from
|
|
234
|
+
``remove_document`` (and from ``reconcile_document`` when an edit drops a
|
|
235
|
+
document to zero person mentions): ``DETACH DELETE`` removes the matched
|
|
236
|
+
``Document`` vertices together with their attached ``MENTIONED_IN``
|
|
237
|
+
edges. Only the listed ``document_uuids`` carrying ``tenant_id`` are
|
|
238
|
+
removed; an empty sequence is a no-op. Returns the number of vertices
|
|
239
|
+
actually deleted. Idempotent.
|
|
240
|
+
"""
|
|
241
|
+
...
|
|
242
|
+
|
|
243
|
+
def clear_tenant(self, conn: psycopg.Connection[Any], tenant_id: str) -> int:
|
|
244
|
+
"""Atomically clear ALL of one tenant's vertices + edges (spec §7 rebuild).
|
|
245
|
+
|
|
246
|
+
``DETACH DELETE`` every ``Entity`` and ``Document`` vertex carrying
|
|
247
|
+
``tenant_id`` (which removes their attached ``MENTIONED_IN`` /
|
|
248
|
+
``CO_OCCURS`` edges) — including any AGE-only state with no relational
|
|
249
|
+
counterpart (a ``Document`` vertex for a doc removed from the relational
|
|
250
|
+
source, a stale AGE-only ``Entity``). The clear-then-rebuild primitive
|
|
251
|
+
the ``brain graphrag build --force`` path calls FIRST so the AGE mirror
|
|
252
|
+
is rebuilt fresh from the relational source-of-truth with no stale
|
|
253
|
+
survivors. Other tenants are untouched even when they share
|
|
254
|
+
``entity_uuid`` / ``document_uuid`` values (the tenant property is matched
|
|
255
|
+
on every vertex). Atomic — runs inside its own ``conn.transaction()`` (a
|
|
256
|
+
real transaction on an autocommit connection, a SAVEPOINT when nested),
|
|
257
|
+
so a mid-clear failure rolls back wholesale rather than leaving a
|
|
258
|
+
half-cleared mirror. Returns the number of vertices deleted.
|
|
259
|
+
"""
|
|
260
|
+
...
|
|
261
|
+
|
|
262
|
+
def drop_graph(self, conn: psycopg.Connection[Any], tenant_id: str) -> int:
|
|
263
|
+
"""Tenant-aware teardown: remove all of one tenant's vertices + edges.
|
|
264
|
+
|
|
265
|
+
``DETACH DELETE`` every ``Entity`` and ``Document`` vertex carrying
|
|
266
|
+
``tenant_id`` (which removes their attached edges). Other tenants are
|
|
267
|
+
untouched. Returns the number of vertices deleted. Equivalent to
|
|
268
|
+
:meth:`clear_tenant` (teardown vs clear-before-rebuild are the same
|
|
269
|
+
operation); kept as a distinct, intention-revealing name for decommission
|
|
270
|
+
callers.
|
|
271
|
+
"""
|
|
272
|
+
...
|
brain/graph_rag/build.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Corpus-wide people-aspect graph build driver (wave G1-d, GraphRAG, spec §9/§12).
|
|
2
|
+
|
|
3
|
+
The batch equivalent of the per-document reconcile hook (wave G1-c): walk a
|
|
4
|
+
stream of document ids and reconcile each one's people aspect into the graph.
|
|
5
|
+
Backs ``brain graphrag build --backfill`` — the one-shot way to populate (or
|
|
6
|
+
re-derive) the people graph for a corpus that was ingested before graph sync was
|
|
7
|
+
enabled, or to propagate a corpus-wide config change.
|
|
8
|
+
|
|
9
|
+
**Single source of truth for the per-doc logic.** :func:`build_graph` calls the
|
|
10
|
+
SAME :func:`brain.graph_rag.reconcile.reconcile_document` the incremental hook
|
|
11
|
+
uses, with the SAME immutable :class:`~brain.graph_rag.reconcile.ReconcileConfig`
|
|
12
|
+
— so a batched backfill produces a graph identical to having reconciled each
|
|
13
|
+
document one-by-one as it was ingested. No co-occurrence / weighting / AGE-sync
|
|
14
|
+
logic is reimplemented here; this module only iterates and tallies.
|
|
15
|
+
|
|
16
|
+
**Resumability (idempotent via the watermark).** ``reconcile_document`` skips a
|
|
17
|
+
document whose per-aspect ``graph_index_state`` watermark is unchanged, so a
|
|
18
|
+
build is safe to re-run and to resume after an interruption: re-running revisits
|
|
19
|
+
the documents in the same ascending-id order (see
|
|
20
|
+
:func:`brain.queries.iter_all_document_ids`) and the already-indexed prefix is
|
|
21
|
+
skipped cheaply (one ``SELECT`` per doc). This only holds when each document
|
|
22
|
+
commits independently, which it does when the caller passes an **autocommit**
|
|
23
|
+
connection — ``reconcile_document`` then opens its own top-level transaction per
|
|
24
|
+
document (see the reconcile module's "Connection contract"). The
|
|
25
|
+
``brain graphrag build`` CLI sets ``conn.autocommit = True`` before calling this.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from collections.abc import Callable, Iterable
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import psycopg
|
|
34
|
+
|
|
35
|
+
from ..errors import GraphReconcileError
|
|
36
|
+
from .aggregates import (
|
|
37
|
+
_gc_orphan_concepts,
|
|
38
|
+
_gc_orphan_persons,
|
|
39
|
+
_recompute_aggregates,
|
|
40
|
+
refresh_aggregates,
|
|
41
|
+
)
|
|
42
|
+
from .backends.base import GraphBackend
|
|
43
|
+
from .extract import EntityExtractor
|
|
44
|
+
from .person_resolver import prebuilt_directory_resolver
|
|
45
|
+
from .reconcile import ReconcileConfig, ReconcileResult, reconcile_document
|
|
46
|
+
from .schema import GraphEntity
|
|
47
|
+
|
|
48
|
+
__all__ = ["BuildResult", "ProgressCallback", "build_graph"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class BuildResult:
|
|
53
|
+
"""Tally of a :func:`build_graph` run.
|
|
54
|
+
|
|
55
|
+
``processed`` is the number of documents reconcile was attempted on (bounded
|
|
56
|
+
by ``limit``); ``reconciled`` is how many did graph work; ``skipped`` is how
|
|
57
|
+
many short-circuited on an unchanged watermark (``reconciled + skipped ==
|
|
58
|
+
processed``); ``orphans_removed`` is the total zero-mention entity vertices
|
|
59
|
+
GC'd across the run; ``relationship_count`` is the number of tenant
|
|
60
|
+
``graph_relationships`` edges materialized by the post-loop refresh (0 when no
|
|
61
|
+
document did work, so no refresh ran). Because the per-document reconcile
|
|
62
|
+
defers the tenant-wide aggregate recompute + orphan GC + AGE ``CO_OCCURS``
|
|
63
|
+
rebuild, ``orphans_removed`` and ``relationship_count`` are sourced from the
|
|
64
|
+
single post-loop :func:`~brain.graph_rag.aggregates.refresh_aggregates`, not
|
|
65
|
+
summed per document.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
processed: int = 0
|
|
69
|
+
reconciled: int = 0
|
|
70
|
+
skipped: int = 0
|
|
71
|
+
orphans_removed: int = 0
|
|
72
|
+
relationship_count: int = 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Invoked after each document with ``(processed_count, document_id, result)`` so
|
|
76
|
+
# a CLI caller can throttle progress output without this module owning any I/O.
|
|
77
|
+
ProgressCallback = Callable[[int, str, ReconcileResult], None]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_graph(
|
|
81
|
+
conn: psycopg.Connection[Any],
|
|
82
|
+
document_ids: Iterable[str],
|
|
83
|
+
*,
|
|
84
|
+
backend: GraphBackend,
|
|
85
|
+
config: ReconcileConfig,
|
|
86
|
+
limit: int | None = None,
|
|
87
|
+
progress: ProgressCallback | None = None,
|
|
88
|
+
extractor: EntityExtractor | None = None,
|
|
89
|
+
force: bool = False,
|
|
90
|
+
) -> BuildResult:
|
|
91
|
+
"""Reconcile each document's people aspect into the graph (batch backfill).
|
|
92
|
+
|
|
93
|
+
Iterates ``document_ids`` (already ordered by the caller — ascending id for
|
|
94
|
+
a resumable build), calling :func:`reconcile_document` on each with the
|
|
95
|
+
shared ``config`` + ``backend``. Stops after ``limit`` documents when set
|
|
96
|
+
(caps the corpus for testing / partial runs). Returns a :class:`BuildResult`
|
|
97
|
+
tally; ``progress`` (when given) is called once per processed document.
|
|
98
|
+
|
|
99
|
+
**Deferred whole-tenant refresh (perf).** Every per-document reconcile runs
|
|
100
|
+
with ``defer_tenant_refresh=True``, so the tenant-wide derived layers — the
|
|
101
|
+
``graph_relationships`` recompute, the orphan GC, and the AGE ``CO_OCCURS``
|
|
102
|
+
rematerialization — are skipped per document and recomputed ONCE after the
|
|
103
|
+
loop via :func:`~brain.graph_rag.aggregates.refresh_aggregates` (only when at
|
|
104
|
+
least one document did work). The AGE ``CO_OCCURS`` rebuild is a whole-tenant
|
|
105
|
+
operation whose cost scales with the tenant's relationship count R, so running
|
|
106
|
+
it per document made a corpus build O(docs × R); hoisting it to a single final
|
|
107
|
+
pass makes the build O(docs × doc_entities + R) while producing the identical
|
|
108
|
+
end state (the derived layers are a deterministic full recompute from the
|
|
109
|
+
per-document relational source-of-truth, which IS still written per document).
|
|
110
|
+
Applies to both the plain backfill and the ``force`` rebuild below.
|
|
111
|
+
|
|
112
|
+
**Directory index hoist (perf, Fix B).** The People-Hub directory index
|
|
113
|
+
(``directory_entries``) drives person resolution but is corpus-wide and does
|
|
114
|
+
NOT change during a build, so this driver builds it ONCE before the loop and
|
|
115
|
+
injects it through reconcile's ``person_resolver`` DI seam (via
|
|
116
|
+
:func:`brain.graph_rag.person_resolver.prebuilt_directory_resolver`) — instead
|
|
117
|
+
of every per-document reconcile rebuilding the ~1.2k-row ``SELECT`` + dict.
|
|
118
|
+
The incremental ingest hook (``sync.py``) is unaffected: it uses the default
|
|
119
|
+
resolver, which builds its own single-document index.
|
|
120
|
+
|
|
121
|
+
The connection SHOULD be autocommit so each document commits on its own,
|
|
122
|
+
making the build resumable after an interruption (see the module docstring).
|
|
123
|
+
A non-autocommit connection still works but the whole run becomes one
|
|
124
|
+
transaction — an interruption then rolls everything back.
|
|
125
|
+
|
|
126
|
+
``extractor`` is the concept-aspect DI seam (wave G2-c): when
|
|
127
|
+
``config.concepts_enabled`` is True it MUST be provided (production passes
|
|
128
|
+
:func:`brain.graph_rag.extract.make_extractor`; tests pass a fake), and each
|
|
129
|
+
document's concept aspect is (re)indexed alongside its person aspect under its
|
|
130
|
+
own watermark. When concepts are disabled (the default) it is ignored and the
|
|
131
|
+
build is person-only — fully backward compatible. The force pre-pass GCs
|
|
132
|
+
orphans of BOTH aspects + restores ALL of the tenant's entity vertices (any
|
|
133
|
+
type), so a force rebuild faithfully restores any existing concept graph from
|
|
134
|
+
the relational source-of-truth even without re-extraction.
|
|
135
|
+
|
|
136
|
+
``force=True`` is the authoritative **clean-then-rebuild** recovery path
|
|
137
|
+
(``brain graphrag build --force``) that produces a clean state in BOTH the
|
|
138
|
+
relational source AND the AGE mirror. It:
|
|
139
|
+
|
|
140
|
+
1. cleans the RELATIONAL source for the tenant — recompute
|
|
141
|
+
``graph_relationships`` from the current contributions (drops aggregate
|
|
142
|
+
rows stranded by deleted docs) and GC orphan entities (zero remaining
|
|
143
|
+
mentions); reuses :func:`~brain.graph_rag.aggregates._recompute_aggregates`
|
|
144
|
+
+ :func:`~brain.graph_rag.aggregates._gc_orphan_persons`. This is what
|
|
145
|
+
makes the zero-doc / all-docs-deleted case authoritative (no per-doc
|
|
146
|
+
reconcile runs there to clean up);
|
|
147
|
+
2. clears the tenant's ENTIRE existing AGE mirror
|
|
148
|
+
(:meth:`~brain.graph_rag.backends.base.GraphBackend.clear_tenant`), so no
|
|
149
|
+
stale survivors remain — a ``Document`` vertex for a doc removed from the
|
|
150
|
+
relational source, an AGE-only ``Entity`` with no catalog row, orphan
|
|
151
|
+
edges;
|
|
152
|
+
3. restores every (now non-orphan) tenant ``Entity`` vertex from the clean
|
|
153
|
+
relational catalog (the pre-pass that lets the per-doc full-tenant
|
|
154
|
+
``CO_OCCURS`` rematerialization find its endpoints); then
|
|
155
|
+
4. threads :func:`reconcile_document`'s force flag through every document,
|
|
156
|
+
bypassing the per-aspect watermark so each doc is fully re-reconciled and
|
|
157
|
+
its watermark rewritten.
|
|
158
|
+
|
|
159
|
+
Net result: BOTH stores are clean — no orphan entities and
|
|
160
|
+
``graph_relationships`` (relational + the AGE ``CO_OCCURS`` mirror) reflect
|
|
161
|
+
only current contributions — and the AGE mirror is rebuilt fresh from the
|
|
162
|
+
relational source-of-truth (entities + ``MENTIONED_IN`` + ``Document``
|
|
163
|
+
vertices + ``CO_OCCURS``). A zero-document corpus ends empty in both stores.
|
|
164
|
+
No document short-circuits, so ``reconciled == processed`` and
|
|
165
|
+
``skipped == 0``.
|
|
166
|
+
|
|
167
|
+
``force`` is incompatible with ``limit`` (a clear-then-partial-rebuild would
|
|
168
|
+
permanently lose the un-rebuilt remainder) — passing both raises
|
|
169
|
+
:class:`brain.errors.GraphReconcileError` (the CLI rejects it earlier as a
|
|
170
|
+
``BadParameter``). A zero-document corpus + ``force`` clears the tenant to an
|
|
171
|
+
empty graph (correct).
|
|
172
|
+
"""
|
|
173
|
+
if force and limit is not None:
|
|
174
|
+
# A clear-then-partial-rebuild is incoherent: the clear wipes the whole
|
|
175
|
+
# tenant mirror, but a limited loop would only rebuild a prefix, leaving
|
|
176
|
+
# the rest permanently missing. Reject the combination (the CLI surfaces
|
|
177
|
+
# this as a BadParameter before reaching here).
|
|
178
|
+
raise GraphReconcileError(
|
|
179
|
+
"build_graph(force=True) rebuilds the full corpus and cannot be "
|
|
180
|
+
"combined with limit (a clear-then-partial-rebuild is incoherent)"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if force:
|
|
184
|
+
# Authoritative rebuild = a clean state in BOTH stores (spec §7), in this
|
|
185
|
+
# order: clean the relational source, then clear + rebuild the AGE mirror
|
|
186
|
+
# FROM that clean source.
|
|
187
|
+
#
|
|
188
|
+
# The pre-pass (steps 1-3) runs inside ONE outer transaction so it is
|
|
189
|
+
# atomic AS A UNIT: a failure during the restore (step 3), AFTER the AGE
|
|
190
|
+
# mirror was cleared (step 2), rolls EVERYTHING back — relational + AGE
|
|
191
|
+
# are left exactly as they were before the --force call. A recovery
|
|
192
|
+
# command must never leave the graph in a worse (cleared / half-restored)
|
|
193
|
+
# state than it started. ``clear_tenant`` and ``upsert_entities`` open
|
|
194
|
+
# their own ``conn.transaction()``, which nest here as SAVEPOINTs, and
|
|
195
|
+
# each AGE primitive activates its own ``ag_catalog`` search_path for the
|
|
196
|
+
# duration of its statements; the relational steps run on the default
|
|
197
|
+
# search_path. The per-doc reconcile LOOP below stays incremental and
|
|
198
|
+
# resumable (each document commits in its own transaction) — only the
|
|
199
|
+
# clear/restore must be atomic together.
|
|
200
|
+
with conn.transaction():
|
|
201
|
+
# 1. RELATIONAL clean: recompute graph_relationships from the CURRENT
|
|
202
|
+
# contributions (drops aggregate rows stranded by deleted docs) and
|
|
203
|
+
# GC orphan entities (zero remaining mentions). Reuses the same
|
|
204
|
+
# aggregate helpers as reconcile/refresh — no duplicated logic.
|
|
205
|
+
# This is what makes the zero-doc / all-docs-deleted case
|
|
206
|
+
# authoritative: there is no per-doc reconcile then to GC/recompute,
|
|
207
|
+
# so the stale relational rows would otherwise survive (and be
|
|
208
|
+
# mirrored into AGE by the pre-pass below).
|
|
209
|
+
_recompute_aggregates(conn, config.tenant_id, config.generic_df_ratio)
|
|
210
|
+
# GC orphans of BOTH aspects (concept GC is a no-op when no concept
|
|
211
|
+
# entities exist, so this is safe for the person-only default too).
|
|
212
|
+
_gc_orphan_persons(conn, config.tenant_id)
|
|
213
|
+
_gc_orphan_concepts(conn, config.tenant_id)
|
|
214
|
+
# 2. Clear the tenant's entire AGE mirror so nothing stale survives — a
|
|
215
|
+
# Document vertex for a doc removed from the relational source, an
|
|
216
|
+
# AGE-only Entity with no catalog row, orphan edges.
|
|
217
|
+
backend.clear_tenant(conn, config.tenant_id)
|
|
218
|
+
# 3. Restore Entity vertices from the now-clean relational catalog. The
|
|
219
|
+
# GC in step 1 ran first, so orphan entities never reach AGE; this
|
|
220
|
+
# pre-pass lets each per-doc CO_OCCURS refresh bind its endpoints (a
|
|
221
|
+
# per-doc reconcile rematerializes the WHOLE tenant's CO_OCCURS,
|
|
222
|
+
# which requires every contribution-referenced Entity vertex to
|
|
223
|
+
# exist).
|
|
224
|
+
_restore_tenant_entity_vertices(conn, config.tenant_id, backend=backend)
|
|
225
|
+
|
|
226
|
+
# Perf Fix B (2026-05-24): build the People-Hub directory index ONCE for the
|
|
227
|
+
# whole batch and inject it through reconcile's person_resolver DI seam,
|
|
228
|
+
# instead of every per-document reconcile rebuilding it. The directory
|
|
229
|
+
# (``directory_entries``, ~1.2k rows) is corpus-wide and does NOT change
|
|
230
|
+
# during a build, so rebuilding the SELECT + dict per document is pure waste
|
|
231
|
+
# (~30-80 ms/doc). The incremental ingest hook (``sync.py``) keeps using the
|
|
232
|
+
# default resolver, which builds its own single-document index, so the
|
|
233
|
+
# one-doc path is unchanged. Late import keeps this module import-cheap
|
|
234
|
+
# (``build_people`` pulls in the wiki package), mirroring person_resolver's
|
|
235
|
+
# own late import of the same helper.
|
|
236
|
+
from ..wiki.build_people import _build_directory_index
|
|
237
|
+
|
|
238
|
+
directory = _build_directory_index(conn, sender_denylist=config.sender_denylist)
|
|
239
|
+
resolver = prebuilt_directory_resolver(directory)
|
|
240
|
+
|
|
241
|
+
processed = 0
|
|
242
|
+
reconciled = 0
|
|
243
|
+
skipped = 0
|
|
244
|
+
orphans_removed = 0
|
|
245
|
+
for document_id in document_ids:
|
|
246
|
+
if limit is not None and processed >= limit:
|
|
247
|
+
break
|
|
248
|
+
result = reconcile_document(
|
|
249
|
+
conn,
|
|
250
|
+
document_id,
|
|
251
|
+
backend=backend,
|
|
252
|
+
config=config,
|
|
253
|
+
person_resolver=resolver,
|
|
254
|
+
extractor=extractor,
|
|
255
|
+
force=force,
|
|
256
|
+
defer_tenant_refresh=True,
|
|
257
|
+
)
|
|
258
|
+
processed += 1
|
|
259
|
+
if result.skipped:
|
|
260
|
+
skipped += 1
|
|
261
|
+
else:
|
|
262
|
+
reconciled += 1
|
|
263
|
+
orphans_removed += result.orphans_removed
|
|
264
|
+
if progress is not None:
|
|
265
|
+
progress(processed, document_id, result)
|
|
266
|
+
|
|
267
|
+
# Each per-document reconcile above ran with ``defer_tenant_refresh=True``, so
|
|
268
|
+
# the tenant-wide derived layers — ``graph_relationships`` (normalized lift +
|
|
269
|
+
# generic suppression), the orphan GC of both aspects, the AGE ``CO_OCCURS``
|
|
270
|
+
# rematerialization, and the orphan-vertex DETACH DELETE — were skipped per
|
|
271
|
+
# document and are recomputed ONCE here from the now-complete relational
|
|
272
|
+
# source-of-truth. This is the SAME whole-tenant operation reconcile ran per
|
|
273
|
+
# document before (``refresh_aggregates`` reuses the identical aggregate + GC
|
|
274
|
+
# helpers), hoisted out of the loop: a corpus build drops from O(docs ×
|
|
275
|
+
# tenant_R) per-doc CO_OCCURS rebuilds to O(docs × doc_entities + tenant_R)
|
|
276
|
+
# (one final rebuild), and the end state is identical (a deterministic full
|
|
277
|
+
# recompute from the source-of-truth).
|
|
278
|
+
#
|
|
279
|
+
# Gate on ``reconciled > 0``: the refresh runs only when at least one document
|
|
280
|
+
# changed the relational source (so the derived layers are stale). When every
|
|
281
|
+
# document short-circuited on its watermark (an idempotent re-run, or a plain
|
|
282
|
+
# ``--backfill`` over an unchanged corpus) the derived layers are already
|
|
283
|
+
# consistent from the build that did the work, so skipping the refresh keeps
|
|
284
|
+
# the re-run a true no-op AND avoids a spurious ``refresh_cooccur_edges`` that
|
|
285
|
+
# would raise against a deliberately-dropped AGE mirror (the recovery path for
|
|
286
|
+
# that is ``--force``, which always reconciles).
|
|
287
|
+
#
|
|
288
|
+
# CRASH-WINDOW RECOVERY: if a deferred build is interrupted AFTER the last
|
|
289
|
+
# document's watermark is written but BEFORE this final refresh runs, a
|
|
290
|
+
# same-command rerun all-skips (``reconciled == 0``) and will NOT repair the
|
|
291
|
+
# derived layer (``graph_relationships`` / AGE ``CO_OCCURS``) — run
|
|
292
|
+
# ``brain graphrag refresh`` (or ``brain graphrag build --force``) to repair.
|
|
293
|
+
# The relational source-of-truth stays correct throughout.
|
|
294
|
+
relationship_count = 0
|
|
295
|
+
if reconciled > 0:
|
|
296
|
+
refresh = refresh_aggregates(conn, backend=backend, config=config)
|
|
297
|
+
relationship_count = refresh.relationship_count
|
|
298
|
+
orphans_removed += refresh.orphans_removed
|
|
299
|
+
return BuildResult(
|
|
300
|
+
processed=processed,
|
|
301
|
+
reconciled=reconciled,
|
|
302
|
+
skipped=skipped,
|
|
303
|
+
orphans_removed=orphans_removed,
|
|
304
|
+
relationship_count=relationship_count,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _restore_tenant_entity_vertices(
|
|
309
|
+
conn: psycopg.Connection[Any], tenant_id: str, *, backend: GraphBackend
|
|
310
|
+
) -> int:
|
|
311
|
+
"""Re-MERGE every relational ``graph_entities`` row for a tenant into AGE.
|
|
312
|
+
|
|
313
|
+
The force-rebuild pre-pass (spec §7 authoritative rebuild). A per-document
|
|
314
|
+
:func:`reconcile_document` recreates the doc's own entity vertices but then
|
|
315
|
+
rematerializes the WHOLE tenant's ``CO_OCCURS`` edges, which requires *every*
|
|
316
|
+
contribution-referenced entity to already have an AGE vertex
|
|
317
|
+
(:meth:`~brain.graph_rag.backends.base.GraphBackend.refresh_cooccur_edges`
|
|
318
|
+
raises if an endpoint vertex is missing). After a dropped or corrupted AGE
|
|
319
|
+
mirror that invariant is broken — the relational catalog still lists every
|
|
320
|
+
entity but no AGE vertex exists — so the first forced doc's cooccur refresh
|
|
321
|
+
would fail to bind an endpoint that belongs to a not-yet-processed doc.
|
|
322
|
+
Restoring all of the tenant's Entity vertices up front (an idempotent MERGE)
|
|
323
|
+
re-establishes the invariant, so each subsequent forced per-doc reconcile
|
|
324
|
+
rebuilds mentions / MENTIONED_IN / Document vertices / CO_OCCURS exactly as
|
|
325
|
+
the incremental path would. Returns the number of entity vertices restored.
|
|
326
|
+
"""
|
|
327
|
+
rows = conn.execute(
|
|
328
|
+
"SELECT id::text, entity_type, name, canonical_key "
|
|
329
|
+
"FROM graph_entities WHERE tenant_id = %s",
|
|
330
|
+
(tenant_id,),
|
|
331
|
+
).fetchall()
|
|
332
|
+
entities = [
|
|
333
|
+
GraphEntity(
|
|
334
|
+
id=str(row[0]),
|
|
335
|
+
entity_type=str(row[1]),
|
|
336
|
+
name=str(row[2]),
|
|
337
|
+
canonical_key=str(row[3]),
|
|
338
|
+
tenant_id=tenant_id,
|
|
339
|
+
)
|
|
340
|
+
for row in rows
|
|
341
|
+
]
|
|
342
|
+
if entities:
|
|
343
|
+
backend.upsert_entities(conn, tenant_id, entities)
|
|
344
|
+
return len(entities)
|