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,792 @@
|
|
|
1
|
+
"""Incremental graph reconcile — people + concept aspects (G1-b/G2-c, spec §7).
|
|
2
|
+
|
|
3
|
+
Centralizes the "make the graph match this document" operation. The **person
|
|
4
|
+
aspect** (G1-b) always runs; the **concept aspect** (G2-c) runs additionally
|
|
5
|
+
when ``config.concepts_enabled`` is set and an
|
|
6
|
+
:class:`~brain.graph_rag.extract.EntityExtractor` is injected (its relational
|
|
7
|
+
helpers live in :mod:`brain.graph_rag.concepts`; the shared per-document
|
|
8
|
+
read/write primitives in :mod:`brain.graph_rag.relational`). Each aspect carries
|
|
9
|
+
its OWN per-aspect ``graph_index_state`` watermark and skips independently. This
|
|
10
|
+
module keeps the three layers in lock-step for one document:
|
|
11
|
+
|
|
12
|
+
1. the **relational source-of-truth** (migration 012: ``graph_entities`` /
|
|
13
|
+
``graph_entity_mentions`` / ``graph_edge_contributions``),
|
|
14
|
+
2. the **derived aggregate mirror** (``graph_relationships`` — normalized lift,
|
|
15
|
+
recomputed from contributions), and
|
|
16
|
+
3. the **Apache AGE graph** (``Entity`` / ``Document`` vertices, ``MENTIONED_IN``
|
|
17
|
+
/ ``CO_OCCURS`` edges), synced through the G0-4 :class:`GraphBackend`
|
|
18
|
+
primitives — reconcile *orchestrates* them and never emits raw Cypher itself.
|
|
19
|
+
|
|
20
|
+
Two entry points, both tenant-aware and idempotent:
|
|
21
|
+
|
|
22
|
+
* :func:`reconcile_document` — (re)index one document's people aspect. Skips when
|
|
23
|
+
the per-aspect ``graph_index_state`` watermark (``content_hash`` +
|
|
24
|
+
``inputs_hash`` + ``extractor_ver`` + ``suppress_ver``; spec §7 step 1) is
|
|
25
|
+
unchanged, mirroring the enrich idempotency pattern.
|
|
26
|
+
* :func:`remove_document` — drop one document from the people graph (all delete
|
|
27
|
+
paths). Explicitly deletes the doc's source rows (rather than relying on the
|
|
28
|
+
``ON DELETE CASCADE`` from a possibly-not-yet-deleted ``documents`` row), so it
|
|
29
|
+
is correct whether called before or after the row itself goes away.
|
|
30
|
+
|
|
31
|
+
**Person source-of-truth (spec §3 reuse map).** Person entities are derived for
|
|
32
|
+
free from the existing people pipeline — ``directory_entries`` +
|
|
33
|
+
``documents.metadata`` participant keys — via the same internal helpers
|
|
34
|
+
:mod:`brain.wiki.build_people` uses (``_build_directory_index`` /
|
|
35
|
+
``_doc_participant_keys`` / ``_resolve_key_to_person`` / ``humanize_display_name``),
|
|
36
|
+
so a doc's graph people roster can never drift from its People-Hub roster. Each
|
|
37
|
+
resolved person becomes a ``graph_entities`` row keyed on
|
|
38
|
+
``(tenant_id, entity_type='person', canonical_key)`` where ``canonical_key`` is
|
|
39
|
+
the normalized lowercase display name (the People-Hub canonical identity) and
|
|
40
|
+
``name`` is its humanized form.
|
|
41
|
+
|
|
42
|
+
**Doc-level person co-occurrence model (spec §6b; documented choice).** A person
|
|
43
|
+
derived from participants has no raw-text position, so the spec's "window
|
|
44
|
+
co-occurrence over raw text" (spec §4 D4) is interpreted at the *document* level
|
|
45
|
+
for the person aspect: every participant of a document is modelled as occurring
|
|
46
|
+
at the same notional position (0), so under :mod:`brain.graph_rag.cooccur`'s
|
|
47
|
+
``|pos_i - pos_j| <= window`` predicate (any ``window >= 1``) every *distinct*
|
|
48
|
+
pair of the doc's persons co-occurs **exactly once** — the complete graph over
|
|
49
|
+
the doc's persons with raw count 1. This is co-presence in a document, not text
|
|
50
|
+
proximity, and it is chunker-independent (spec §4 D4). The same
|
|
51
|
+
:func:`~brain.graph_rag.cooccur.cooccurrence_counts` /
|
|
52
|
+
:func:`~brain.graph_rag.cooccur.to_contributions` from G1-a produce the rows.
|
|
53
|
+
|
|
54
|
+
**Aggregate refresh = full tenant recompute (spec §7 step 4, §15).** Because the
|
|
55
|
+
aggregates derive purely from the per-document source-of-truth, every reconcile
|
|
56
|
+
that does work recomputes the tenant's ``graph_relationships`` (normalized lift +
|
|
57
|
+
generic suppression, G1-a :mod:`~brain.graph_rag.weighting`) from *all*
|
|
58
|
+
contributions, then rematerializes the AGE ``CO_OCCURS`` edges via
|
|
59
|
+
:meth:`~brain.graph_rag.backends.base.GraphBackend.refresh_cooccur_edges`. Full
|
|
60
|
+
refresh is correct and cascade-safe; affected-only incremental refresh is
|
|
61
|
+
explicitly out of scope for v1 (spec §15) and batching/perf is a later wave.
|
|
62
|
+
|
|
63
|
+
**Resolved spec ambiguities (flagged for review).**
|
|
64
|
+
|
|
65
|
+
* *Tenant corpus N for generic suppression* (spec §6b "round(GENERIC_DF ×
|
|
66
|
+
tenant_corpus_N)"): defined here as the count of **distinct documents that have
|
|
67
|
+
any mention in the tenant** (person + concept union; ``COUNT(DISTINCT
|
|
68
|
+
document_id)`` over ``graph_entity_mentions`` regardless of ``source``) — the
|
|
69
|
+
graph's document universe for the tenant. Tenant-scoped and well-defined;
|
|
70
|
+
``documents`` is not tenantized in G0.
|
|
71
|
+
* *GC scope*: now-zero-mention vertices of EVERY active aspect are garbage-
|
|
72
|
+
collected — :func:`reconcile_document` GCs the person and (when concepts are
|
|
73
|
+
enabled) concept aspects it ran, and :func:`remove_document` GCs both
|
|
74
|
+
unconditionally (a removed document loses all of its graph presence).
|
|
75
|
+
|
|
76
|
+
**Config (single object, no divergence).** Both entry points take ONE frozen
|
|
77
|
+
:class:`ReconcileConfig` (tenant + co-occurrence window + per-doc cap + generic
|
|
78
|
+
ratio + owner keys). Bundling them guarantees a build and a later delete can
|
|
79
|
+
never recompute the full-tenant aggregate with a *different* ``generic_df_ratio``
|
|
80
|
+
(which would corrupt the suppression/weights). G1-c/G1-d resolve it once from
|
|
81
|
+
``BRAIN_GRAPH_*`` (spec §10) and thread the same object through every write/delete
|
|
82
|
+
path.
|
|
83
|
+
|
|
84
|
+
**Connection contract (atomicity).** Each entry point opens ``with
|
|
85
|
+
conn.transaction()`` as its FIRST database action and performs *all* reads and
|
|
86
|
+
writes inside it. That guarantees a true top-level transaction even on a
|
|
87
|
+
caller's ``autocommit=False`` connection (a pre-transaction read would otherwise
|
|
88
|
+
open an implicit transaction and demote the block to a SAVEPOINT), so the
|
|
89
|
+
relational rewrite and the AGE sync commit or roll back **together** — a failure
|
|
90
|
+
mid-sync never leaves a half-written graph or a dangling transaction. When the
|
|
91
|
+
caller is already inside a transaction the block nests as a SAVEPOINT and the
|
|
92
|
+
caller still owns the outer transaction (G1-c wiring relies on this).
|
|
93
|
+
"""
|
|
94
|
+
from __future__ import annotations
|
|
95
|
+
|
|
96
|
+
import hashlib
|
|
97
|
+
import json
|
|
98
|
+
from dataclasses import dataclass
|
|
99
|
+
from typing import Any, Protocol
|
|
100
|
+
|
|
101
|
+
import psycopg
|
|
102
|
+
|
|
103
|
+
from ..errors import GraphReconcileError
|
|
104
|
+
from .aggregates import (
|
|
105
|
+
RefreshResult,
|
|
106
|
+
_gc_orphan_concepts,
|
|
107
|
+
_gc_orphan_persons,
|
|
108
|
+
_recompute_aggregates,
|
|
109
|
+
refresh_aggregates,
|
|
110
|
+
)
|
|
111
|
+
from .backends.base import GraphBackend
|
|
112
|
+
from .concepts import (
|
|
113
|
+
CONCEPT_ENTITY_TYPES,
|
|
114
|
+
CONCEPTS_ASPECT,
|
|
115
|
+
build_concept_rows,
|
|
116
|
+
concept_inputs_hash,
|
|
117
|
+
concept_mention_source,
|
|
118
|
+
upsert_concept_entities,
|
|
119
|
+
)
|
|
120
|
+
from .cooccur import (
|
|
121
|
+
DEFAULT_COOCCUR_WINDOW,
|
|
122
|
+
DEFAULT_MAX_ENTITIES_PER_DOC,
|
|
123
|
+
EntityOccurrence,
|
|
124
|
+
cooccurrence_counts,
|
|
125
|
+
to_contributions,
|
|
126
|
+
)
|
|
127
|
+
from .extract import EntityExtractor
|
|
128
|
+
from .person_resolver import ResolvedPerson, default_person_resolver
|
|
129
|
+
from .relational import (
|
|
130
|
+
delete_doc_relational,
|
|
131
|
+
fetch_doc_content,
|
|
132
|
+
fetch_doc_meta,
|
|
133
|
+
index_state,
|
|
134
|
+
read_doc_mentions,
|
|
135
|
+
rewrite_doc_relational,
|
|
136
|
+
upsert_index_state,
|
|
137
|
+
)
|
|
138
|
+
from .schema import EdgeContribution, EntityMention, GraphEntity
|
|
139
|
+
from .weighting import DEFAULT_GENERIC_DF, suppress_ver
|
|
140
|
+
|
|
141
|
+
__all__ = [
|
|
142
|
+
"CONCEPTS_ASPECT",
|
|
143
|
+
"PEOPLE_ASPECT",
|
|
144
|
+
"PEOPLE_ASPECT_VERSION",
|
|
145
|
+
"PEOPLE_MENTION_SOURCE",
|
|
146
|
+
"PersonResolver",
|
|
147
|
+
"ReconcileConfig",
|
|
148
|
+
"ReconcileResult",
|
|
149
|
+
"RefreshResult",
|
|
150
|
+
"ResolvedPerson",
|
|
151
|
+
"default_person_resolver",
|
|
152
|
+
"reconcile_document",
|
|
153
|
+
"refresh_aggregates",
|
|
154
|
+
"remove_document",
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
# The ``entity_type``s the person aspect owns — the scope of its aspect-scoped
|
|
158
|
+
# relational rewrite + GC. Concepts own :data:`CONCEPT_ENTITY_TYPES`.
|
|
159
|
+
_PERSON_ENTITY_TYPES: tuple[str, ...] = ("person",)
|
|
160
|
+
|
|
161
|
+
# The migration-012 ``graph_index_state.aspect`` value this module owns. People
|
|
162
|
+
# and concepts (G2) re-index independently under their own watermark rows.
|
|
163
|
+
PEOPLE_ASPECT = "people"
|
|
164
|
+
|
|
165
|
+
# Versions the people-aspect derivation. Stored in
|
|
166
|
+
# ``graph_index_state.extractor_ver`` (spec §7 step 1) — bumping it forces every
|
|
167
|
+
# document's people aspect to re-reconcile (e.g. if the participant-derivation
|
|
168
|
+
# logic changes). There is no LLM extractor for the people aspect (people are
|
|
169
|
+
# derived from participants), so this is the derivation-logic version.
|
|
170
|
+
#
|
|
171
|
+
# people-v2 (2026-05-23, Phase 1 data-quality remediation): the shared
|
|
172
|
+
# person-name normalizer (:mod:`brain.wiki._person_name`) now drives canonical
|
|
173
|
+
# keys — mailing-list "via X" decoration stripped, ``Last, First`` flipped,
|
|
174
|
+
# separators collapsed (so ``Jane.Doe`` / ``Jane Doe`` merge), email-as-name
|
|
175
|
+
# humanized from the local part, automated / org senders dropped, and owner
|
|
176
|
+
# first-name / local-part variants excluded. A re-`build --backfill` re-extracts
|
|
177
|
+
# every document's people aspect cleanly under the new keys.
|
|
178
|
+
PEOPLE_ASPECT_VERSION = "people-v2"
|
|
179
|
+
|
|
180
|
+
# ``graph_entity_mentions.source`` provenance for person mentions (spec §5a:
|
|
181
|
+
# ``'people'`` for the people pipeline vs ``'extractor:<model>@<ver>'`` for the
|
|
182
|
+
# concept extractor).
|
|
183
|
+
PEOPLE_MENTION_SOURCE = "people"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class ReconcileConfig:
|
|
188
|
+
"""Resolved per-call config shared by reconcile + remove (no divergence).
|
|
189
|
+
|
|
190
|
+
Bundling tenant + co-occurrence window + per-doc cap + generic ratio + owner
|
|
191
|
+
keys into ONE frozen object means :func:`reconcile_document` and
|
|
192
|
+
:func:`remove_document` cannot be handed a *different* ``generic_df_ratio``
|
|
193
|
+
(or tenant) — a divergence would recompute the full-tenant aggregate with
|
|
194
|
+
mismatched suppression and corrupt the weights. G1-c/G1-d build this once
|
|
195
|
+
from ``BRAIN_GRAPH_*`` (spec §10) and thread the same object through every
|
|
196
|
+
write/delete path. ``owner_keys`` is consumed only by the person resolver
|
|
197
|
+
(``reconcile_document``); ``remove_document`` ignores it.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
tenant_id: str = "default"
|
|
201
|
+
cooccur_window: int = DEFAULT_COOCCUR_WINDOW
|
|
202
|
+
max_entities_per_doc: int | None = DEFAULT_MAX_ENTITIES_PER_DOC
|
|
203
|
+
generic_df_ratio: float = DEFAULT_GENERIC_DF
|
|
204
|
+
owner_keys: frozenset[str] = frozenset()
|
|
205
|
+
# Extra automated-sender denylist entries (``BRAIN_GRAPH_SENDER_DENYLIST``)
|
|
206
|
+
# threaded to the person resolver's :func:`brain.wiki._person_name
|
|
207
|
+
# .is_automated_sender` filter, on top of the always-on generic heuristic
|
|
208
|
+
# (no-reply / notifications / mailer / …). Consumed only by the resolver
|
|
209
|
+
# (``reconcile_document``); ``remove_document`` ignores it.
|
|
210
|
+
sender_denylist: frozenset[str] = frozenset()
|
|
211
|
+
# Wave G2-c: gate the concept aspect (``BRAIN_GRAPH_CONCEPTS``). When True AND
|
|
212
|
+
# an :class:`~brain.graph_rag.extract.EntityExtractor` is injected,
|
|
213
|
+
# :func:`reconcile_document` also extracts + indexes the document's concept
|
|
214
|
+
# entities (its own ``aspect='concepts'`` watermark) alongside the always-on
|
|
215
|
+
# person aspect. Default False keeps the person-only behavior unchanged.
|
|
216
|
+
concepts_enabled: bool = False
|
|
217
|
+
# Phase B (2026-05-25): operator-curated extraction stopwords folded into the
|
|
218
|
+
# concept watermark (``concept_inputs_hash``) so a stopword-set change forces
|
|
219
|
+
# re-extraction beyond the one-time ``EXTRACTOR_VERSION`` bump. Default empty
|
|
220
|
+
# (real terms are employer-specific, rule 15). Threaded from
|
|
221
|
+
# ``Config.graph_extract_stopwords`` by the CLI/sync build path.
|
|
222
|
+
graph_extract_stopwords: frozenset[str] = frozenset()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True)
|
|
226
|
+
class ReconcileResult:
|
|
227
|
+
"""Outcome of a :func:`reconcile_document` / :func:`remove_document` call.
|
|
228
|
+
|
|
229
|
+
``skipped`` is ``True`` only when :func:`reconcile_document` short-circuited
|
|
230
|
+
on an unchanged ``graph_index_state`` watermark for **every** active aspect
|
|
231
|
+
(no writes performed). ``person_count`` / ``mention_count`` /
|
|
232
|
+
``contribution_count`` describe the **people** aspect; the ``concept_*``
|
|
233
|
+
fields describe the **concepts** aspect (wave G2-c; all zero when concepts
|
|
234
|
+
are disabled). ``orphans_removed`` and ``relationship_count`` reflect the
|
|
235
|
+
tenant-wide aggregate refresh across both aspects (the relationship mirror is
|
|
236
|
+
shared). For ``remove_document`` the per-document counts are zero.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
document_id: str
|
|
240
|
+
tenant_id: str
|
|
241
|
+
aspect: str = PEOPLE_ASPECT
|
|
242
|
+
skipped: bool = False
|
|
243
|
+
person_count: int = 0
|
|
244
|
+
mention_count: int = 0
|
|
245
|
+
contribution_count: int = 0
|
|
246
|
+
relationship_count: int = 0
|
|
247
|
+
orphans_removed: int = 0
|
|
248
|
+
concept_count: int = 0
|
|
249
|
+
concept_mention_count: int = 0
|
|
250
|
+
concept_contribution_count: int = 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class PersonResolver(Protocol):
|
|
254
|
+
"""Resolve one document to its person set (dependency-inversion seam).
|
|
255
|
+
|
|
256
|
+
The default implementation (:func:`default_person_resolver`) reuses the
|
|
257
|
+
People-Hub infrastructure; tests inject fakes to exercise the orchestration
|
|
258
|
+
in isolation.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
def __call__(
|
|
262
|
+
self,
|
|
263
|
+
conn: psycopg.Connection[Any],
|
|
264
|
+
document_id: str,
|
|
265
|
+
*,
|
|
266
|
+
owner_keys: frozenset[str],
|
|
267
|
+
sender_denylist: frozenset[str] = frozenset(),
|
|
268
|
+
) -> list[ResolvedPerson]:
|
|
269
|
+
...
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# The default :class:`PersonResolver` implementation
|
|
273
|
+
# (:func:`default_person_resolver`) and its :class:`ResolvedPerson` value object
|
|
274
|
+
# live in :mod:`brain.graph_rag.person_resolver` (extracted to keep this module
|
|
275
|
+
# under the 800-line cap). They are imported above and re-exported via
|
|
276
|
+
# ``__all__`` so existing
|
|
277
|
+
# ``from brain.graph_rag.reconcile import ResolvedPerson, default_person_resolver``
|
|
278
|
+
# imports keep working.
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# --------------------------------------------------------------------------- #
|
|
282
|
+
# Public entry points
|
|
283
|
+
# --------------------------------------------------------------------------- #
|
|
284
|
+
def reconcile_document(
|
|
285
|
+
conn: psycopg.Connection[Any],
|
|
286
|
+
document_id: str,
|
|
287
|
+
*,
|
|
288
|
+
backend: GraphBackend,
|
|
289
|
+
config: ReconcileConfig | None = None,
|
|
290
|
+
person_resolver: PersonResolver = default_person_resolver,
|
|
291
|
+
extractor: EntityExtractor | None = None,
|
|
292
|
+
force: bool = False,
|
|
293
|
+
defer_tenant_refresh: bool = False,
|
|
294
|
+
) -> ReconcileResult:
|
|
295
|
+
"""(Re)index one document's people (+ optionally concept) aspect (spec §7).
|
|
296
|
+
|
|
297
|
+
All work runs inside ``with conn.transaction()`` (opened as the first DB
|
|
298
|
+
action — see the module "Connection contract"), so the relational rewrites
|
|
299
|
+
and the AGE sync are one atomic unit. The **people** aspect always runs; the
|
|
300
|
+
**concepts** aspect (wave G2-c) runs additionally when
|
|
301
|
+
``config.concepts_enabled`` is True AND an ``extractor`` is injected (DI
|
|
302
|
+
seam, like ``person_resolver``). Each aspect has its OWN per-aspect
|
|
303
|
+
``graph_index_state`` watermark and skips independently (spec §7 step 1):
|
|
304
|
+
|
|
305
|
+
* **People** — ``content_hash`` + ``inputs_hash`` over the *resolved persons*
|
|
306
|
+
+ co-occurrence config, ``extractor_ver`` = :data:`PEOPLE_ASPECT_VERSION`.
|
|
307
|
+
* **Concepts** — ``content_hash`` + ``inputs_hash`` over only the
|
|
308
|
+
co-occurrence config (the extracted entities are captured by
|
|
309
|
+
``content_hash`` + ``extractor_ver``, so this skip-check runs BEFORE the
|
|
310
|
+
LLM call: an unchanged concept watermark short-circuits extraction),
|
|
311
|
+
``extractor_ver`` = ``extractor.version`` (``"<model>@concepts-v5"``).
|
|
312
|
+
|
|
313
|
+
Both share ``suppress_ver`` (the derive-time weighting/suppression version).
|
|
314
|
+
For each STALE aspect the pipeline upserts its ``graph_entities`` rows and
|
|
315
|
+
aspect-scopes its mentions + co-occurrence-contribution rewrite (people at
|
|
316
|
+
notional position 0 → complete graph; concepts at real raw-text word
|
|
317
|
+
positions → windowed proximity). Then ONCE: recompute the tenant's shared
|
|
318
|
+
aggregate ``graph_relationships`` (normalized lift + generic suppression), GC
|
|
319
|
+
each ran-aspect's now-orphaned catalog rows, and sync AGE via the G0-4
|
|
320
|
+
primitives — rebuilding the doc's COMBINED ``MENTIONED_IN`` (read back from
|
|
321
|
+
the relational source-of-truth so a fresh aspect's edges are never clobbered),
|
|
322
|
+
rematerializing ``CO_OCCURS``, and DETACH-DELETE-ing orphan vertices. Finally
|
|
323
|
+
each ran aspect's watermark is upserted. When EVERY active aspect's watermark
|
|
324
|
+
already matches (and not ``force``), nothing is written and ``skipped`` is
|
|
325
|
+
True (concepts never extracts on a skip).
|
|
326
|
+
|
|
327
|
+
``config`` defaults to :class:`ReconcileConfig` (single-tenant, person-only);
|
|
328
|
+
G1-c/G1-d/G2-c pass a resolved one. Idempotent: a second call with the same
|
|
329
|
+
content + persons + concept inputs + config skips; re-running after a
|
|
330
|
+
watermark change yields an identical graph (deterministic full recompute).
|
|
331
|
+
|
|
332
|
+
``force=True`` BYPASSES the per-aspect watermark skip and re-reconciles every
|
|
333
|
+
active aspect unconditionally — the authoritative recovery path for a dropped
|
|
334
|
+
or corrupted AGE mirror (spec §7). ``brain graphrag build --force`` threads
|
|
335
|
+
this through every document.
|
|
336
|
+
|
|
337
|
+
``defer_tenant_refresh=True`` (bulk build only) HOISTS the three tenant-wide
|
|
338
|
+
derived-layer steps — the ``graph_relationships`` recompute, the orphan GC,
|
|
339
|
+
and the AGE ``CO_OCCURS`` rematerialization (+ orphan-vertex DETACH DELETE) —
|
|
340
|
+
out of the per-document path so a corpus build pays them ONCE (after the loop,
|
|
341
|
+
via :func:`brain.graph_rag.build.build_graph` →
|
|
342
|
+
:func:`~brain.graph_rag.aggregates.refresh_aggregates`) instead of once per
|
|
343
|
+
document. The per-document relational rewrite and the doc's own AGE
|
|
344
|
+
entity/``MENTIONED_IN`` sync + watermark still run, so the relational
|
|
345
|
+
source-of-truth stays current and the build stays resumable; the deferred
|
|
346
|
+
end state is identical (the derived layers are a deterministic full recompute
|
|
347
|
+
from that source-of-truth). On a deferred call ``relationship_count`` and
|
|
348
|
+
``orphans_removed`` are 0 — the post-loop refresh reports the tenant totals.
|
|
349
|
+
Leave it ``False`` (the default) for the incremental ingest hook, which must
|
|
350
|
+
keep every single-document write fully consistent immediately.
|
|
351
|
+
|
|
352
|
+
Crash-window recovery: if a deferred bulk build is interrupted AFTER a
|
|
353
|
+
document's watermark is written but BEFORE ``build_graph``'s final
|
|
354
|
+
``refresh_aggregates`` runs, a same-command rerun all-skips
|
|
355
|
+
(``reconciled == 0``) and will NOT repair the derived layer
|
|
356
|
+
(``graph_relationships`` / AGE ``CO_OCCURS``) — run ``brain graphrag refresh``
|
|
357
|
+
(or ``brain graphrag build --force``) to repair. The relational
|
|
358
|
+
source-of-truth stays correct throughout.
|
|
359
|
+
|
|
360
|
+
Raises:
|
|
361
|
+
GraphReconcileError: the document does not exist;
|
|
362
|
+
``config.max_entities_per_doc`` is set below 1; or
|
|
363
|
+
``config.concepts_enabled`` is True but no ``extractor`` was injected.
|
|
364
|
+
"""
|
|
365
|
+
cfg = config if config is not None else ReconcileConfig()
|
|
366
|
+
if cfg.max_entities_per_doc is not None and cfg.max_entities_per_doc < 1:
|
|
367
|
+
raise GraphReconcileError(
|
|
368
|
+
"config.max_entities_per_doc must be a positive integer or None "
|
|
369
|
+
f"(got {cfg.max_entities_per_doc})"
|
|
370
|
+
)
|
|
371
|
+
concepts_active = cfg.concepts_enabled and extractor is not None
|
|
372
|
+
if cfg.concepts_enabled and extractor is None:
|
|
373
|
+
raise GraphReconcileError(
|
|
374
|
+
"config.concepts_enabled is True but no EntityExtractor was injected "
|
|
375
|
+
"(reconcile cannot index the concept aspect without one)"
|
|
376
|
+
)
|
|
377
|
+
tenant_id = cfg.tenant_id
|
|
378
|
+
|
|
379
|
+
# Open the transaction FIRST so no implicit transaction is pre-opened by a
|
|
380
|
+
# read (which would demote this block to a SAVEPOINT on an autocommit=False
|
|
381
|
+
# connection); see the module "Connection contract". The return commits on
|
|
382
|
+
# the way out of the ``with`` block.
|
|
383
|
+
with conn.transaction():
|
|
384
|
+
doc_meta = fetch_doc_meta(conn, document_id)
|
|
385
|
+
if doc_meta is None:
|
|
386
|
+
raise GraphReconcileError(
|
|
387
|
+
f"cannot reconcile document {document_id!r}: not found"
|
|
388
|
+
)
|
|
389
|
+
content_hash, content_type = doc_meta
|
|
390
|
+
sver = suppress_ver(cfg.generic_df_ratio)
|
|
391
|
+
|
|
392
|
+
# --- People aspect: resolve + watermark (always active). ---
|
|
393
|
+
persons = person_resolver(
|
|
394
|
+
conn,
|
|
395
|
+
document_id,
|
|
396
|
+
owner_keys=cfg.owner_keys,
|
|
397
|
+
sender_denylist=cfg.sender_denylist,
|
|
398
|
+
)
|
|
399
|
+
person_inputs_hash = _inputs_hash(
|
|
400
|
+
persons, cfg.cooccur_window, cfg.max_entities_per_doc
|
|
401
|
+
)
|
|
402
|
+
person_watermark = (
|
|
403
|
+
content_hash,
|
|
404
|
+
person_inputs_hash,
|
|
405
|
+
PEOPLE_ASPECT_VERSION,
|
|
406
|
+
sver,
|
|
407
|
+
)
|
|
408
|
+
person_stale = force or (
|
|
409
|
+
index_state(conn, tenant_id, document_id, PEOPLE_ASPECT)
|
|
410
|
+
!= person_watermark
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# --- Concept aspect: PRE-extraction watermark check (no LLM on skip). ---
|
|
414
|
+
concept_stale = False
|
|
415
|
+
concept_extractor_ver = ""
|
|
416
|
+
c_inputs_hash = ""
|
|
417
|
+
if concepts_active:
|
|
418
|
+
assert extractor is not None # narrowed by concepts_active
|
|
419
|
+
concept_extractor_ver = extractor.version
|
|
420
|
+
c_inputs_hash = concept_inputs_hash(
|
|
421
|
+
cfg.cooccur_window,
|
|
422
|
+
cfg.max_entities_per_doc,
|
|
423
|
+
stopwords=cfg.graph_extract_stopwords,
|
|
424
|
+
)
|
|
425
|
+
concept_watermark = (
|
|
426
|
+
content_hash,
|
|
427
|
+
c_inputs_hash,
|
|
428
|
+
concept_extractor_ver,
|
|
429
|
+
sver,
|
|
430
|
+
)
|
|
431
|
+
concept_stale = force or (
|
|
432
|
+
index_state(conn, tenant_id, document_id, CONCEPTS_ASPECT)
|
|
433
|
+
!= concept_watermark
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
if not person_stale and not concept_stale:
|
|
437
|
+
return ReconcileResult(
|
|
438
|
+
document_id=document_id,
|
|
439
|
+
tenant_id=tenant_id,
|
|
440
|
+
skipped=True,
|
|
441
|
+
person_count=len(persons),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# --- People relational rewrite (only when its watermark is stale). ---
|
|
445
|
+
person_entities: list[GraphEntity] = []
|
|
446
|
+
person_mentions: list[EntityMention] = []
|
|
447
|
+
person_contributions: list[EdgeContribution] = []
|
|
448
|
+
if person_stale:
|
|
449
|
+
capped = _apply_person_cap(persons, cfg.max_entities_per_doc)
|
|
450
|
+
person_entities = _upsert_person_entities(conn, tenant_id, capped)
|
|
451
|
+
person_mentions = [
|
|
452
|
+
EntityMention(
|
|
453
|
+
entity_id=entity.id,
|
|
454
|
+
document_id=document_id,
|
|
455
|
+
source=PEOPLE_MENTION_SOURCE,
|
|
456
|
+
tenant_id=tenant_id,
|
|
457
|
+
mention_count=1,
|
|
458
|
+
)
|
|
459
|
+
for entity in person_entities
|
|
460
|
+
]
|
|
461
|
+
# Doc-level co-occurrence: every participant occupies position 0, so
|
|
462
|
+
# the window predicate makes the complete graph over the doc's persons
|
|
463
|
+
# with count 1 (already capped above, so disable cooccur's own cap).
|
|
464
|
+
occurrences = [
|
|
465
|
+
EntityOccurrence(entity_id=entity.id, position=0)
|
|
466
|
+
for entity in person_entities
|
|
467
|
+
]
|
|
468
|
+
counts = cooccurrence_counts(
|
|
469
|
+
occurrences, window=cfg.cooccur_window, max_entities=None
|
|
470
|
+
)
|
|
471
|
+
person_contributions = to_contributions(
|
|
472
|
+
counts, document_id=document_id, tenant_id=tenant_id
|
|
473
|
+
)
|
|
474
|
+
rewrite_doc_relational(
|
|
475
|
+
conn,
|
|
476
|
+
tenant_id,
|
|
477
|
+
document_id,
|
|
478
|
+
person_mentions,
|
|
479
|
+
person_contributions,
|
|
480
|
+
entity_types=_PERSON_ENTITY_TYPES,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
# --- Concept relational rewrite (only when its watermark is stale). ---
|
|
484
|
+
concept_entities: list[GraphEntity] = []
|
|
485
|
+
concept_mentions: list[EntityMention] = []
|
|
486
|
+
concept_contributions: list[EdgeContribution] = []
|
|
487
|
+
if concept_stale:
|
|
488
|
+
assert extractor is not None # narrowed by concepts_active
|
|
489
|
+
content = fetch_doc_content(conn, document_id)
|
|
490
|
+
# OllamaExtractor.extract is never-raise: an Ollama outage yields []
|
|
491
|
+
# (logged WARN). The watermark is still written, marking the doc
|
|
492
|
+
# concept-indexed-empty; `brain graphrag build --concepts --force`
|
|
493
|
+
# re-extracts it once Ollama is back (spec §7 / §17b decision 7).
|
|
494
|
+
extracted = extractor.extract(content)
|
|
495
|
+
concept_entities = upsert_concept_entities(conn, tenant_id, extracted)
|
|
496
|
+
concept_mentions, concept_contributions = build_concept_rows(
|
|
497
|
+
extracted,
|
|
498
|
+
concept_entities,
|
|
499
|
+
document_id=document_id,
|
|
500
|
+
tenant_id=tenant_id,
|
|
501
|
+
window=cfg.cooccur_window,
|
|
502
|
+
source=concept_mention_source(concept_extractor_ver),
|
|
503
|
+
)
|
|
504
|
+
rewrite_doc_relational(
|
|
505
|
+
conn,
|
|
506
|
+
tenant_id,
|
|
507
|
+
document_id,
|
|
508
|
+
concept_mentions,
|
|
509
|
+
concept_contributions,
|
|
510
|
+
entity_types=CONCEPT_ENTITY_TYPES,
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
# --- Shared aggregate recompute + per-aspect orphan GC. ---
|
|
514
|
+
# When ``defer_tenant_refresh`` is set (bulk build), the tenant-wide
|
|
515
|
+
# aggregate recompute, the orphan GC, and (below) the AGE CO_OCCURS
|
|
516
|
+
# rematerialization are HOISTED out of the per-document loop and run ONCE
|
|
517
|
+
# after it — :func:`brain.graph_rag.build.build_graph` calls
|
|
518
|
+
# :func:`~brain.graph_rag.aggregates.refresh_aggregates` when any document
|
|
519
|
+
# did work. This turns a corpus build from O(docs × tenant_R) per-document
|
|
520
|
+
# CO_OCCURS rebuilds into O(docs × doc_entities + tenant_R) — a single
|
|
521
|
+
# final rebuild. The per-document relational rewrite and the doc's own AGE
|
|
522
|
+
# entity/MENTIONED_IN sync STILL run (so the relational source-of-truth and
|
|
523
|
+
# the doc's mention edges stay current); only the tenant-wide DERIVED layers
|
|
524
|
+
# are deferred, and because those derive purely from the per-document
|
|
525
|
+
# source-of-truth, the deferred end state is identical to the per-document
|
|
526
|
+
# path (a deterministic full recompute). ``relationship_count`` /
|
|
527
|
+
# ``orphans_removed`` are then 0 on this result — the final refresh reports
|
|
528
|
+
# the tenant-wide totals.
|
|
529
|
+
relationship_count = 0
|
|
530
|
+
orphan_ids: list[str] = []
|
|
531
|
+
if not defer_tenant_refresh:
|
|
532
|
+
relationship_count = _recompute_aggregates(
|
|
533
|
+
conn, tenant_id, cfg.generic_df_ratio
|
|
534
|
+
)
|
|
535
|
+
if person_stale:
|
|
536
|
+
orphan_ids += _gc_orphan_persons(conn, tenant_id)
|
|
537
|
+
if concept_stale:
|
|
538
|
+
orphan_ids += _gc_orphan_concepts(conn, tenant_id)
|
|
539
|
+
|
|
540
|
+
# --- AGE sync. Re-read the doc's COMBINED current mentions from the
|
|
541
|
+
# relational source-of-truth so a fresh aspect's MENTIONED_IN edges are
|
|
542
|
+
# rebuilt intact (upsert_mention_edges deletes+recreates ALL of a doc's
|
|
543
|
+
# MENTIONED_IN, so it must always carry the complete person+concept set).
|
|
544
|
+
# Only the ran aspects' entity properties are refreshed (the other
|
|
545
|
+
# aspect's vertices are MERGEd defensively by upsert_mention_edges).
|
|
546
|
+
entities_ran = [*person_entities, *concept_entities]
|
|
547
|
+
combined_mentions = read_doc_mentions(conn, tenant_id, document_id)
|
|
548
|
+
_sync_age_reconcile(
|
|
549
|
+
backend,
|
|
550
|
+
conn,
|
|
551
|
+
tenant_id,
|
|
552
|
+
document_id,
|
|
553
|
+
entities=entities_ran,
|
|
554
|
+
mentions=combined_mentions,
|
|
555
|
+
content_type=content_type,
|
|
556
|
+
orphan_ids=orphan_ids,
|
|
557
|
+
skip_cooccur=defer_tenant_refresh,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
# --- Per-aspect watermarks (only for the aspects that ran). ---
|
|
561
|
+
if person_stale:
|
|
562
|
+
upsert_index_state(
|
|
563
|
+
conn,
|
|
564
|
+
tenant_id,
|
|
565
|
+
document_id,
|
|
566
|
+
aspect=PEOPLE_ASPECT,
|
|
567
|
+
content_hash=content_hash,
|
|
568
|
+
inputs_hash=person_inputs_hash,
|
|
569
|
+
extractor_ver=PEOPLE_ASPECT_VERSION,
|
|
570
|
+
sver=sver,
|
|
571
|
+
)
|
|
572
|
+
if concept_stale:
|
|
573
|
+
upsert_index_state(
|
|
574
|
+
conn,
|
|
575
|
+
tenant_id,
|
|
576
|
+
document_id,
|
|
577
|
+
aspect=CONCEPTS_ASPECT,
|
|
578
|
+
content_hash=content_hash,
|
|
579
|
+
inputs_hash=c_inputs_hash,
|
|
580
|
+
extractor_ver=concept_extractor_ver,
|
|
581
|
+
sver=sver,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
return ReconcileResult(
|
|
585
|
+
document_id=document_id,
|
|
586
|
+
tenant_id=tenant_id,
|
|
587
|
+
skipped=False,
|
|
588
|
+
person_count=len(person_entities) if person_stale else len(persons),
|
|
589
|
+
mention_count=len(person_mentions),
|
|
590
|
+
contribution_count=len(person_contributions),
|
|
591
|
+
relationship_count=relationship_count,
|
|
592
|
+
orphans_removed=len(orphan_ids),
|
|
593
|
+
concept_count=len(concept_entities),
|
|
594
|
+
concept_mention_count=len(concept_mentions),
|
|
595
|
+
concept_contribution_count=len(concept_contributions),
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def remove_document(
|
|
600
|
+
conn: psycopg.Connection[Any],
|
|
601
|
+
document_id: str,
|
|
602
|
+
*,
|
|
603
|
+
backend: GraphBackend,
|
|
604
|
+
config: ReconcileConfig | None = None,
|
|
605
|
+
) -> ReconcileResult:
|
|
606
|
+
"""Remove one document from the graph — BOTH aspects (all delete paths; §7).
|
|
607
|
+
|
|
608
|
+
Aspect-agnostic: a document being removed loses ALL of its graph presence
|
|
609
|
+
(person AND concept), regardless of ``config.concepts_enabled`` — so no
|
|
610
|
+
``extractor`` is needed. Takes the SAME :class:`ReconcileConfig` as
|
|
611
|
+
:func:`reconcile_document` so the full-tenant aggregate is recomputed with the
|
|
612
|
+
identical ``generic_df_ratio`` used to build it (a mismatch would corrupt the
|
|
613
|
+
surviving weights). Runs entirely inside ``with conn.transaction()`` (see the
|
|
614
|
+
module "Connection contract").
|
|
615
|
+
|
|
616
|
+
Explicitly deletes the document's relational source rows (all mentions, all
|
|
617
|
+
contributions, BOTH aspects' watermarks) — robust whether the ``documents``
|
|
618
|
+
row still exists or was already deleted (its ``ON DELETE CASCADE`` would have
|
|
619
|
+
cleared these). Then recomputes the tenant aggregate, GCs now-orphaned person
|
|
620
|
+
AND concept entities, and syncs AGE (DETACH DELETE the ``Document`` vertex +
|
|
621
|
+
its ``MENTIONED_IN`` edges, rematerialize ``CO_OCCURS``, DETACH DELETE orphan
|
|
622
|
+
vertices). Idempotent: a second call is a stable no-op that converges to the
|
|
623
|
+
same graph.
|
|
624
|
+
"""
|
|
625
|
+
cfg = config if config is not None else ReconcileConfig()
|
|
626
|
+
tenant_id = cfg.tenant_id
|
|
627
|
+
with conn.transaction():
|
|
628
|
+
delete_doc_relational(conn, tenant_id, document_id)
|
|
629
|
+
relationship_count = _recompute_aggregates(
|
|
630
|
+
conn, tenant_id, cfg.generic_df_ratio
|
|
631
|
+
)
|
|
632
|
+
orphan_ids = [
|
|
633
|
+
*_gc_orphan_persons(conn, tenant_id),
|
|
634
|
+
*_gc_orphan_concepts(conn, tenant_id),
|
|
635
|
+
]
|
|
636
|
+
|
|
637
|
+
backend.detach_delete_documents(conn, tenant_id, [document_id])
|
|
638
|
+
backend.refresh_cooccur_edges(conn, tenant_id)
|
|
639
|
+
if orphan_ids:
|
|
640
|
+
backend.detach_delete_entities(conn, tenant_id, orphan_ids)
|
|
641
|
+
|
|
642
|
+
return ReconcileResult(
|
|
643
|
+
document_id=document_id,
|
|
644
|
+
tenant_id=tenant_id,
|
|
645
|
+
skipped=False,
|
|
646
|
+
relationship_count=relationship_count,
|
|
647
|
+
orphans_removed=len(orphan_ids),
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
# --------------------------------------------------------------------------- #
|
|
652
|
+
# AGE sync (orchestration over the G0-4 primitives — no raw Cypher here)
|
|
653
|
+
# --------------------------------------------------------------------------- #
|
|
654
|
+
def _sync_age_reconcile(
|
|
655
|
+
backend: GraphBackend,
|
|
656
|
+
conn: psycopg.Connection[Any],
|
|
657
|
+
tenant_id: str,
|
|
658
|
+
document_id: str,
|
|
659
|
+
*,
|
|
660
|
+
entities: list[GraphEntity],
|
|
661
|
+
mentions: list[EntityMention],
|
|
662
|
+
content_type: str,
|
|
663
|
+
orphan_ids: list[str],
|
|
664
|
+
skip_cooccur: bool = False,
|
|
665
|
+
) -> None:
|
|
666
|
+
"""Mirror the just-rewritten relational state for one doc into AGE.
|
|
667
|
+
|
|
668
|
+
``entities`` are the ran aspects' upserted entities (person and/or concept,
|
|
669
|
+
for property freshness); ``mentions`` is the doc's COMBINED current mention
|
|
670
|
+
set read back from the relational source-of-truth (both aspects), so the
|
|
671
|
+
single :meth:`GraphBackend.upsert_mention_edges` call — which deletes and
|
|
672
|
+
recreates ALL of a doc's ``MENTIONED_IN`` edges — always carries the complete
|
|
673
|
+
set and never clobbers a fresh aspect's edges. When the doc has at least one
|
|
674
|
+
mention: MERGE the ran-aspect vertices, then recreate the doc's
|
|
675
|
+
``MENTIONED_IN`` (which defensively MERGEs every mentioned vertex, so a fresh
|
|
676
|
+
aspect's vertices are ensured even when not re-upserted), tagging the
|
|
677
|
+
``Document`` vertex with ``content_type``. When an edit drops the doc to zero
|
|
678
|
+
mentions: DETACH DELETE its ``Document`` vertex so entity-less docs hold no
|
|
679
|
+
graph presence. Then rematerialize the tenant's ``CO_OCCURS`` edges from the
|
|
680
|
+
recomputed mirror and DETACH DELETE any orphaned vertices.
|
|
681
|
+
|
|
682
|
+
``skip_cooccur`` (bulk build) defers the two TENANT-WIDE steps — the
|
|
683
|
+
``CO_OCCURS`` rematerialization and the orphan-vertex DETACH DELETE — to a
|
|
684
|
+
single post-loop :func:`~brain.graph_rag.aggregates.refresh_aggregates`. The
|
|
685
|
+
per-document Document / Entity / ``MENTIONED_IN`` sync above ALWAYS runs, so
|
|
686
|
+
the doc's own mention edges are written incrementally; only the whole-tenant
|
|
687
|
+
derived edges are hoisted out of the loop (``orphan_ids`` is empty in this
|
|
688
|
+
mode, the GC having been deferred too).
|
|
689
|
+
"""
|
|
690
|
+
if mentions:
|
|
691
|
+
backend.upsert_entities(conn, tenant_id, entities)
|
|
692
|
+
backend.upsert_mention_edges(
|
|
693
|
+
conn,
|
|
694
|
+
tenant_id,
|
|
695
|
+
document_id,
|
|
696
|
+
mentions,
|
|
697
|
+
document_props={"content_type": content_type},
|
|
698
|
+
)
|
|
699
|
+
else:
|
|
700
|
+
backend.detach_delete_documents(conn, tenant_id, [document_id])
|
|
701
|
+
if not skip_cooccur:
|
|
702
|
+
backend.refresh_cooccur_edges(conn, tenant_id)
|
|
703
|
+
if orphan_ids:
|
|
704
|
+
backend.detach_delete_entities(conn, tenant_id, orphan_ids)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# --------------------------------------------------------------------------- #
|
|
708
|
+
# Person-aspect relational helpers (parameterized SQL only). The aspect-agnostic
|
|
709
|
+
# per-document read/write + watermark helpers live in
|
|
710
|
+
# :mod:`brain.graph_rag.relational` (shared by both aspects).
|
|
711
|
+
# --------------------------------------------------------------------------- #
|
|
712
|
+
def _upsert_person_entities(
|
|
713
|
+
conn: psycopg.Connection[Any],
|
|
714
|
+
tenant_id: str,
|
|
715
|
+
persons: list[ResolvedPerson],
|
|
716
|
+
) -> list[GraphEntity]:
|
|
717
|
+
"""Upsert person ``graph_entities`` rows, returning them with their ids.
|
|
718
|
+
|
|
719
|
+
Keyed on ``(tenant_id, entity_type='person', canonical_key)`` so re-running
|
|
720
|
+
reuses the existing row (and refreshes its humanized ``name``). The returned
|
|
721
|
+
:class:`GraphEntity` objects carry the durable catalog ``id`` (= the AGE
|
|
722
|
+
``entity_uuid``).
|
|
723
|
+
"""
|
|
724
|
+
entities: list[GraphEntity] = []
|
|
725
|
+
for person in persons:
|
|
726
|
+
row = conn.execute(
|
|
727
|
+
"""
|
|
728
|
+
INSERT INTO graph_entities (tenant_id, entity_type, name, canonical_key)
|
|
729
|
+
VALUES (%s, 'person', %s, %s)
|
|
730
|
+
ON CONFLICT (tenant_id, entity_type, canonical_key) DO UPDATE SET
|
|
731
|
+
name = EXCLUDED.name,
|
|
732
|
+
updated_at = NOW()
|
|
733
|
+
RETURNING id::text
|
|
734
|
+
""",
|
|
735
|
+
(tenant_id, person.display_name, person.canonical_key),
|
|
736
|
+
).fetchone()
|
|
737
|
+
# RETURNING on an INSERT ... ON CONFLICT DO UPDATE always yields one row.
|
|
738
|
+
assert row is not None
|
|
739
|
+
entities.append(
|
|
740
|
+
GraphEntity(
|
|
741
|
+
id=str(row[0]),
|
|
742
|
+
entity_type="person",
|
|
743
|
+
name=person.display_name,
|
|
744
|
+
canonical_key=person.canonical_key,
|
|
745
|
+
tenant_id=tenant_id,
|
|
746
|
+
)
|
|
747
|
+
)
|
|
748
|
+
return entities
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
# --------------------------------------------------------------------------- #
|
|
752
|
+
# Pure helpers
|
|
753
|
+
# --------------------------------------------------------------------------- #
|
|
754
|
+
def _apply_person_cap(
|
|
755
|
+
persons: list[ResolvedPerson], max_entities: int | None
|
|
756
|
+
) -> list[ResolvedPerson]:
|
|
757
|
+
"""Cap the per-document person set deterministically (spec §10).
|
|
758
|
+
|
|
759
|
+
Bounds ``MAX_ENTITIES_PER_DOC`` so an entity-dense document cannot blow up
|
|
760
|
+
the O(pairs) co-occurrence. Persons are kept by ``canonical_key`` ascending
|
|
761
|
+
(all carry equal per-doc weight), so both the mentions and the contributions
|
|
762
|
+
derive from the same capped set. ``None`` disables the cap.
|
|
763
|
+
"""
|
|
764
|
+
if max_entities is None or len(persons) <= max_entities:
|
|
765
|
+
return list(persons)
|
|
766
|
+
return sorted(persons, key=lambda person: person.canonical_key)[:max_entities]
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _inputs_hash(
|
|
770
|
+
persons: list[ResolvedPerson], window: int, max_entities: int | None
|
|
771
|
+
) -> str:
|
|
772
|
+
"""Stable fingerprint of the people-aspect inputs for the watermark.
|
|
773
|
+
|
|
774
|
+
Captures everything that determines this document's people-aspect output:
|
|
775
|
+
the resolved person *tuples* — ``(canonical_key, display_name)`` — plus the
|
|
776
|
+
co-occurrence config. Hashing the display name (not just the canonical key)
|
|
777
|
+
means a resolver that changes a person's presentation name (without changing
|
|
778
|
+
their canonical identity) still flips the hash and re-indexes, so the AGE
|
|
779
|
+
vertex / catalog ``name`` cannot go stale. A change in participants,
|
|
780
|
+
directory resolution, owner filtering, or config flips the hash and forces a
|
|
781
|
+
re-reconcile (spec §7 step 1). The suppression/weighting config is tracked
|
|
782
|
+
separately via ``suppress_ver``.
|
|
783
|
+
"""
|
|
784
|
+
payload = {
|
|
785
|
+
"persons": sorted(
|
|
786
|
+
[person.canonical_key, person.display_name] for person in persons
|
|
787
|
+
),
|
|
788
|
+
"window": window,
|
|
789
|
+
"max_entities": max_entities,
|
|
790
|
+
}
|
|
791
|
+
serialized = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
792
|
+
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|