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,473 @@
|
|
|
1
|
+
"""Pure (instance-state-free) helpers + constants for the AGE backend (G2 split).
|
|
2
|
+
|
|
3
|
+
Extracted from :mod:`brain.graph_rag.backends.age` (the G2 wave-boundary
|
|
4
|
+
file-size split, mirroring the G1 :mod:`brain.graph_rag.aggregates` / G2-c
|
|
5
|
+
:mod:`brain.graph_rag.relational` extractions) so
|
|
6
|
+
:class:`~brain.graph_rag.backends.age.AgeBackend` stays a focused class under the
|
|
7
|
+
file-size cap. Everything here is free of ``AgeBackend`` instance state — module
|
|
8
|
+
constants, agtype parsing, input validation, the inline Cypher property-map
|
|
9
|
+
builder, the BTREE property-index DDL builder, and the **relational**
|
|
10
|
+
``scope_person`` two-hop query. The :meth:`AgeBackend.scope_person` Protocol
|
|
11
|
+
method delegates to :func:`_scope_person_relational` here; the rest of the
|
|
12
|
+
backend imports the constants + validators by their original names, so this is a
|
|
13
|
+
pure move with no behavior change.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import psycopg
|
|
24
|
+
from psycopg import sql
|
|
25
|
+
|
|
26
|
+
from ...errors import GraphBackendError
|
|
27
|
+
from .base import PersonScope
|
|
28
|
+
|
|
29
|
+
_logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# AGE graph names are SQL identifiers (they back a Postgres schema). The name is
|
|
32
|
+
# a controlled config value, never user input, but it is embedded as a literal
|
|
33
|
+
# in the ``cypher()`` call (it cannot be a bound parameter), so we validate it
|
|
34
|
+
# defensively before use — an invalid name is a caller bug, not a DB failure.
|
|
35
|
+
_GRAPH_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
36
|
+
|
|
37
|
+
# Cypher property keys must be plain identifiers. AGE rejects ``SET x += $param``
|
|
38
|
+
# (a bare param as the map — "SET clause expects a map"), so dynamic property
|
|
39
|
+
# maps are built inline with the *keys* interpolated (validated against this
|
|
40
|
+
# pattern to bar injection) and the *values* routed through agtype params.
|
|
41
|
+
_PROP_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
42
|
+
|
|
43
|
+
# Reserved identity keys per label — a ``SET x += {...}`` over a caller property
|
|
44
|
+
# bag must NEVER overwrite these (doing so would move/strand a vertex across its
|
|
45
|
+
# tenant/UUID identity, breaking the scoped MERGE + delete/create idempotency).
|
|
46
|
+
# Note: ``canonical_key`` is deliberately NOT reserved for entities — it is a
|
|
47
|
+
# legitimately mutable property that ``upsert_entities`` owns (the AGE vertex
|
|
48
|
+
# identity is ``(entity_uuid, tenant_id)``, not canonical_key).
|
|
49
|
+
_ENTITY_RESERVED_KEYS = frozenset({"tenant_id", "entity_uuid"})
|
|
50
|
+
_DOCUMENT_RESERVED_KEYS = frozenset({"tenant_id", "document_uuid"})
|
|
51
|
+
|
|
52
|
+
# AGE serializes graph elements with a trailing ``::edge`` / ``::vertex`` /
|
|
53
|
+
# ``::path`` type annotation appended to otherwise-valid JSON. Strip it before
|
|
54
|
+
# ``json.loads``.
|
|
55
|
+
_AGTYPE_ANNOTATION_RE = re.compile(r"::(?:edge|vertex|path|numeric)\b")
|
|
56
|
+
|
|
57
|
+
# Vertex/edge labels (spec §5b). Vertex labels back the tables the property
|
|
58
|
+
# indexes target, so they are created before the edge labels and indexes.
|
|
59
|
+
_VERTEX_LABELS = ("Entity", "Document")
|
|
60
|
+
_EDGE_LABELS = ("MENTIONED_IN", "CO_OCCURS")
|
|
61
|
+
|
|
62
|
+
# Property indexes (spec §5b): (label, property, index-name). Index names live
|
|
63
|
+
# in the graph's own schema, so they need no graph prefix.
|
|
64
|
+
_PROPERTY_INDEXES = (
|
|
65
|
+
("Entity", "tenant_id", "idx_entity_tenant_id"),
|
|
66
|
+
("Entity", "entity_uuid", "idx_entity_entity_uuid"),
|
|
67
|
+
("Entity", "canonical_key", "idx_entity_canonical_key"),
|
|
68
|
+
("CO_OCCURS", "weight", "idx_cooccur_weight"),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Traversal correctness bound. The *functional* frontier cap is applied in
|
|
72
|
+
# Python AFTER affinity scoring (a Cypher ``LIMIT`` before scoring would
|
|
73
|
+
# silently drop the highest-affinity entities — AGE cannot ``ORDER BY`` true
|
|
74
|
+
# affinity = ∏ weights, having no list-comprehension/reduce). So traverse() must
|
|
75
|
+
# score EVERY within-depth path. To stay bounded against a pathological fan-out
|
|
76
|
+
# it fetches at most this many paths and DETECTS overflow: if more paths exist
|
|
77
|
+
# than the bound, it raises rather than returning a silently-truncated (possibly
|
|
78
|
+
# wrong) best-per-entity. The bound is generous; tightening/perf is G2's P95
|
|
79
|
+
# gate. Result: traverse() is either correct or a loud failure, never silently
|
|
80
|
+
# wrong.
|
|
81
|
+
_TRAVERSE_MAX_PATHS_MULTIPLIER = 50
|
|
82
|
+
_TRAVERSE_MAX_PATHS_FLOOR = 5000
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _agtype_loads(raw: Any) -> Any:
|
|
86
|
+
"""Parse one agtype column value (a ``str`` or ``None``) into Python.
|
|
87
|
+
|
|
88
|
+
Strips AGE's ``::edge``/``::vertex``/``::path`` annotations, then
|
|
89
|
+
``json.loads`` — which covers scalars (``"x"``→str, ``3``→int,
|
|
90
|
+
``1.5``→float, ``null``→None) and the list/dict shapes of
|
|
91
|
+
``relationships``/``nodes`` results.
|
|
92
|
+
"""
|
|
93
|
+
if raw is None:
|
|
94
|
+
return None
|
|
95
|
+
if not isinstance(raw, str): # pragma: no cover - psycopg yields agtype as str
|
|
96
|
+
return raw
|
|
97
|
+
cleaned = _AGTYPE_ANNOTATION_RE.sub("", raw)
|
|
98
|
+
try:
|
|
99
|
+
return json.loads(cleaned)
|
|
100
|
+
except json.JSONDecodeError as exc:
|
|
101
|
+
raise GraphBackendError(
|
|
102
|
+
f"could not parse agtype result: {raw!r}"
|
|
103
|
+
) from exc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _require_autocommit(conn: psycopg.Connection[Any], operation: str) -> None:
|
|
107
|
+
"""Guard operations whose AGE catalog DDL needs autocommit (psycopg v3)."""
|
|
108
|
+
if not conn.autocommit:
|
|
109
|
+
raise GraphBackendError(
|
|
110
|
+
f"{operation} requires an autocommit connection — AGE catalog DDL "
|
|
111
|
+
"does not run reliably inside an open transaction under psycopg v3"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _require_positive_int(value: int, name: str) -> None:
|
|
116
|
+
"""Validate a value is a genuine positive int before Cypher interpolation.
|
|
117
|
+
|
|
118
|
+
``depth`` / ``frontier_cap`` are interpolated into the generated Cypher
|
|
119
|
+
template, and a type hint is NOT an injection boundary — so verify the
|
|
120
|
+
value is an actual ``int`` (``bool`` is an ``int`` subclass; exclude it)
|
|
121
|
+
and ``>= 1`` *before* it ever reaches an f-string. A non-int is a caller
|
|
122
|
+
bug, surfaced as :class:`GraphBackendError`.
|
|
123
|
+
"""
|
|
124
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
125
|
+
raise GraphBackendError(
|
|
126
|
+
f"{name} must be an int, got {type(value).__name__}"
|
|
127
|
+
)
|
|
128
|
+
if value < 1:
|
|
129
|
+
raise GraphBackendError(f"{name} must be >= 1, got {value}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _require_weight_floor(value: float) -> None:
|
|
133
|
+
"""Validate ``min_edge_weight`` is a real number in ``[0, 1]``.
|
|
134
|
+
|
|
135
|
+
Not interpolated into Cypher (compared in Python), but a non-numeric
|
|
136
|
+
value would raise a raw ``TypeError`` at comparison time — convert that
|
|
137
|
+
to a typed :class:`GraphBackendError` here. ``bool`` is rejected as a
|
|
138
|
+
nonsensical weight floor.
|
|
139
|
+
"""
|
|
140
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
141
|
+
raise GraphBackendError(
|
|
142
|
+
f"min_edge_weight must be a number, got {type(value).__name__}"
|
|
143
|
+
)
|
|
144
|
+
if not 0.0 <= value <= 1.0:
|
|
145
|
+
raise GraphBackendError(
|
|
146
|
+
f"min_edge_weight must be in [0, 1], got {value}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _inline_set_map(
|
|
151
|
+
props: Mapping[str, Any],
|
|
152
|
+
*,
|
|
153
|
+
reserved: frozenset[str],
|
|
154
|
+
context: str,
|
|
155
|
+
) -> tuple[str, dict[str, Any]]:
|
|
156
|
+
"""Build an inline Cypher property map ``{k: $pk, ...}`` + its params.
|
|
157
|
+
|
|
158
|
+
AGE rejects ``SET x += $param`` (a bare param map), so a dynamic map is
|
|
159
|
+
emitted inline with the *keys* interpolated (validated as identifiers to
|
|
160
|
+
bar injection) and the *values* bound as agtype params (``p0``, ``p1``,
|
|
161
|
+
...). ``reserved`` is the set of identity keys this map must NOT contain
|
|
162
|
+
— a caller property bag that tries to overwrite e.g. ``tenant_id`` /
|
|
163
|
+
``document_uuid`` is a caller bug, rejected with :class:`GraphBackendError`
|
|
164
|
+
(``context`` names the offending bag for the message).
|
|
165
|
+
"""
|
|
166
|
+
parts: list[str] = []
|
|
167
|
+
params: dict[str, Any] = {}
|
|
168
|
+
for index, (key, value) in enumerate(props.items()):
|
|
169
|
+
if key in reserved:
|
|
170
|
+
raise GraphBackendError(
|
|
171
|
+
f"{context} may not overwrite the reserved identity key "
|
|
172
|
+
f"{key!r}"
|
|
173
|
+
)
|
|
174
|
+
if not _PROP_KEY_RE.match(key):
|
|
175
|
+
raise GraphBackendError(
|
|
176
|
+
f"invalid property key {key!r}: must be a Cypher identifier "
|
|
177
|
+
r"matching ^[A-Za-z_][A-Za-z0-9_]*$"
|
|
178
|
+
)
|
|
179
|
+
param_key = f"p{index}"
|
|
180
|
+
parts.append(f"{key}: ${param_key}")
|
|
181
|
+
params[param_key] = value
|
|
182
|
+
return "{" + ", ".join(parts) + "}", params
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _all_same_tenant(
|
|
186
|
+
rels: list[dict[str, Any]],
|
|
187
|
+
nodes: list[dict[str, Any]],
|
|
188
|
+
tenant_id: str,
|
|
189
|
+
) -> bool:
|
|
190
|
+
"""True iff every edge and node in the path carries ``tenant_id``."""
|
|
191
|
+
return all(
|
|
192
|
+
element.get("properties", {}).get("tenant_id") == tenant_id
|
|
193
|
+
for element in (*rels, *nodes)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _edge_weight(edge: Mapping[str, Any]) -> float:
|
|
198
|
+
"""Extract a CO_OCCURS edge's ``weight`` property (required)."""
|
|
199
|
+
props = edge.get("properties", {})
|
|
200
|
+
weight = props.get("weight")
|
|
201
|
+
if weight is None:
|
|
202
|
+
raise GraphBackendError(
|
|
203
|
+
f"CO_OCCURS edge missing required 'weight' property: {edge!r}"
|
|
204
|
+
)
|
|
205
|
+
return float(weight)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _property_index_ddl(
|
|
209
|
+
graph_name: str, label: str, prop: str, idx_name: str
|
|
210
|
+
) -> sql.Composed:
|
|
211
|
+
"""Build the BTREE property-index DDL for ``<graph>.<label>(prop)``."""
|
|
212
|
+
return sql.SQL(
|
|
213
|
+
"CREATE INDEX IF NOT EXISTS {idx} ON {tbl} USING btree "
|
|
214
|
+
"(ag_catalog.agtype_access_operator(properties, {prop}::ag_catalog.agtype))"
|
|
215
|
+
).format(
|
|
216
|
+
idx=sql.Identifier(idx_name),
|
|
217
|
+
tbl=sql.Identifier(graph_name, label),
|
|
218
|
+
prop=sql.Literal(f'"{prop}"'),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _scope_person_relational(
|
|
223
|
+
conn: psycopg.Connection[Any],
|
|
224
|
+
tenant_id: str,
|
|
225
|
+
seed_entity_uuid: str,
|
|
226
|
+
*,
|
|
227
|
+
frontier_cap: int,
|
|
228
|
+
) -> PersonScope:
|
|
229
|
+
"""Person -> docs -> co-mentioned entities, tenant-scoped (spec §6b).
|
|
230
|
+
|
|
231
|
+
**Indexed RELATIONAL two-hop over ``graph_entity_mentions`` — NOT Cypher.**
|
|
232
|
+
The earlier Cypher form (a ``MENTIONED_IN`` self-join) made AGE materialize a
|
|
233
|
+
tenant-wide join *before* restricting to the seed: AGE cannot estimate
|
|
234
|
+
``agtype``-property selectivity, so its planner badly under-counts the seed
|
|
235
|
+
filter and builds hundreds of thousands of full vertex objects, OOM-killing
|
|
236
|
+
Postgres at the G2-k full-scale corpus (50k entities / 1M mentions / 10
|
|
237
|
+
tenants). The relational two-hop is the same shape the rest of the themes
|
|
238
|
+
path already uses (:mod:`brain.graph_rag.themes` computes df / lift / edges
|
|
239
|
+
relationally from ``graph_entity_mentions`` / ``graph_edge_contributions``),
|
|
240
|
+
and it is fully btree-index-supported:
|
|
241
|
+
|
|
242
|
+
* **hop 1** — the seed's documents: ``graph_entity_mentions`` filtered by
|
|
243
|
+
``(tenant_id, entity_id = seed)``, served by the PK
|
|
244
|
+
``(tenant_id, entity_id, document_id)``.
|
|
245
|
+
* **hop 2** — entities co-mentioned in those documents: the ``co``
|
|
246
|
+
self-join on ``(tenant_id, document_id)``, served by
|
|
247
|
+
``idx_gem_document (tenant_id, document_id)`` (migration 012).
|
|
248
|
+
|
|
249
|
+
So per-query cost scales with the SEED's document count × per-document
|
|
250
|
+
fan-out, never the whole tenant's mention set, and the planner has sound
|
|
251
|
+
row estimates (no ``agtype`` opacity). The result is identical in shape
|
|
252
|
+
to the prior Cypher: the distinct co-mentioned entity set (seed
|
|
253
|
+
excluded) + the distinct connecting documents (those where the seed and
|
|
254
|
+
at least one other entity are both mentioned), tenant-scoped on every
|
|
255
|
+
clause and deterministically sorted.
|
|
256
|
+
|
|
257
|
+
**Why this backend reads relational here while :meth:`AgeBackend.traverse`
|
|
258
|
+
stays on Cypher:** ``scope_person`` returns a *set* whose only graph
|
|
259
|
+
structure is a one-hop-shared-document join — exactly what an indexed
|
|
260
|
+
relational query does best, and what :meth:`AgeBackend.refresh_cooccur_edges`
|
|
261
|
+
already reads relationally too. The variable-length affinity walk in
|
|
262
|
+
:meth:`AgeBackend.traverse` is the genuine graph workload and remains on AGE
|
|
263
|
+
Cypher (it passes the G2-k local P95 gate). A future
|
|
264
|
+
:class:`~brain.graph_rag.backends.base.GraphBackend` is free to implement
|
|
265
|
+
``scope_person`` against its own store.
|
|
266
|
+
|
|
267
|
+
**Bounded ranked-truncation contract** (DIFFERS from
|
|
268
|
+
:meth:`AgeBackend.traverse`): a scope is a *set*, and the cheap relational
|
|
269
|
+
two-hop above is the same indexed query downstream df/lift/grouping already
|
|
270
|
+
runs — so when a hub person's co-mention set exceeds ``frontier_cap`` the
|
|
271
|
+
right answer is to keep the STRONGEST ``frontier_cap`` worth of scope, not to
|
|
272
|
+
crash. The common (under-cap) path fetches one extra row
|
|
273
|
+
(``LIMIT frontier_cap + 1``) and, when within bounds, returns the COMPLETE
|
|
274
|
+
set unchanged — identical behavior + cost to the prior form, no extra query.
|
|
275
|
+
Only on overflow does it fall through to :func:`_scope_person_truncated`,
|
|
276
|
+
which ranks co-entities by co-mention frequency (ties → newest shared doc →
|
|
277
|
+
entity id) and keeps the strongest prefix whose cumulative ``(co-entity,
|
|
278
|
+
document)`` row count stays within ``frontier_cap`` — preserving the exact
|
|
279
|
+
downstream bound the safe-cap was introduced to guarantee (the df/lift/
|
|
280
|
+
grouping work stays ≤ ``frontier_cap`` rows), and logging ONE actionable
|
|
281
|
+
WARNING (kept/total counts + the ``BRAIN_GRAPH_FRONTIER_CAP`` knob). The
|
|
282
|
+
returned ``entity_uuids`` / ``document_uuids`` are sorted ascending so the
|
|
283
|
+
result is deterministic regardless of row order.
|
|
284
|
+
|
|
285
|
+
**Why this truncates while :meth:`AgeBackend.traverse` still raises:**
|
|
286
|
+
``traverse`` runs a variable-length Cypher affinity walk whose cap protects
|
|
287
|
+
against genuine *path explosion* — it cannot ``ORDER BY`` true affinity in
|
|
288
|
+
AGE, so it must score every within-depth path and a pre-score ``LIMIT`` would
|
|
289
|
+
silently drop the highest-affinity entities. Here the ranking key
|
|
290
|
+
(co-mention frequency) is computable directly in indexed SQL, so a correct,
|
|
291
|
+
deterministic top-``frontier_cap`` selection is cheap — truncation is safe
|
|
292
|
+
and is exactly what the headline themes/audio surfaces need for hub people.
|
|
293
|
+
"""
|
|
294
|
+
# frontier_cap bounds the fetched rows (overflow detection). It is a
|
|
295
|
+
# bound parameter, not interpolated, but the positive-int contract
|
|
296
|
+
# mirrors the prior form.
|
|
297
|
+
_require_positive_int(frontier_cap, "frontier_cap")
|
|
298
|
+
# Fetch one extra row to DETECT overflow (see contract above). The seed
|
|
299
|
+
# uuid is bound (parameterized) on every clause — no string formatting of
|
|
300
|
+
# inputs into SQL. ``entity_id`` / ``document_id`` are uuid columns; the
|
|
301
|
+
# str params follow the established scalar convention (e.g.
|
|
302
|
+
# ``brain.graph_rag.relational.read_doc_mentions``).
|
|
303
|
+
fetch_limit = frontier_cap + 1
|
|
304
|
+
try:
|
|
305
|
+
rows = conn.execute(
|
|
306
|
+
"SELECT DISTINCT co.entity_id::text AS eid, "
|
|
307
|
+
"seed.document_id::text AS did "
|
|
308
|
+
"FROM graph_entity_mentions AS seed "
|
|
309
|
+
"JOIN graph_entity_mentions AS co "
|
|
310
|
+
" ON co.tenant_id = seed.tenant_id "
|
|
311
|
+
" AND co.document_id = seed.document_id "
|
|
312
|
+
"WHERE seed.tenant_id = %s AND seed.entity_id = %s "
|
|
313
|
+
" AND co.entity_id <> %s "
|
|
314
|
+
"LIMIT %s",
|
|
315
|
+
(tenant_id, seed_entity_uuid, seed_entity_uuid, fetch_limit),
|
|
316
|
+
).fetchall()
|
|
317
|
+
except psycopg.Error as exc:
|
|
318
|
+
raise GraphBackendError(
|
|
319
|
+
f"relational person scope failed for seed {seed_entity_uuid!r} "
|
|
320
|
+
f"in tenant {tenant_id!r}: {exc}"
|
|
321
|
+
) from exc
|
|
322
|
+
if len(rows) > frontier_cap:
|
|
323
|
+
# More co-mention rows than the bound → keep the strongest frontier_cap
|
|
324
|
+
# worth of scope (deterministic ranked truncation + WARNING), never a
|
|
325
|
+
# crash. Bounded exactly as before: the truncated scope feeds ≤
|
|
326
|
+
# frontier_cap rows downstream.
|
|
327
|
+
return _scope_person_truncated(
|
|
328
|
+
conn, tenant_id, seed_entity_uuid, frontier_cap=frontier_cap
|
|
329
|
+
)
|
|
330
|
+
entity_uuids: set[str] = set()
|
|
331
|
+
document_uuids: set[str] = set()
|
|
332
|
+
for eid, did in rows:
|
|
333
|
+
entity_uuids.add(str(eid))
|
|
334
|
+
document_uuids.add(str(did))
|
|
335
|
+
# Sort ascending so the (set-valued) scope is returned deterministically.
|
|
336
|
+
return PersonScope(
|
|
337
|
+
seed_entity_uuid=seed_entity_uuid,
|
|
338
|
+
entity_uuids=tuple(sorted(entity_uuids)),
|
|
339
|
+
document_uuids=tuple(sorted(document_uuids)),
|
|
340
|
+
tenant_id=tenant_id,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _scope_person_truncated(
|
|
345
|
+
conn: psycopg.Connection[Any],
|
|
346
|
+
tenant_id: str,
|
|
347
|
+
seed_entity_uuid: str,
|
|
348
|
+
*,
|
|
349
|
+
frontier_cap: int,
|
|
350
|
+
) -> PersonScope:
|
|
351
|
+
"""Deterministically truncate an over-cap person scope to its strongest part.
|
|
352
|
+
|
|
353
|
+
Reached only from :func:`_scope_person_relational` when the seed's distinct
|
|
354
|
+
``(co-entity, document)`` co-mention rows exceed ``frontier_cap``. Ranks the
|
|
355
|
+
co-mentioned entities by co-mention frequency with the seed (DESC), breaking
|
|
356
|
+
ties by newest shared document (DESC) then entity id (ASC) — a total order,
|
|
357
|
+
so the selection is reproducible across runs. It then keeps the strongest
|
|
358
|
+
prefix of entities whose cumulative co-mention-row count stays within
|
|
359
|
+
``frontier_cap`` (always keeping at least the top entity), and resolves their
|
|
360
|
+
connecting documents (themselves capped to ``frontier_cap``, newest-first, to
|
|
361
|
+
bound the rare single-dominant-entity case). One actionable WARNING is logged
|
|
362
|
+
— never silent, never a crash. The result preserves the downstream envelope
|
|
363
|
+
the safe-cap guaranteed: at most ``frontier_cap`` entity rows and at most
|
|
364
|
+
``frontier_cap`` documents reach the df/lift/grouping stage.
|
|
365
|
+
"""
|
|
366
|
+
# Rank query: one row per co-entity with its shared-doc count + newest shared
|
|
367
|
+
# doc. Bounded by the SEED's co-entity neighborhood (the legitimate scope,
|
|
368
|
+
# index-served via the gem PK + idx_gem_document), never a tenant-wide scan —
|
|
369
|
+
# and we only ever keep a ≤ frontier_cap prefix of it. All inputs bound
|
|
370
|
+
# (parameterized); the seed uuid casts against the uuid columns as elsewhere.
|
|
371
|
+
try:
|
|
372
|
+
ranked = conn.execute(
|
|
373
|
+
"SELECT co.entity_id::text AS eid, "
|
|
374
|
+
"COUNT(DISTINCT seed.document_id) AS comention_count, "
|
|
375
|
+
"MAX(d.ingested_at) AS newest_doc "
|
|
376
|
+
"FROM graph_entity_mentions AS seed "
|
|
377
|
+
"JOIN graph_entity_mentions AS co "
|
|
378
|
+
" ON co.tenant_id = seed.tenant_id "
|
|
379
|
+
" AND co.document_id = seed.document_id "
|
|
380
|
+
"JOIN documents AS d ON d.id = seed.document_id "
|
|
381
|
+
"WHERE seed.tenant_id = %s AND seed.entity_id = %s "
|
|
382
|
+
" AND co.entity_id <> %s "
|
|
383
|
+
"GROUP BY co.entity_id "
|
|
384
|
+
"ORDER BY comention_count DESC, newest_doc DESC, eid ASC",
|
|
385
|
+
(tenant_id, seed_entity_uuid, seed_entity_uuid),
|
|
386
|
+
).fetchall()
|
|
387
|
+
except psycopg.Error as exc:
|
|
388
|
+
raise GraphBackendError(
|
|
389
|
+
f"relational person scope ranking failed for seed "
|
|
390
|
+
f"{seed_entity_uuid!r} in tenant {tenant_id!r}: {exc}"
|
|
391
|
+
) from exc
|
|
392
|
+
total_rows = sum(int(count) for _eid, count, _newest in ranked)
|
|
393
|
+
# Greedy prefix by the (co-entity, document) row budget. Each kept entity is
|
|
394
|
+
# included with ALL its shared docs (a partial entity would distort its
|
|
395
|
+
# in-scope df), so we stop before the first entity that would overflow the
|
|
396
|
+
# cap. The top entity is always kept (its docs are bounded below) so a single
|
|
397
|
+
# dominant co-entity never collapses the scope to empty.
|
|
398
|
+
kept_entities: list[str] = []
|
|
399
|
+
kept_rows = 0
|
|
400
|
+
for eid, count, _newest in ranked:
|
|
401
|
+
count_int = int(count)
|
|
402
|
+
if kept_entities and kept_rows + count_int > frontier_cap:
|
|
403
|
+
break
|
|
404
|
+
kept_entities.append(str(eid))
|
|
405
|
+
kept_rows += count_int
|
|
406
|
+
document_uuids = _scope_connecting_documents(
|
|
407
|
+
conn, tenant_id, seed_entity_uuid, kept_entities, frontier_cap=frontier_cap
|
|
408
|
+
)
|
|
409
|
+
_logger.warning(
|
|
410
|
+
"graphrag person scope for seed %s exceeded frontier_cap=%d: kept the %d "
|
|
411
|
+
"strongest co-entities (%d of %d co-mention rows); raise "
|
|
412
|
+
"BRAIN_GRAPH_FRONTIER_CAP to widen the scope.",
|
|
413
|
+
seed_entity_uuid,
|
|
414
|
+
frontier_cap,
|
|
415
|
+
len(kept_entities),
|
|
416
|
+
kept_rows,
|
|
417
|
+
total_rows,
|
|
418
|
+
)
|
|
419
|
+
return PersonScope(
|
|
420
|
+
seed_entity_uuid=seed_entity_uuid,
|
|
421
|
+
entity_uuids=tuple(sorted(kept_entities)),
|
|
422
|
+
document_uuids=document_uuids,
|
|
423
|
+
tenant_id=tenant_id,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _scope_connecting_documents(
|
|
428
|
+
conn: psycopg.Connection[Any],
|
|
429
|
+
tenant_id: str,
|
|
430
|
+
seed_entity_uuid: str,
|
|
431
|
+
kept_entities: list[str],
|
|
432
|
+
*,
|
|
433
|
+
frontier_cap: int,
|
|
434
|
+
) -> tuple[str, ...]:
|
|
435
|
+
"""Connecting documents for the kept co-entities, bounded + deterministic.
|
|
436
|
+
|
|
437
|
+
The distinct documents in which the seed and at least one ``kept_entities``
|
|
438
|
+
member are both mentioned. In the common truncated case this is already ≤
|
|
439
|
+
``frontier_cap`` (the greedy budget bounds the kept rows); only the rare
|
|
440
|
+
single-dominant-entity case (one co-entity sharing more than ``frontier_cap``
|
|
441
|
+
docs with the seed) can exceed it, and there we keep the ``frontier_cap``
|
|
442
|
+
newest documents (``ingested_at`` DESC, id ASC) so the document array also
|
|
443
|
+
stays within the cap. Returned sorted ascending for determinism.
|
|
444
|
+
"""
|
|
445
|
+
if not kept_entities:
|
|
446
|
+
return ()
|
|
447
|
+
try:
|
|
448
|
+
rows = conn.execute(
|
|
449
|
+
"SELECT DISTINCT seed.document_id::text AS did, "
|
|
450
|
+
"d.ingested_at AS doc_ts "
|
|
451
|
+
"FROM graph_entity_mentions AS seed "
|
|
452
|
+
"JOIN graph_entity_mentions AS co "
|
|
453
|
+
" ON co.tenant_id = seed.tenant_id "
|
|
454
|
+
" AND co.document_id = seed.document_id "
|
|
455
|
+
"JOIN documents AS d ON d.id = seed.document_id "
|
|
456
|
+
"WHERE seed.tenant_id = %s AND seed.entity_id = %s "
|
|
457
|
+
" AND co.entity_id = ANY(%s)",
|
|
458
|
+
(tenant_id, seed_entity_uuid, kept_entities),
|
|
459
|
+
).fetchall()
|
|
460
|
+
except psycopg.Error as exc:
|
|
461
|
+
raise GraphBackendError(
|
|
462
|
+
f"relational person scope documents failed for seed "
|
|
463
|
+
f"{seed_entity_uuid!r} in tenant {tenant_id!r}: {exc}"
|
|
464
|
+
) from exc
|
|
465
|
+
if len(rows) <= frontier_cap:
|
|
466
|
+
return tuple(sorted(str(did) for did, _doc_ts in rows))
|
|
467
|
+
# Single dominant co-entity overflowed the cap alone: keep the newest
|
|
468
|
+
# frontier_cap docs. Stable two-pass sort (id ASC, then ingested_at DESC)
|
|
469
|
+
# gives a total newest-first order with an id tiebreak.
|
|
470
|
+
by_id = sorted(rows, key=lambda row: str(row[0]))
|
|
471
|
+
newest_first = sorted(by_id, key=lambda row: row[1], reverse=True)
|
|
472
|
+
kept_docs = [str(did) for did, _doc_ts in newest_first[:frontier_cap]]
|
|
473
|
+
return tuple(sorted(kept_docs))
|