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
brain/graph_rag/sync.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Post-write / post-delete graph reconcile hook (wave G1-c, GraphRAG, spec §7/§10).
|
|
2
|
+
|
|
3
|
+
Bridges the document write/delete paths to the people-aspect reconcile in
|
|
4
|
+
:mod:`brain.graph_rag.reconcile`. One :class:`GraphSyncer` is built per CLI / MCP
|
|
5
|
+
/ watcher invocation from the resolved :class:`brain.config.Config` and threaded
|
|
6
|
+
into ``ingest_document`` / ``update_document`` / ``sync_vault`` and the explicit
|
|
7
|
+
delete sites, so a single immutable
|
|
8
|
+
:class:`brain.graph_rag.reconcile.ReconcileConfig` is shared across *every*
|
|
9
|
+
write and delete in that process. That shared object is the divergence-safety
|
|
10
|
+
guarantee G1-b flagged: a build and a later delete can never recompute the
|
|
11
|
+
tenant aggregate with a different ``generic_df_ratio`` (which would corrupt the
|
|
12
|
+
generic-suppression weights).
|
|
13
|
+
|
|
14
|
+
**Discipline (mirrors the Q1-D enrich hook).** Graph sync is best-effort and
|
|
15
|
+
MUST NOT block or crash an ingest / edit / delete:
|
|
16
|
+
|
|
17
|
+
* it runs only when graph sync is enabled (``BRAIN_GRAPH_ENABLED``) AND the
|
|
18
|
+
database actually ships Apache AGE (``age_extension_available`` is a safe
|
|
19
|
+
``False`` on a stock pgvector DB -- e.g. the prod DB before the AGE cut-over),
|
|
20
|
+
* it provisions the graph labels/indexes lazily on first use (idempotent;
|
|
21
|
+
guarded by the connection being in autocommit mode, since AGE catalog DDL
|
|
22
|
+
needs it), and
|
|
23
|
+
* any failure is logged at WARNING with the document id and swallowed -- the
|
|
24
|
+
relational write already committed and the graph is a recomputable mirror
|
|
25
|
+
(``brain graphrag build`` rebuilds it). This is the *intended* degradation:
|
|
26
|
+
distinct from a swallowed bug, a graph-sync hiccup must never surface as an
|
|
27
|
+
ingest failure.
|
|
28
|
+
|
|
29
|
+
**Connection contract.** The caller's connection is reused (no per-document
|
|
30
|
+
connection churn). The hook is invoked at a point where the caller's write has
|
|
31
|
+
already committed and no transaction is open, so ``reconcile_document`` /
|
|
32
|
+
``remove_document`` open their own top-level transaction (real on an autocommit
|
|
33
|
+
connection, a true top-level one on an idle ``autocommit=False`` connection --
|
|
34
|
+
see the reconcile module's "Connection contract"). The connection must have AGE
|
|
35
|
+
loadable; :meth:`GraphSyncer._age_ready` runs ``LOAD 'age'`` itself, so callers
|
|
36
|
+
do not need :func:`brain.db.connect_age`.
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import logging
|
|
41
|
+
from functools import cache
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
import psycopg
|
|
45
|
+
|
|
46
|
+
from ..config import Config
|
|
47
|
+
from ..db import age_extension_available, load_age
|
|
48
|
+
from .backends.age import AgeBackend
|
|
49
|
+
from .backends.base import GraphBackend
|
|
50
|
+
from .extract import EntityExtractor
|
|
51
|
+
from .reconcile import ReconcileConfig, reconcile_document, remove_document
|
|
52
|
+
|
|
53
|
+
_logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
__all__ = ["GraphSyncer", "build_reconcile_config", "make_graph_syncer"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@cache
|
|
59
|
+
def build_reconcile_config(cfg: Config) -> ReconcileConfig:
|
|
60
|
+
"""Resolve the ONE :class:`ReconcileConfig` for a Config (cached -> shared).
|
|
61
|
+
|
|
62
|
+
``functools.cache`` keyed on the frozen, hashable :class:`brain.config.Config`
|
|
63
|
+
returns the *same* :class:`ReconcileConfig` instance for repeated calls in a
|
|
64
|
+
process, so a build and a later delete provably reconcile the tenant
|
|
65
|
+
aggregate with the identical ``generic_df_ratio`` / ``tenant_id`` (spec §7
|
|
66
|
+
divergence-safety). ``owner_keys`` reuses ``cfg.owner_participants`` -- the
|
|
67
|
+
corpus owner is stripped from the graph's person roster exactly as from the
|
|
68
|
+
People Hub.
|
|
69
|
+
"""
|
|
70
|
+
return ReconcileConfig(
|
|
71
|
+
tenant_id=cfg.graph_tenant_id,
|
|
72
|
+
cooccur_window=cfg.graph_cooccur_window,
|
|
73
|
+
max_entities_per_doc=cfg.graph_max_entities,
|
|
74
|
+
generic_df_ratio=cfg.graph_generic_df_ratio,
|
|
75
|
+
owner_keys=cfg.owner_participants,
|
|
76
|
+
sender_denylist=cfg.graph_sender_denylist,
|
|
77
|
+
concepts_enabled=cfg.graph_concepts,
|
|
78
|
+
graph_extract_stopwords=cfg.graph_extract_stopwords,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class GraphSyncer:
|
|
83
|
+
"""Best-effort people-aspect graph sync for one process invocation.
|
|
84
|
+
|
|
85
|
+
Holds the single shared :class:`ReconcileConfig` + the graph backend, and
|
|
86
|
+
exposes :meth:`reconcile` / :meth:`remove` -- both gated, both never-raise.
|
|
87
|
+
Construct via :func:`make_graph_syncer` (production) or directly (tests).
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
config: ReconcileConfig,
|
|
93
|
+
*,
|
|
94
|
+
enabled: bool,
|
|
95
|
+
backend: GraphBackend | None = None,
|
|
96
|
+
extractor: EntityExtractor | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
self._config = config
|
|
99
|
+
self._enabled = enabled
|
|
100
|
+
self._backend: GraphBackend = (
|
|
101
|
+
backend if backend is not None else AgeBackend()
|
|
102
|
+
)
|
|
103
|
+
# Concept-aspect DI seam (wave G2-c). Threaded into reconcile only; when
|
|
104
|
+
# ``config.concepts_enabled`` is True this MUST be set (``make_graph_syncer``
|
|
105
|
+
# builds it from the config). ``None`` keeps the syncer person-only.
|
|
106
|
+
self._extractor = extractor
|
|
107
|
+
# Lazy one-time label/index provisioning per syncer (per invocation).
|
|
108
|
+
self._bootstrapped = False
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def config(self) -> ReconcileConfig:
|
|
112
|
+
"""The single shared reconcile config (used by reconcile AND remove)."""
|
|
113
|
+
return self._config
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def enabled(self) -> bool:
|
|
117
|
+
"""True iff ``BRAIN_GRAPH_ENABLED`` resolved truthy for this process."""
|
|
118
|
+
return self._enabled
|
|
119
|
+
|
|
120
|
+
def reconcile(self, conn: psycopg.Connection[Any], document_id: str) -> None:
|
|
121
|
+
"""Reconcile one document into the people graph after a write.
|
|
122
|
+
|
|
123
|
+
Never raises -- a graph-sync failure is logged and swallowed so the
|
|
124
|
+
(already-committed) relational write is never undone.
|
|
125
|
+
"""
|
|
126
|
+
if not self._enabled:
|
|
127
|
+
return
|
|
128
|
+
try:
|
|
129
|
+
if not self._age_ready(conn):
|
|
130
|
+
return
|
|
131
|
+
reconcile_document(
|
|
132
|
+
conn,
|
|
133
|
+
document_id,
|
|
134
|
+
backend=self._backend,
|
|
135
|
+
config=self._config,
|
|
136
|
+
extractor=self._extractor,
|
|
137
|
+
)
|
|
138
|
+
# Bug A — cross-document concept type-collapse. Runs AFTER
|
|
139
|
+
# reconcile_document has written + committed this document's
|
|
140
|
+
# mentions/contributions (its own top-level transaction), so a
|
|
141
|
+
# zero-mention source GC inside the collapse can never orphan an id
|
|
142
|
+
# the pending mention-insert still references. Only meaningful when
|
|
143
|
+
# concepts are active (the catalog is otherwise person-only, which
|
|
144
|
+
# this never touches); idempotent + a cheap catalog scan no-op when
|
|
145
|
+
# nothing is fragmented. Late import keeps the person-only path from
|
|
146
|
+
# pulling the extractor transport at module load.
|
|
147
|
+
if self._config.concepts_enabled and self._extractor is not None:
|
|
148
|
+
from .cross_type import collapse_cross_type_concepts
|
|
149
|
+
|
|
150
|
+
collapse_cross_type_concepts(
|
|
151
|
+
conn,
|
|
152
|
+
self._config.tenant_id,
|
|
153
|
+
self._backend,
|
|
154
|
+
config=self._config,
|
|
155
|
+
)
|
|
156
|
+
except Exception as exc: # noqa: BLE001 -- best-effort: never block a write
|
|
157
|
+
_logger.warning(
|
|
158
|
+
"graph sync (reconcile) skipped for doc %s: %s; "
|
|
159
|
+
"the graph is recomputable via `brain graphrag build`",
|
|
160
|
+
document_id,
|
|
161
|
+
exc,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def remove(self, conn: psycopg.Connection[Any], document_id: str) -> None:
|
|
165
|
+
"""Remove one document from the people graph after a delete.
|
|
166
|
+
|
|
167
|
+
Never raises -- same best-effort discipline as :meth:`reconcile`.
|
|
168
|
+
"""
|
|
169
|
+
if not self._enabled:
|
|
170
|
+
return
|
|
171
|
+
try:
|
|
172
|
+
if not self._age_ready(conn):
|
|
173
|
+
return
|
|
174
|
+
remove_document(
|
|
175
|
+
conn, document_id, backend=self._backend, config=self._config
|
|
176
|
+
)
|
|
177
|
+
except Exception as exc: # noqa: BLE001 -- best-effort: never block a delete
|
|
178
|
+
_logger.warning(
|
|
179
|
+
"graph sync (remove) skipped for doc %s: %s; "
|
|
180
|
+
"the graph is recomputable via `brain graphrag build`",
|
|
181
|
+
document_id,
|
|
182
|
+
exc,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def _age_ready(self, conn: psycopg.Connection[Any]) -> bool:
|
|
186
|
+
"""Gate + lazily provision: True iff AGE is usable on ``conn``.
|
|
187
|
+
|
|
188
|
+
Returns ``False`` (caller skips) when the database does not ship AGE
|
|
189
|
+
(stock pgvector) or the extension is not yet installed/loadable. When
|
|
190
|
+
AGE is ready, lazily bootstraps the backend's labels + property indexes
|
|
191
|
+
once per syncer -- idempotent, and only attempted on an autocommit
|
|
192
|
+
connection (AGE catalog DDL needs it; the CLI / MCP write paths run
|
|
193
|
+
autocommit). On a non-autocommit connection the bootstrap is skipped and
|
|
194
|
+
reconcile relies on `brain init` having provisioned the graph; if it
|
|
195
|
+
hasn't, the reconcile attempt fails and is caught by the caller.
|
|
196
|
+
"""
|
|
197
|
+
if not age_extension_available(conn):
|
|
198
|
+
return False
|
|
199
|
+
if not load_age(conn):
|
|
200
|
+
return False
|
|
201
|
+
if not self._bootstrapped and conn.autocommit:
|
|
202
|
+
self._backend.bootstrap(conn)
|
|
203
|
+
self._bootstrapped = True
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def make_graph_syncer(
|
|
208
|
+
cfg: Config,
|
|
209
|
+
*,
|
|
210
|
+
backend: GraphBackend | None = None,
|
|
211
|
+
extractor: EntityExtractor | None = None,
|
|
212
|
+
) -> GraphSyncer:
|
|
213
|
+
"""Build the per-invocation :class:`GraphSyncer` from a resolved Config.
|
|
214
|
+
|
|
215
|
+
The single source of truth for wiring the graph sync hook. CLI / MCP /
|
|
216
|
+
watcher entry points call this once and thread the returned syncer through
|
|
217
|
+
every write/delete path so they all share one :class:`ReconcileConfig`.
|
|
218
|
+
|
|
219
|
+
When ``BRAIN_GRAPH_CONCEPTS`` is on (``cfg.graph_concepts``) and no
|
|
220
|
+
``extractor`` is injected, the concept-aspect extractor is built from the
|
|
221
|
+
config (:func:`brain.graph_rag.extract.make_extractor`, using
|
|
222
|
+
``BRAIN_GRAPH_EXTRACT_MODEL``); tests inject a fake instead. With concepts
|
|
223
|
+
off the extractor stays ``None`` and the syncer is person-only.
|
|
224
|
+
"""
|
|
225
|
+
syncer_extractor = extractor
|
|
226
|
+
if syncer_extractor is None and cfg.graph_concepts:
|
|
227
|
+
# Late import keeps this module import-cheap (extract pulls in the
|
|
228
|
+
# enrichment transport) for the common concepts-disabled path.
|
|
229
|
+
from .extract import make_extractor
|
|
230
|
+
|
|
231
|
+
syncer_extractor = make_extractor(cfg)
|
|
232
|
+
return GraphSyncer(
|
|
233
|
+
build_reconcile_config(cfg),
|
|
234
|
+
enabled=cfg.graph_enabled,
|
|
235
|
+
backend=backend,
|
|
236
|
+
extractor=syncer_extractor,
|
|
237
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Effective ``tenant_id`` resolution for GraphRAG surfaces (spec §9/§10)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..config import Config
|
|
5
|
+
from ..errors import GraphTenantError
|
|
6
|
+
|
|
7
|
+
__all__ = ["resolve_tenant"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resolve_tenant(cfg: Config, override: str | None = None) -> str:
|
|
11
|
+
"""Return the effective, validated ``tenant_id`` for a graph operation.
|
|
12
|
+
|
|
13
|
+
GraphRAG is multi-tenant (spec §9 D9): every relational row, AGE
|
|
14
|
+
vertex/edge property, and generated query is scoped by ``tenant_id``.
|
|
15
|
+
Centralizing the "override-else-default, validated non-empty" rule here is
|
|
16
|
+
the single source of truth the G2 retrieval CLI / MCP / themes paths call,
|
|
17
|
+
so they cannot each re-implement it (and drift).
|
|
18
|
+
|
|
19
|
+
Precedence: a non-blank ``override`` (trimmed) wins; otherwise the config
|
|
20
|
+
default ``cfg.graph_tenant_id`` (trimmed, ``BRAIN_GRAPH_TENANT``, itself
|
|
21
|
+
defaulting to ``"default"`` for single-user local use). A ``None`` / blank /
|
|
22
|
+
whitespace-only override is treated as "not supplied" and falls back to the
|
|
23
|
+
default — matching the existing ``brain graphrag build/refresh`` ``--tenant``
|
|
24
|
+
precedent in ``cli._graphrag_config`` — so a stray ``--tenant ""`` never
|
|
25
|
+
silently scopes a query to an empty tenant.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
GraphTenantError: the resolved tenant id is empty. Every tenant-scoped
|
|
29
|
+
row/vertex/edge/query needs a non-empty id (the schema's
|
|
30
|
+
``tenant_id TEXT NOT NULL`` invariant). With config validation this
|
|
31
|
+
only fires on a programmatically-constructed Config carrying a blank
|
|
32
|
+
``graph_tenant_id``, but the guard keeps the invariant local and
|
|
33
|
+
explicit.
|
|
34
|
+
"""
|
|
35
|
+
if override is not None and override.strip():
|
|
36
|
+
return override.strip()
|
|
37
|
+
resolved = cfg.graph_tenant_id.strip()
|
|
38
|
+
if not resolved:
|
|
39
|
+
raise GraphTenantError(
|
|
40
|
+
"effective tenant_id is empty: pass --tenant or set "
|
|
41
|
+
"BRAIN_GRAPH_TENANT to a non-empty value"
|
|
42
|
+
)
|
|
43
|
+
return resolved
|