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,583 @@
|
|
|
1
|
+
"""Curated entity alias/merge rules for the GraphRAG entity catalog."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import psycopg
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from brain.errors import GraphReconcileError
|
|
12
|
+
from brain.wiki._person_name import humanize_person_name
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ..backends.base import GraphBackend
|
|
16
|
+
from ..reconcile import ReconcileConfig
|
|
17
|
+
from ..schema import GraphEntity
|
|
18
|
+
|
|
19
|
+
_VALID_TYPES = frozenset({"person", "org", "project", "topic", "tool"})
|
|
20
|
+
|
|
21
|
+
# ``graph_entity_mentions.source`` value for person mentions — a mirror of
|
|
22
|
+
# :data:`brain.graph_rag.reconcile.PEOPLE_MENTION_SOURCE`. Duplicated as a plain
|
|
23
|
+
# literal (not imported) so this module stays import-cheap and free of the
|
|
24
|
+
# ``reconcile`` → ``aggregates`` → ``concepts`` chain (the same late-import
|
|
25
|
+
# discipline the rest of the module follows). Person mentions are *presence
|
|
26
|
+
# flags* (always ``mention_count = 1``); concept mentions carry real counts under
|
|
27
|
+
# an ``"extractor:<model>@<ver>"`` source — the two aspects merge differently
|
|
28
|
+
# (see :func:`_repoint_mentions`).
|
|
29
|
+
_PEOPLE_MENTION_SOURCE = "people"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class AliasRule:
|
|
34
|
+
"""One directed merge rule: source entity (from_type, from_key) → target (to_type, to_key)."""
|
|
35
|
+
|
|
36
|
+
from_type: str
|
|
37
|
+
from_key: str
|
|
38
|
+
to_type: str
|
|
39
|
+
to_key: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class AliasResult:
|
|
44
|
+
"""Summary of a single ``apply_aliases`` (or ``merge_aliases``) execution."""
|
|
45
|
+
|
|
46
|
+
tenant_id: str
|
|
47
|
+
rules_total: int = 0
|
|
48
|
+
rules_applied: int = 0 # rules whose source entity existed
|
|
49
|
+
mentions_repointed: int = 0
|
|
50
|
+
contributions_repointed: int = 0
|
|
51
|
+
sources_orphaned: int = 0 # sources left zero-mention (deleted by refresh GC, F2)
|
|
52
|
+
dry_run: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _norm(value: str) -> str:
|
|
56
|
+
"""Normalise a key to lowercase, collapsed whitespace."""
|
|
57
|
+
return " ".join(str(value).lower().split())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_alias_rules(path: Path | None = None) -> list[AliasRule]:
|
|
61
|
+
"""Load curated rules from *path*.
|
|
62
|
+
|
|
63
|
+
A missing file returns ``[]`` — the feature is opt-in; real rules live in a
|
|
64
|
+
gitignored local file (``BRAIN_GRAPH_ALIASES_PATH``).
|
|
65
|
+
|
|
66
|
+
Raises :class:`brain.errors.GraphReconcileError` on semantic violations:
|
|
67
|
+
invalid entity type, self-merge, duplicate source, or alias chain/cycle.
|
|
68
|
+
Error messages include the rule *type* and a redacted key only (F5 — never
|
|
69
|
+
the full real value).
|
|
70
|
+
"""
|
|
71
|
+
if path is None or not path.exists():
|
|
72
|
+
return []
|
|
73
|
+
raw: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
74
|
+
rules: list[AliasRule] = []
|
|
75
|
+
for entry in raw.get("rules", []):
|
|
76
|
+
frm, to = entry["from"], entry["to"]
|
|
77
|
+
rule = AliasRule(
|
|
78
|
+
from_type=str(frm["type"]).lower(),
|
|
79
|
+
from_key=_norm(frm["key"]),
|
|
80
|
+
to_type=str(to["type"]).lower(),
|
|
81
|
+
to_key=_norm(to["key"]),
|
|
82
|
+
)
|
|
83
|
+
if rule.from_type not in _VALID_TYPES or rule.to_type not in _VALID_TYPES:
|
|
84
|
+
raise GraphReconcileError(
|
|
85
|
+
f"alias rule has invalid type: "
|
|
86
|
+
f"{rule.from_type}:{_redact(rule.from_key)} → "
|
|
87
|
+
f"{rule.to_type}:{_redact(rule.to_key)}"
|
|
88
|
+
)
|
|
89
|
+
if (rule.from_type, rule.from_key) == (rule.to_type, rule.to_key):
|
|
90
|
+
raise GraphReconcileError(
|
|
91
|
+
f"alias rule is a self-merge: {rule.from_type}:{_redact(rule.from_key)}"
|
|
92
|
+
)
|
|
93
|
+
rules.append(rule)
|
|
94
|
+
_validate_alias_graph(rules) # F7: reject duplicate sources / chains / cycles
|
|
95
|
+
return rules
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _validate_alias_graph(rules: list[AliasRule]) -> None:
|
|
99
|
+
"""Reject an ill-formed alias graph (F7).
|
|
100
|
+
|
|
101
|
+
A source ``(type, key)`` may appear at most once (no ambiguous duplicate
|
|
102
|
+
targets), and a source may not also be some other rule's target (no
|
|
103
|
+
transitive chains/cycles like A→B→C or A→B→A), because the merge is
|
|
104
|
+
single-pass and order-independent.
|
|
105
|
+
|
|
106
|
+
Error messages name type + a redacted key only (F5), never the full mapping.
|
|
107
|
+
"""
|
|
108
|
+
targets = {(r.to_type, r.to_key) for r in rules}
|
|
109
|
+
seen: set[tuple[str, str]] = set()
|
|
110
|
+
for r in rules:
|
|
111
|
+
src = (r.from_type, r.from_key)
|
|
112
|
+
if src in seen:
|
|
113
|
+
raise GraphReconcileError(
|
|
114
|
+
f"duplicate alias source: {r.from_type}:{_redact(r.from_key)}"
|
|
115
|
+
)
|
|
116
|
+
seen.add(src)
|
|
117
|
+
if src in targets:
|
|
118
|
+
raise GraphReconcileError(
|
|
119
|
+
f"alias chain/cycle: {r.from_type}:{_redact(r.from_key)} "
|
|
120
|
+
"is both a source and a target"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _redact(key: str) -> str:
|
|
125
|
+
"""Return a safe, non-identifying prefix of *key* for error/log messages (F5)."""
|
|
126
|
+
return key[:2] + "…" if len(key) > 2 else "…"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# C2 — apply_aliases: FK re-point only (F2: never DELETE graph_entities rows)
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class _Rollback(Exception): # noqa: N818 — internal control-flow marker, not user-facing
|
|
135
|
+
"""Internal control-flow exception used to unwind a dry-run savepoint.
|
|
136
|
+
|
|
137
|
+
Raised at the end of a successful per-rule transaction when ``dry_run`` is
|
|
138
|
+
true so that ``psycopg``'s context-managed ``SAVEPOINT`` rolls the row
|
|
139
|
+
changes back without surfacing as a real error to the caller.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def apply_aliases(
|
|
144
|
+
conn: psycopg.Connection[Any],
|
|
145
|
+
tenant_id: str,
|
|
146
|
+
rules: list[AliasRule],
|
|
147
|
+
*,
|
|
148
|
+
dry_run: bool = False,
|
|
149
|
+
) -> AliasResult:
|
|
150
|
+
"""Re-point each rule's source mentions+contributions onto its target.
|
|
151
|
+
|
|
152
|
+
Leaves the source ``graph_entities`` row in place (zero-mention). The caller
|
|
153
|
+
is then responsible for (1) upserting target AGE vertices and
|
|
154
|
+
(2) running ``refresh_aggregates`` — whose existing orphan-GC step deletes
|
|
155
|
+
the zero-mention source relationally **and** detach-deletes its AGE vertex
|
|
156
|
+
(F2). This function intentionally never touches ``graph_entities`` rows,
|
|
157
|
+
``graph_relationships``, ``doc_count``, or the AGE graph.
|
|
158
|
+
|
|
159
|
+
Per rule the work runs inside a ``with conn.transaction()`` SAVEPOINT so
|
|
160
|
+
failure rolls back only that rule. ``dry_run=True`` rolls every savepoint
|
|
161
|
+
back after counting; the returned summary still reports what *would* move.
|
|
162
|
+
|
|
163
|
+
Idempotent: an absent source ``(from_type, from_key)`` is a silent no-op
|
|
164
|
+
(does not increment ``rules_applied``). ``rules_total`` always reflects the
|
|
165
|
+
input count.
|
|
166
|
+
|
|
167
|
+
SQL is parameterized throughout — entity ids never concatenate into a
|
|
168
|
+
string. Catches only the internal :class:`_Rollback` sentinel; real
|
|
169
|
+
``psycopg`` errors propagate so callers see actionable failures.
|
|
170
|
+
"""
|
|
171
|
+
mentions_repointed = 0
|
|
172
|
+
contributions_repointed = 0
|
|
173
|
+
applied = 0
|
|
174
|
+
orphaned = 0
|
|
175
|
+
|
|
176
|
+
for rule in rules:
|
|
177
|
+
src_id = _entity_id_by_key(conn, tenant_id, rule.from_type, rule.from_key)
|
|
178
|
+
if src_id is None:
|
|
179
|
+
# F2 idempotency: source absent (already merged / never existed) — skip.
|
|
180
|
+
continue
|
|
181
|
+
try:
|
|
182
|
+
with conn.transaction(): # SAVEPOINT
|
|
183
|
+
dst_id = _upsert_entity(conn, tenant_id, rule.to_type, rule.to_key)
|
|
184
|
+
mentions_repointed += _repoint_mentions(
|
|
185
|
+
conn, tenant_id, src_id, dst_id
|
|
186
|
+
)
|
|
187
|
+
contributions_repointed += _repoint_contributions(
|
|
188
|
+
conn, tenant_id, src_id, dst_id
|
|
189
|
+
)
|
|
190
|
+
applied += 1
|
|
191
|
+
orphaned += 1 # source is now zero-mention (GC handled by caller)
|
|
192
|
+
if dry_run:
|
|
193
|
+
raise _Rollback
|
|
194
|
+
except _Rollback:
|
|
195
|
+
# Counters above were tallied before the rollback unwound the row
|
|
196
|
+
# changes, so the dry-run summary still reports the would-be moves.
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
return AliasResult(
|
|
200
|
+
tenant_id=tenant_id,
|
|
201
|
+
rules_total=len(rules),
|
|
202
|
+
rules_applied=applied,
|
|
203
|
+
mentions_repointed=mentions_repointed,
|
|
204
|
+
contributions_repointed=contributions_repointed,
|
|
205
|
+
sources_orphaned=orphaned,
|
|
206
|
+
dry_run=dry_run,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# SQL helpers — all parameterized (NEVER concat ids/keys into SQL strings).
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _entity_id_by_key(
|
|
216
|
+
conn: psycopg.Connection[Any],
|
|
217
|
+
tenant_id: str,
|
|
218
|
+
entity_type: str,
|
|
219
|
+
canonical_key: str,
|
|
220
|
+
) -> str | None:
|
|
221
|
+
"""Return the durable entity id for ``(tenant, type, key)`` or ``None``."""
|
|
222
|
+
row = conn.execute(
|
|
223
|
+
"SELECT id::text FROM graph_entities "
|
|
224
|
+
"WHERE tenant_id = %s AND entity_type = %s AND canonical_key = %s",
|
|
225
|
+
(tenant_id, entity_type, canonical_key),
|
|
226
|
+
).fetchone()
|
|
227
|
+
return None if row is None else str(row[0])
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _upsert_entity(
|
|
231
|
+
conn: psycopg.Connection[Any],
|
|
232
|
+
tenant_id: str,
|
|
233
|
+
entity_type: str,
|
|
234
|
+
canonical_key: str,
|
|
235
|
+
) -> str:
|
|
236
|
+
"""Find-or-create the target entity and return its id::text.
|
|
237
|
+
|
|
238
|
+
Uses the migration-012 UNIQUE ``(tenant_id, entity_type, canonical_key)``
|
|
239
|
+
constraint so a re-merge re-uses the existing row. ``name`` is set to the
|
|
240
|
+
humanized canonical key on every call — reuses
|
|
241
|
+
:func:`brain.wiki._person_name.humanize_person_name` so the alias surface
|
|
242
|
+
matches the people-resolver display-name shape (DRY).
|
|
243
|
+
"""
|
|
244
|
+
row = conn.execute(
|
|
245
|
+
"""
|
|
246
|
+
INSERT INTO graph_entities (tenant_id, entity_type, name, canonical_key)
|
|
247
|
+
VALUES (%s, %s, %s, %s)
|
|
248
|
+
ON CONFLICT (tenant_id, entity_type, canonical_key) DO UPDATE SET
|
|
249
|
+
name = EXCLUDED.name,
|
|
250
|
+
updated_at = NOW()
|
|
251
|
+
RETURNING id::text
|
|
252
|
+
""",
|
|
253
|
+
(tenant_id, entity_type, humanize_person_name(canonical_key), canonical_key),
|
|
254
|
+
).fetchone()
|
|
255
|
+
# RETURNING on INSERT ... ON CONFLICT DO UPDATE always yields one row.
|
|
256
|
+
assert row is not None
|
|
257
|
+
return str(row[0])
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _repoint_mentions(
|
|
261
|
+
conn: psycopg.Connection[Any],
|
|
262
|
+
tenant_id: str,
|
|
263
|
+
src_id: str,
|
|
264
|
+
dst_id: str,
|
|
265
|
+
) -> int:
|
|
266
|
+
"""Move every source-entity mention onto *dst_id*; return rows moved.
|
|
267
|
+
|
|
268
|
+
Two-step: INSERT the source rows under ``dst_id`` (collapsing the PK
|
|
269
|
+
``(tenant_id, entity_id, document_id)`` on conflict), then DELETE the old
|
|
270
|
+
source-keyed rows so the source ends up zero-mention. Returns the number of
|
|
271
|
+
source rows that existed pre-move (= the number routed to the target).
|
|
272
|
+
|
|
273
|
+
The on-conflict collapse is **aspect-aware** because ``mention_count`` means
|
|
274
|
+
different things per aspect. Person mentions (``source = 'people'``) are
|
|
275
|
+
*presence flags* — always ``1`` — so summing two ``1``s into ``2`` when both
|
|
276
|
+
the source and target already mention the same document would fabricate a
|
|
277
|
+
count the reconcile pipeline never writes. For those we keep presence via
|
|
278
|
+
``GREATEST`` (``1``). Concept mentions carry real per-document counts, so
|
|
279
|
+
they still *sum*. The determinant is the row's ``source`` (not entity type):
|
|
280
|
+
if either the existing target row or the incoming source row is a
|
|
281
|
+
people-presence mention, clamp; otherwise sum.
|
|
282
|
+
"""
|
|
283
|
+
moved_row = conn.execute(
|
|
284
|
+
"""
|
|
285
|
+
WITH moved AS (
|
|
286
|
+
INSERT INTO graph_entity_mentions
|
|
287
|
+
(tenant_id, entity_id, document_id, mention_count, source)
|
|
288
|
+
SELECT tenant_id, %(dst)s, document_id, mention_count, source
|
|
289
|
+
FROM graph_entity_mentions
|
|
290
|
+
WHERE tenant_id = %(tenant)s AND entity_id = %(src)s
|
|
291
|
+
ON CONFLICT (tenant_id, entity_id, document_id)
|
|
292
|
+
DO UPDATE SET
|
|
293
|
+
mention_count = CASE
|
|
294
|
+
WHEN graph_entity_mentions.source = %(people)s
|
|
295
|
+
OR EXCLUDED.source = %(people)s
|
|
296
|
+
THEN GREATEST(
|
|
297
|
+
graph_entity_mentions.mention_count,
|
|
298
|
+
EXCLUDED.mention_count
|
|
299
|
+
)
|
|
300
|
+
ELSE graph_entity_mentions.mention_count
|
|
301
|
+
+ EXCLUDED.mention_count
|
|
302
|
+
END
|
|
303
|
+
RETURNING 1
|
|
304
|
+
)
|
|
305
|
+
SELECT count(*) FROM moved
|
|
306
|
+
""",
|
|
307
|
+
{
|
|
308
|
+
"dst": dst_id,
|
|
309
|
+
"tenant": tenant_id,
|
|
310
|
+
"src": src_id,
|
|
311
|
+
"people": _PEOPLE_MENTION_SOURCE,
|
|
312
|
+
},
|
|
313
|
+
).fetchone()
|
|
314
|
+
assert moved_row is not None
|
|
315
|
+
moved = int(moved_row[0])
|
|
316
|
+
conn.execute(
|
|
317
|
+
"DELETE FROM graph_entity_mentions WHERE tenant_id = %s AND entity_id = %s",
|
|
318
|
+
(tenant_id, src_id),
|
|
319
|
+
)
|
|
320
|
+
return moved
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _repoint_contributions(
|
|
324
|
+
conn: psycopg.Connection[Any],
|
|
325
|
+
tenant_id: str,
|
|
326
|
+
src_id: str,
|
|
327
|
+
dst_id: str,
|
|
328
|
+
) -> int:
|
|
329
|
+
"""Move every source-touching contribution edge onto *dst_id*.
|
|
330
|
+
|
|
331
|
+
The ``graph_edge_contributions`` PK is
|
|
332
|
+
``(tenant_id, document_id, src_id, dst_id)`` with CHECK ``src_id < dst_id``,
|
|
333
|
+
so naively swapping the endpoint id breaks the canonical ordering. We
|
|
334
|
+
therefore re-canonicalize via ``LEAST/GREATEST`` after substituting, then:
|
|
335
|
+
|
|
336
|
+
* **drop self-edges** (``LEAST == GREATEST``) — re-pointing produces a
|
|
337
|
+
self-edge when the variant already co-occurred with the target in the
|
|
338
|
+
same document.
|
|
339
|
+
* collapse PK conflicts **aspect-aware** — like :func:`_repoint_mentions`,
|
|
340
|
+
person-person co-occurrence edges are *presence flags* (the people
|
|
341
|
+
pipeline emits ``cooccur_count = 1`` for every same-doc pair), so summing
|
|
342
|
+
two ``1``s when the source and target both co-occurred with a third person
|
|
343
|
+
in the same document would fabricate a weight. ``graph_edge_contributions``
|
|
344
|
+
carries no ``source`` column, so the aspect is derived from the endpoints'
|
|
345
|
+
types: a conflict whose two endpoints are BOTH ``person`` keeps presence
|
|
346
|
+
via ``GREATEST``; every other edge (concept-concept, or a cross-type merge)
|
|
347
|
+
still *sums*.
|
|
348
|
+
* delete the old source-touching rows so the source is fully detached at
|
|
349
|
+
the contributions table too.
|
|
350
|
+
|
|
351
|
+
Returns the number of source-touching rows that existed pre-move (= the
|
|
352
|
+
number considered for the target).
|
|
353
|
+
"""
|
|
354
|
+
moved_row = conn.execute(
|
|
355
|
+
"""
|
|
356
|
+
WITH source_rows AS (
|
|
357
|
+
SELECT
|
|
358
|
+
tenant_id,
|
|
359
|
+
document_id,
|
|
360
|
+
LEAST(
|
|
361
|
+
CASE WHEN src_id = %(src)s THEN %(dst)s::uuid ELSE src_id END,
|
|
362
|
+
CASE WHEN dst_id = %(src)s THEN %(dst)s::uuid ELSE dst_id END
|
|
363
|
+
) AS new_src,
|
|
364
|
+
GREATEST(
|
|
365
|
+
CASE WHEN src_id = %(src)s THEN %(dst)s::uuid ELSE src_id END,
|
|
366
|
+
CASE WHEN dst_id = %(src)s THEN %(dst)s::uuid ELSE dst_id END
|
|
367
|
+
) AS new_dst,
|
|
368
|
+
cooccur_count
|
|
369
|
+
FROM graph_edge_contributions
|
|
370
|
+
WHERE tenant_id = %(tenant)s
|
|
371
|
+
AND (src_id = %(src)s OR dst_id = %(src)s)
|
|
372
|
+
),
|
|
373
|
+
rewritten AS (
|
|
374
|
+
SELECT tenant_id, document_id, new_src, new_dst, cooccur_count
|
|
375
|
+
FROM source_rows
|
|
376
|
+
WHERE new_src <> new_dst -- drop self-edges
|
|
377
|
+
),
|
|
378
|
+
upserted AS (
|
|
379
|
+
INSERT INTO graph_edge_contributions
|
|
380
|
+
(tenant_id, document_id, src_id, dst_id, cooccur_count)
|
|
381
|
+
SELECT tenant_id, document_id, new_src, new_dst, cooccur_count
|
|
382
|
+
FROM rewritten
|
|
383
|
+
ON CONFLICT (tenant_id, document_id, src_id, dst_id)
|
|
384
|
+
DO UPDATE SET
|
|
385
|
+
cooccur_count = CASE
|
|
386
|
+
WHEN (
|
|
387
|
+
SELECT bool_and(ge.entity_type = 'person')
|
|
388
|
+
FROM graph_entities ge
|
|
389
|
+
WHERE ge.tenant_id = graph_edge_contributions.tenant_id
|
|
390
|
+
AND ge.id IN (
|
|
391
|
+
graph_edge_contributions.src_id,
|
|
392
|
+
graph_edge_contributions.dst_id
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
THEN GREATEST(
|
|
396
|
+
graph_edge_contributions.cooccur_count,
|
|
397
|
+
EXCLUDED.cooccur_count
|
|
398
|
+
)
|
|
399
|
+
ELSE graph_edge_contributions.cooccur_count
|
|
400
|
+
+ EXCLUDED.cooccur_count
|
|
401
|
+
END
|
|
402
|
+
RETURNING 1
|
|
403
|
+
)
|
|
404
|
+
SELECT (SELECT count(*) FROM source_rows)
|
|
405
|
+
""",
|
|
406
|
+
{"tenant": tenant_id, "src": src_id, "dst": dst_id},
|
|
407
|
+
).fetchone()
|
|
408
|
+
assert moved_row is not None
|
|
409
|
+
moved = int(moved_row[0])
|
|
410
|
+
conn.execute(
|
|
411
|
+
"DELETE FROM graph_edge_contributions "
|
|
412
|
+
"WHERE tenant_id = %s AND (src_id = %s OR dst_id = %s)",
|
|
413
|
+
(tenant_id, src_id, src_id),
|
|
414
|
+
)
|
|
415
|
+
return moved
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
# C3 — merge_aliases: atomic orchestrator (F2)
|
|
420
|
+
#
|
|
421
|
+
# Wraps the full apply → upsert AGE targets → refresh aggregates flow in ONE
|
|
422
|
+
# transaction so the relational re-point, the AGE vertex provisioning, and the
|
|
423
|
+
# GC + AGE-detach + edge rebuild commit or roll back together. ``dry_run``
|
|
424
|
+
# rolls everything back via the internal :class:`_Rollback` sentinel while
|
|
425
|
+
# still returning the counters that ``apply_aliases`` tallied.
|
|
426
|
+
# ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def merge_aliases(
|
|
430
|
+
conn: psycopg.Connection[Any],
|
|
431
|
+
tenant_id: str,
|
|
432
|
+
rules: list[AliasRule],
|
|
433
|
+
backend: GraphBackend,
|
|
434
|
+
*,
|
|
435
|
+
dry_run: bool = False,
|
|
436
|
+
config: ReconcileConfig | None = None,
|
|
437
|
+
) -> AliasResult:
|
|
438
|
+
"""Apply curated alias rules atomically, then refresh tenant aggregates (F2).
|
|
439
|
+
|
|
440
|
+
The single corpus-level entry point used by ``brain graphrag aliases apply``,
|
|
441
|
+
the ``build`` / ``refresh`` CLI wiring, and the MCP twin
|
|
442
|
+
``brain_graphrag_aliases_apply``. Orchestrates the three steps that together
|
|
443
|
+
leave the relational source-of-truth and the AGE mirror consistent:
|
|
444
|
+
|
|
445
|
+
1. :func:`apply_aliases` re-points every source entity's mentions +
|
|
446
|
+
contributions onto its rule target, leaving the source ``graph_entities``
|
|
447
|
+
row in place (zero-mention; F2 — never DELETEd here).
|
|
448
|
+
2. ``backend.upsert_entities`` MERGEs the rule targets' AGE vertices so
|
|
449
|
+
newly-created targets exist before
|
|
450
|
+
:meth:`~brain.graph_rag.aggregates.refresh_aggregates`'s
|
|
451
|
+
``refresh_cooccur_edges`` looks them up (which raises on a missing
|
|
452
|
+
vertex; F2).
|
|
453
|
+
3. :func:`~brain.graph_rag.aggregates.refresh_aggregates` rebuilds the
|
|
454
|
+
tenant's derived ``graph_relationships`` from the contributions,
|
|
455
|
+
GCs the now-zero-mention sources relationally **and** ``detach delete``
|
|
456
|
+
their AGE vertices, and rematerializes the AGE ``CO_OCCURS`` edges.
|
|
457
|
+
|
|
458
|
+
Steps 1-3 run inside ONE ``with conn.transaction()`` so a failure at step 2
|
|
459
|
+
or 3 rolls back the re-point at step 1 — the graph never lands in a
|
|
460
|
+
half-merged state. ``dry_run=True`` runs step 1 only and raises
|
|
461
|
+
:class:`_Rollback` to unwind the transaction without persisting; the
|
|
462
|
+
returned :class:`AliasResult` still reports the would-be moves and has
|
|
463
|
+
``dry_run=True``.
|
|
464
|
+
|
|
465
|
+
Empty ``rules`` short-circuits before opening a transaction and returns a
|
|
466
|
+
zero-valued result (the wiring sites can call this unconditionally).
|
|
467
|
+
|
|
468
|
+
``config`` is an optional :class:`ReconcileConfig` forwarded to
|
|
469
|
+
``refresh_aggregates`` so the caller's ``generic_df_ratio`` /
|
|
470
|
+
``suppress_ver`` etc. apply to the post-merge edge recompute. When ``None``
|
|
471
|
+
the default :class:`ReconcileConfig` is used (its ``tenant_id`` is replaced
|
|
472
|
+
with the caller's). When supplied, its ``tenant_id`` MUST match ``tenant_id``
|
|
473
|
+
so the apply step (using ``tenant_id``) and the refresh step (using
|
|
474
|
+
``config.tenant_id``) can never diverge.
|
|
475
|
+
"""
|
|
476
|
+
if not rules:
|
|
477
|
+
return AliasResult(tenant_id=tenant_id, rules_total=0, dry_run=dry_run)
|
|
478
|
+
|
|
479
|
+
# Late imports keep this module import-cheap (avoid pulling reconcile +
|
|
480
|
+
# aggregates + backends at module-load time) and break the import cycle
|
|
481
|
+
# that would otherwise form between aliases.py and aggregates.py (which is
|
|
482
|
+
# allowed to depend on aliases at a later G-wave but not at module-load).
|
|
483
|
+
from ..aggregates import refresh_aggregates # noqa: PLC0415 — break cycle
|
|
484
|
+
from ..reconcile import ReconcileConfig # noqa: PLC0415 — break cycle
|
|
485
|
+
|
|
486
|
+
refresh_config: ReconcileConfig
|
|
487
|
+
if config is None:
|
|
488
|
+
refresh_config = ReconcileConfig(tenant_id=tenant_id)
|
|
489
|
+
elif config.tenant_id != tenant_id:
|
|
490
|
+
raise GraphReconcileError(
|
|
491
|
+
"merge_aliases: config.tenant_id "
|
|
492
|
+
f"({config.tenant_id!r}) does not match tenant_id ({tenant_id!r})"
|
|
493
|
+
)
|
|
494
|
+
else:
|
|
495
|
+
refresh_config = config
|
|
496
|
+
|
|
497
|
+
captured: dict[str, AliasResult] = {}
|
|
498
|
+
try:
|
|
499
|
+
with conn.transaction():
|
|
500
|
+
res = apply_aliases(conn, tenant_id, rules, dry_run=False)
|
|
501
|
+
if dry_run:
|
|
502
|
+
# Record the counters BEFORE unwinding so the dry-run summary
|
|
503
|
+
# still reports what WOULD have moved. The _Rollback raise
|
|
504
|
+
# unwinds the savepoint without surfacing as a real error.
|
|
505
|
+
captured["res"] = AliasResult(
|
|
506
|
+
tenant_id=res.tenant_id,
|
|
507
|
+
rules_total=res.rules_total,
|
|
508
|
+
rules_applied=res.rules_applied,
|
|
509
|
+
mentions_repointed=res.mentions_repointed,
|
|
510
|
+
contributions_repointed=res.contributions_repointed,
|
|
511
|
+
sources_orphaned=res.sources_orphaned,
|
|
512
|
+
dry_run=True,
|
|
513
|
+
)
|
|
514
|
+
raise _Rollback
|
|
515
|
+
# Gate the AGE upsert + corpus refresh on `rules_applied > 0`:
|
|
516
|
+
# when no rule's source entity existed in this corpus (a config
|
|
517
|
+
# carried over from another brain, or an already-merged corpus),
|
|
518
|
+
# `apply_aliases` re-pointed NOTHING — the derived layers are
|
|
519
|
+
# already consistent, so the post-apply target upsert + the
|
|
520
|
+
# whole-tenant `refresh_aggregates` (with its O(R) CO_OCCURS
|
|
521
|
+
# rematerialization) are pure waste. They also create the only
|
|
522
|
+
# branch where the find-or-create `_upsert_entity` from a no-op
|
|
523
|
+
# rule could materialize an unused target vertex in AGE.
|
|
524
|
+
if res.rules_applied > 0:
|
|
525
|
+
# F2: ensure every rule TARGET has an AGE vertex before
|
|
526
|
+
# refresh_cooccur_edges looks it up. A target that was created
|
|
527
|
+
# by apply_aliases (find-or-create via _upsert_entity) has a
|
|
528
|
+
# relational row but no AGE vertex yet; without this upsert
|
|
529
|
+
# the post-merge CO_OCCURS rebuild would raise
|
|
530
|
+
# GraphBackendError on the first contribution touching the
|
|
531
|
+
# new target.
|
|
532
|
+
target_pairs = list({(r.to_type, r.to_key) for r in rules})
|
|
533
|
+
targets = _fetch_entities_by_keys(conn, tenant_id, target_pairs)
|
|
534
|
+
if targets:
|
|
535
|
+
backend.upsert_entities(conn, tenant_id, targets)
|
|
536
|
+
# Refresh: recompute graph_relationships from contributions,
|
|
537
|
+
# GC the now-zero-mention source entities + DETACH DELETE
|
|
538
|
+
# their AGE vertices, rematerialize CO_OCCURS. Runs inside
|
|
539
|
+
# this outer transaction (nested as a SAVEPOINT) so the
|
|
540
|
+
# whole flow commits or rolls back together.
|
|
541
|
+
refresh_aggregates(conn, backend=backend, config=refresh_config)
|
|
542
|
+
captured["res"] = res
|
|
543
|
+
except _Rollback:
|
|
544
|
+
# Counters were tallied before the rollback unwound the row changes,
|
|
545
|
+
# so the dry-run summary still reports the would-be moves.
|
|
546
|
+
pass
|
|
547
|
+
return captured["res"]
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _fetch_entities_by_keys(
|
|
551
|
+
conn: psycopg.Connection[Any],
|
|
552
|
+
tenant_id: str,
|
|
553
|
+
type_key_pairs: list[tuple[str, str]],
|
|
554
|
+
) -> list[GraphEntity]:
|
|
555
|
+
"""Resolve ``(entity_type, canonical_key)`` pairs to :class:`GraphEntity` rows.
|
|
556
|
+
|
|
557
|
+
De-duplicates the input pairs (multiple alias rules can share a target),
|
|
558
|
+
then runs ONE tenant-scoped ``SELECT`` over ``graph_entities`` and maps each
|
|
559
|
+
row via :func:`brain.graph_rag._retrieval_common._row_to_entity` so the
|
|
560
|
+
:class:`GraphEntity` shape stays in lockstep with the rest of the read path
|
|
561
|
+
(DRY — G2 split). Pairs that resolve to no row are silently dropped
|
|
562
|
+
(nothing to AGE-upsert when nothing was created). SQL is parameterized.
|
|
563
|
+
"""
|
|
564
|
+
if not type_key_pairs:
|
|
565
|
+
return []
|
|
566
|
+
# Late import keeps this module import-cheap and breaks the cycle with
|
|
567
|
+
# retrieve.py / _retrieval_common (which imports schema, which we already
|
|
568
|
+
# have — but keeping it lazy mirrors the other late imports in this file).
|
|
569
|
+
from .._retrieval_common import _row_to_entity # noqa: PLC0415
|
|
570
|
+
|
|
571
|
+
deduped = sorted(set(type_key_pairs))
|
|
572
|
+
types = [t for t, _ in deduped]
|
|
573
|
+
keys = [k for _, k in deduped]
|
|
574
|
+
rows = conn.execute(
|
|
575
|
+
"SELECT id::text, entity_type, name, canonical_key, description, doc_count "
|
|
576
|
+
"FROM graph_entities "
|
|
577
|
+
"WHERE tenant_id = %s "
|
|
578
|
+
"AND (entity_type, canonical_key) IN ("
|
|
579
|
+
" SELECT unnest(%s::text[]), unnest(%s::text[])"
|
|
580
|
+
")",
|
|
581
|
+
(tenant_id, types, keys),
|
|
582
|
+
).fetchall()
|
|
583
|
+
return [_row_to_entity(row, tenant_id) for row in rows]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""GraphRAG storage/traversal backends (spec §4 D10, §8).
|
|
2
|
+
|
|
3
|
+
:class:`GraphBackend` is the narrow Protocol; :class:`AgeBackend` is the default
|
|
4
|
+
Apache AGE implementation. A Neo4j/Memgraph kill-switch backend (spec §16) would
|
|
5
|
+
land here too, conforming to the same Protocol.
|
|
6
|
+
"""
|
|
7
|
+
from .age import AgeBackend
|
|
8
|
+
from .base import GraphBackend, PersonScope, TraversalHit
|
|
9
|
+
|
|
10
|
+
__all__ = ["AgeBackend", "GraphBackend", "PersonScope", "TraversalHit"]
|