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,353 @@
|
|
|
1
|
+
"""Per-document relational source-of-truth helpers for graph reconcile.
|
|
2
|
+
|
|
3
|
+
Aspect-agnostic read/write + per-aspect watermark helpers shared by the person
|
|
4
|
+
and concept reconcile paths in :mod:`brain.graph_rag.reconcile`. Extracted from
|
|
5
|
+
that module (mirroring the G1 :mod:`brain.graph_rag.aggregates` boundary) so the
|
|
6
|
+
orchestrator stays lean and these parameterized-SQL primitives live in one
|
|
7
|
+
focused place. Every function is tenant-scoped and operates only on the
|
|
8
|
+
migration-012 relational tables (``documents`` is read-only here); the AGE mirror
|
|
9
|
+
is the orchestrator's concern.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import psycopg
|
|
16
|
+
|
|
17
|
+
from ..errors import GraphBackendError
|
|
18
|
+
from .schema import EdgeContribution, EntityMention, EntitySummary, GraphStats
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"delete_doc_relational",
|
|
22
|
+
"fetch_doc_content",
|
|
23
|
+
"fetch_doc_meta",
|
|
24
|
+
"graph_stats",
|
|
25
|
+
"index_state",
|
|
26
|
+
"list_entities",
|
|
27
|
+
"read_doc_mentions",
|
|
28
|
+
"rewrite_doc_relational",
|
|
29
|
+
"upsert_index_state",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# Allowlists for validated query parameters — never interpolated into SQL.
|
|
33
|
+
_VALID_ENTITY_TYPES: frozenset[str] = frozenset({"org", "project", "tool", "topic", "person"})
|
|
34
|
+
_VALID_SORT_OPTIONS: frozenset[str] = frozenset({"docs", "name"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fetch_doc_meta(
|
|
38
|
+
conn: psycopg.Connection[Any], document_id: str
|
|
39
|
+
) -> tuple[str, str] | None:
|
|
40
|
+
"""Return ``(content_hash, content_type)`` for a document, or ``None``."""
|
|
41
|
+
row = conn.execute(
|
|
42
|
+
"SELECT content_hash, content_type FROM documents WHERE id = %s",
|
|
43
|
+
(document_id,),
|
|
44
|
+
).fetchone()
|
|
45
|
+
if row is None:
|
|
46
|
+
return None
|
|
47
|
+
return (str(row[0]), str(row[1]))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fetch_doc_content(conn: psycopg.Connection[Any], document_id: str) -> str:
|
|
51
|
+
"""Return a document's body text (for concept extraction; spec §4 D4).
|
|
52
|
+
|
|
53
|
+
Fetched lazily — only when the concept aspect is stale and about to extract —
|
|
54
|
+
so the person-only path never pays to load a (potentially large) body.
|
|
55
|
+
"""
|
|
56
|
+
row = conn.execute(
|
|
57
|
+
"SELECT content FROM documents WHERE id = %s",
|
|
58
|
+
(document_id,),
|
|
59
|
+
).fetchone()
|
|
60
|
+
return str(row[0]) if row is not None and row[0] is not None else ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def read_doc_mentions(
|
|
64
|
+
conn: psycopg.Connection[Any], tenant_id: str, document_id: str
|
|
65
|
+
) -> list[EntityMention]:
|
|
66
|
+
"""Read a doc's COMBINED current mentions (both aspects) from relational.
|
|
67
|
+
|
|
68
|
+
The relational source-of-truth after the per-aspect rewrites; feeds the
|
|
69
|
+
single AGE ``MENTIONED_IN`` rebuild so a fresh aspect's edges are preserved
|
|
70
|
+
intact. Ordered by ``entity_id`` for deterministic Cypher emission.
|
|
71
|
+
"""
|
|
72
|
+
rows = conn.execute(
|
|
73
|
+
"SELECT entity_id::text, source, mention_count FROM graph_entity_mentions "
|
|
74
|
+
"WHERE tenant_id = %s AND document_id = %s ORDER BY entity_id",
|
|
75
|
+
(tenant_id, document_id),
|
|
76
|
+
).fetchall()
|
|
77
|
+
return [
|
|
78
|
+
EntityMention(
|
|
79
|
+
entity_id=str(row[0]),
|
|
80
|
+
document_id=document_id,
|
|
81
|
+
source=str(row[1]),
|
|
82
|
+
tenant_id=tenant_id,
|
|
83
|
+
mention_count=int(row[2]),
|
|
84
|
+
)
|
|
85
|
+
for row in rows
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def index_state(
|
|
90
|
+
conn: psycopg.Connection[Any],
|
|
91
|
+
tenant_id: str,
|
|
92
|
+
document_id: str,
|
|
93
|
+
aspect: str,
|
|
94
|
+
) -> tuple[str, str, str, str] | None:
|
|
95
|
+
"""Return the stored watermark tuple for one aspect, or ``None`` if absent."""
|
|
96
|
+
row = conn.execute(
|
|
97
|
+
"SELECT content_hash, inputs_hash, extractor_ver, suppress_ver "
|
|
98
|
+
"FROM graph_index_state "
|
|
99
|
+
"WHERE tenant_id = %s AND document_id = %s AND aspect = %s",
|
|
100
|
+
(tenant_id, document_id, aspect),
|
|
101
|
+
).fetchone()
|
|
102
|
+
if row is None:
|
|
103
|
+
return None
|
|
104
|
+
return (str(row[0]), str(row[1]), str(row[2]), str(row[3]))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def upsert_index_state(
|
|
108
|
+
conn: psycopg.Connection[Any],
|
|
109
|
+
tenant_id: str,
|
|
110
|
+
document_id: str,
|
|
111
|
+
*,
|
|
112
|
+
aspect: str,
|
|
113
|
+
content_hash: str,
|
|
114
|
+
inputs_hash: str,
|
|
115
|
+
extractor_ver: str,
|
|
116
|
+
sver: str,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Write/refresh one aspect's ``graph_index_state`` watermark row."""
|
|
119
|
+
conn.execute(
|
|
120
|
+
"""
|
|
121
|
+
INSERT INTO graph_index_state
|
|
122
|
+
(tenant_id, document_id, aspect, content_hash, inputs_hash,
|
|
123
|
+
extractor_ver, suppress_ver, indexed_at)
|
|
124
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
|
|
125
|
+
ON CONFLICT (tenant_id, document_id, aspect) DO UPDATE SET
|
|
126
|
+
content_hash = EXCLUDED.content_hash,
|
|
127
|
+
inputs_hash = EXCLUDED.inputs_hash,
|
|
128
|
+
extractor_ver = EXCLUDED.extractor_ver,
|
|
129
|
+
suppress_ver = EXCLUDED.suppress_ver,
|
|
130
|
+
indexed_at = NOW()
|
|
131
|
+
""",
|
|
132
|
+
(
|
|
133
|
+
tenant_id,
|
|
134
|
+
document_id,
|
|
135
|
+
aspect,
|
|
136
|
+
content_hash,
|
|
137
|
+
inputs_hash,
|
|
138
|
+
extractor_ver,
|
|
139
|
+
sver,
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def rewrite_doc_relational(
|
|
145
|
+
conn: psycopg.Connection[Any],
|
|
146
|
+
tenant_id: str,
|
|
147
|
+
document_id: str,
|
|
148
|
+
mentions: list[EntityMention],
|
|
149
|
+
contributions: list[EdgeContribution],
|
|
150
|
+
*,
|
|
151
|
+
entity_types: tuple[str, ...],
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Aspect-scoped delete + reinsert of one doc's mentions + contributions.
|
|
154
|
+
|
|
155
|
+
Deletes ONLY the doc's rows whose entity is one of ``entity_types`` (the
|
|
156
|
+
aspect being rewritten) before reinserting, so the two aspects never clobber
|
|
157
|
+
each other's rows for the same document. People and concepts never share a
|
|
158
|
+
co-occurrence pair (each aspect's co-occurrence is computed within its own
|
|
159
|
+
entity set), so a contribution's ``src_id`` aspect determines the whole row's
|
|
160
|
+
aspect — scoping the contribution delete on ``src_id`` alone is correct. With
|
|
161
|
+
a single aspect active (the person-only default) this scopes to ``('person',)``
|
|
162
|
+
and matches exactly the rows the old delete-all removed.
|
|
163
|
+
"""
|
|
164
|
+
type_list = list(entity_types)
|
|
165
|
+
conn.execute(
|
|
166
|
+
"DELETE FROM graph_entity_mentions "
|
|
167
|
+
"WHERE tenant_id = %s AND document_id = %s AND entity_id IN ("
|
|
168
|
+
" SELECT id FROM graph_entities "
|
|
169
|
+
" WHERE tenant_id = %s AND entity_type = ANY(%s))",
|
|
170
|
+
(tenant_id, document_id, tenant_id, type_list),
|
|
171
|
+
)
|
|
172
|
+
conn.execute(
|
|
173
|
+
"DELETE FROM graph_edge_contributions "
|
|
174
|
+
"WHERE tenant_id = %s AND document_id = %s AND src_id IN ("
|
|
175
|
+
" SELECT id FROM graph_entities "
|
|
176
|
+
" WHERE tenant_id = %s AND entity_type = ANY(%s))",
|
|
177
|
+
(tenant_id, document_id, tenant_id, type_list),
|
|
178
|
+
)
|
|
179
|
+
for mention in mentions:
|
|
180
|
+
conn.execute(
|
|
181
|
+
"INSERT INTO graph_entity_mentions "
|
|
182
|
+
"(tenant_id, entity_id, document_id, mention_count, source) "
|
|
183
|
+
"VALUES (%s, %s, %s, %s, %s)",
|
|
184
|
+
(
|
|
185
|
+
tenant_id,
|
|
186
|
+
mention.entity_id,
|
|
187
|
+
document_id,
|
|
188
|
+
mention.mention_count,
|
|
189
|
+
mention.source,
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
for contribution in contributions:
|
|
193
|
+
conn.execute(
|
|
194
|
+
"INSERT INTO graph_edge_contributions "
|
|
195
|
+
"(tenant_id, document_id, src_id, dst_id, cooccur_count) "
|
|
196
|
+
"VALUES (%s, %s, %s, %s, %s)",
|
|
197
|
+
(
|
|
198
|
+
tenant_id,
|
|
199
|
+
document_id,
|
|
200
|
+
contribution.src_id,
|
|
201
|
+
contribution.dst_id,
|
|
202
|
+
contribution.cooccur_count,
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def delete_doc_relational(
|
|
208
|
+
conn: psycopg.Connection[Any], tenant_id: str, document_id: str
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Delete one doc's source rows + BOTH aspects' watermarks (idempotent).
|
|
211
|
+
|
|
212
|
+
Aspect-agnostic delete-all for the document — mentions/contributions of every
|
|
213
|
+
entity type, and the people AND concepts ``graph_index_state`` rows — so a
|
|
214
|
+
removed document leaves no graph presence regardless of which aspects indexed
|
|
215
|
+
it (wave G2-c).
|
|
216
|
+
"""
|
|
217
|
+
conn.execute(
|
|
218
|
+
"DELETE FROM graph_entity_mentions WHERE tenant_id = %s AND document_id = %s",
|
|
219
|
+
(tenant_id, document_id),
|
|
220
|
+
)
|
|
221
|
+
conn.execute(
|
|
222
|
+
"DELETE FROM graph_edge_contributions "
|
|
223
|
+
"WHERE tenant_id = %s AND document_id = %s",
|
|
224
|
+
(tenant_id, document_id),
|
|
225
|
+
)
|
|
226
|
+
conn.execute(
|
|
227
|
+
"DELETE FROM graph_index_state WHERE tenant_id = %s AND document_id = %s",
|
|
228
|
+
(tenant_id, document_id),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# Listing helpers for the admin enumeration surfaces (wave plan 2026-05-23).
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def list_entities(
|
|
238
|
+
conn: psycopg.Connection[Any],
|
|
239
|
+
tenant_id: str,
|
|
240
|
+
*,
|
|
241
|
+
entity_type: str | None = None,
|
|
242
|
+
sort: str = "docs",
|
|
243
|
+
limit: int = 50,
|
|
244
|
+
) -> list[EntitySummary]:
|
|
245
|
+
"""List entities for the tenant, filtered and sorted (admin listing).
|
|
246
|
+
|
|
247
|
+
Returns :class:`~brain.graph_rag.schema.EntitySummary` rows from
|
|
248
|
+
``graph_entities``, filtered to ``entity_type`` when given, ordered by
|
|
249
|
+
``sort`` (``"docs"`` → ``doc_count DESC, name ASC``; ``"name"`` →
|
|
250
|
+
``name ASC``), and capped at ``limit`` rows (``limit <= 0`` returns all).
|
|
251
|
+
Read-only; no raw Cypher or AGE traversal. The raw ``embedding`` column is
|
|
252
|
+
not selected (a storage handle, not a wire value).
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
GraphBackendError: ``entity_type`` is not one of the five known types, or
|
|
256
|
+
``sort`` is not one of the two valid options.
|
|
257
|
+
"""
|
|
258
|
+
if entity_type is not None and entity_type not in _VALID_ENTITY_TYPES:
|
|
259
|
+
raise GraphBackendError(
|
|
260
|
+
f"invalid entity_type {entity_type!r}; "
|
|
261
|
+
f"must be one of {sorted(_VALID_ENTITY_TYPES)}"
|
|
262
|
+
)
|
|
263
|
+
if sort not in _VALID_SORT_OPTIONS:
|
|
264
|
+
raise GraphBackendError(
|
|
265
|
+
f"invalid sort {sort!r}; must be one of {sorted(_VALID_SORT_OPTIONS)}"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
params: list[Any] = [tenant_id]
|
|
269
|
+
where_clause = "WHERE tenant_id = %s"
|
|
270
|
+
if entity_type is not None:
|
|
271
|
+
where_clause += " AND entity_type = %s"
|
|
272
|
+
params.append(entity_type)
|
|
273
|
+
|
|
274
|
+
order_clause = (
|
|
275
|
+
"ORDER BY doc_count DESC, name ASC" if sort == "docs" else "ORDER BY name ASC"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
sql = (
|
|
279
|
+
"SELECT entity_type, name, canonical_key, doc_count, description "
|
|
280
|
+
f"FROM graph_entities {where_clause} {order_clause}"
|
|
281
|
+
)
|
|
282
|
+
if limit > 0:
|
|
283
|
+
sql += " LIMIT %s"
|
|
284
|
+
params.append(limit)
|
|
285
|
+
|
|
286
|
+
rows = conn.execute(sql, tuple(params)).fetchall()
|
|
287
|
+
return [
|
|
288
|
+
EntitySummary(
|
|
289
|
+
entity_type=str(row[0]),
|
|
290
|
+
name=str(row[1]),
|
|
291
|
+
canonical_key=str(row[2]),
|
|
292
|
+
doc_count=int(row[3]),
|
|
293
|
+
description=str(row[4]) if row[4] is not None else None,
|
|
294
|
+
)
|
|
295
|
+
for row in rows
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def graph_stats(
|
|
300
|
+
conn: psycopg.Connection[Any],
|
|
301
|
+
tenant_id: str,
|
|
302
|
+
) -> GraphStats:
|
|
303
|
+
"""Return an at-a-glance graph overview for the tenant.
|
|
304
|
+
|
|
305
|
+
Reads entity counts grouped by type from ``graph_entities``, the
|
|
306
|
+
relationship count from ``graph_relationships``, the community count from
|
|
307
|
+
``graph_communities``, and the top-10 entities by ``doc_count``
|
|
308
|
+
(the same slice :func:`list_entities` with ``limit=10, sort="docs"``
|
|
309
|
+
returns). All queries are tenant-scoped and parameterized. Read-only.
|
|
310
|
+
"""
|
|
311
|
+
type_rows = conn.execute(
|
|
312
|
+
"SELECT entity_type, COUNT(*) FROM graph_entities "
|
|
313
|
+
"WHERE tenant_id = %s GROUP BY entity_type ORDER BY entity_type",
|
|
314
|
+
(tenant_id,),
|
|
315
|
+
).fetchall()
|
|
316
|
+
counts_by_type: dict[str, int] = {str(row[0]): int(row[1]) for row in type_rows}
|
|
317
|
+
total_entities = sum(counts_by_type.values())
|
|
318
|
+
|
|
319
|
+
rel_row = conn.execute(
|
|
320
|
+
"SELECT COUNT(*) FROM graph_relationships WHERE tenant_id = %s",
|
|
321
|
+
(tenant_id,),
|
|
322
|
+
).fetchone()
|
|
323
|
+
total_relationships = int(rel_row[0]) if rel_row is not None else 0
|
|
324
|
+
|
|
325
|
+
comm_row = conn.execute(
|
|
326
|
+
"SELECT COUNT(*) FROM graph_communities WHERE tenant_id = %s",
|
|
327
|
+
(tenant_id,),
|
|
328
|
+
).fetchone()
|
|
329
|
+
total_communities = int(comm_row[0]) if comm_row is not None else 0
|
|
330
|
+
|
|
331
|
+
top_rows = conn.execute(
|
|
332
|
+
"SELECT entity_type, name, canonical_key, doc_count, description "
|
|
333
|
+
"FROM graph_entities WHERE tenant_id = %s "
|
|
334
|
+
"ORDER BY doc_count DESC, name ASC LIMIT 10",
|
|
335
|
+
(tenant_id,),
|
|
336
|
+
).fetchall()
|
|
337
|
+
top_entities = tuple(
|
|
338
|
+
EntitySummary(
|
|
339
|
+
entity_type=str(row[0]),
|
|
340
|
+
name=str(row[1]),
|
|
341
|
+
canonical_key=str(row[2]),
|
|
342
|
+
doc_count=int(row[3]),
|
|
343
|
+
description=str(row[4]) if row[4] is not None else None,
|
|
344
|
+
)
|
|
345
|
+
for row in top_rows
|
|
346
|
+
)
|
|
347
|
+
return GraphStats(
|
|
348
|
+
counts_by_type=counts_by_type,
|
|
349
|
+
total_entities=total_entities,
|
|
350
|
+
total_relationships=total_relationships,
|
|
351
|
+
total_communities=total_communities,
|
|
352
|
+
top_entities=top_entities,
|
|
353
|
+
)
|