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.
Files changed (273) hide show
  1. brain/__init__.py +0 -0
  2. brain/__main__.py +18 -0
  3. brain/_capture_command.py +445 -0
  4. brain/_compose.py +52 -0
  5. brain/activity.py +206 -0
  6. brain/ask.py +631 -0
  7. brain/audio.py +591 -0
  8. brain/backfill/__init__.py +12 -0
  9. brain/backfill/search_extras.py +141 -0
  10. brain/backfill/source_rows.py +101 -0
  11. brain/bin/__init__.py +1 -0
  12. brain/bin/_launcher.py +107 -0
  13. brain/bin/down.py +8 -0
  14. brain/bin/launchd.py +268 -0
  15. brain/bin/monitor.py +570 -0
  16. brain/bin/rebuild.py +8 -0
  17. brain/bin/status.py +8 -0
  18. brain/bin/up.py +8 -0
  19. brain/brief.py +272 -0
  20. brain/capture.py +49 -0
  21. brain/chat.py +293 -0
  22. brain/cli.py +9760 -0
  23. brain/cli_claude.py +81 -0
  24. brain/cli_connect.py +285 -0
  25. brain/cli_demo.py +266 -0
  26. brain/config.py +1949 -0
  27. brain/connect.py +925 -0
  28. brain/db.py +540 -0
  29. brain/demo/__init__.py +452 -0
  30. brain/demo/corpus/manifest.json +403 -0
  31. brain/demo/embedder.py +74 -0
  32. brain/durations.py +84 -0
  33. brain/edit_session.py +156 -0
  34. brain/editor.py +67 -0
  35. brain/elicit/__init__.py +16 -0
  36. brain/elicit/detectors.py +250 -0
  37. brain/elicit/drafter.py +70 -0
  38. brain/elicit/queue.py +220 -0
  39. brain/elicit/schema.py +48 -0
  40. brain/elicit/session.py +445 -0
  41. brain/embedding_targets.py +54 -0
  42. brain/embeddings.py +424 -0
  43. brain/enrichment.py +808 -0
  44. brain/errors.py +357 -0
  45. brain/eval/__init__.py +129 -0
  46. brain/eval/answer_eval.py +281 -0
  47. brain/eval/baseline.py +265 -0
  48. brain/eval/concept_extraction.py +378 -0
  49. brain/eval/corpus.py +152 -0
  50. brain/eval/errors.py +19 -0
  51. brain/eval/graph_baseline.py +226 -0
  52. brain/eval/graph_retrieval.py +202 -0
  53. brain/eval/graph_runner.py +319 -0
  54. brain/eval/metrics.py +101 -0
  55. brain/eval/runner.py +223 -0
  56. brain/format.py +783 -0
  57. brain/gaps.py +390 -0
  58. brain/graph_rag/__init__.py +94 -0
  59. brain/graph_rag/_retrieval_common.py +113 -0
  60. brain/graph_rag/aggregates.py +303 -0
  61. brain/graph_rag/aliases/__init__.py +583 -0
  62. brain/graph_rag/backends/__init__.py +10 -0
  63. brain/graph_rag/backends/_age_helpers.py +473 -0
  64. brain/graph_rag/backends/age.py +782 -0
  65. brain/graph_rag/backends/base.py +272 -0
  66. brain/graph_rag/build.py +344 -0
  67. brain/graph_rag/communities.py +644 -0
  68. brain/graph_rag/communities_summary.py +437 -0
  69. brain/graph_rag/concepts.py +202 -0
  70. brain/graph_rag/cooccur.py +193 -0
  71. brain/graph_rag/cross_type.py +312 -0
  72. brain/graph_rag/extract.py +885 -0
  73. brain/graph_rag/fuse.py +371 -0
  74. brain/graph_rag/global_.py +412 -0
  75. brain/graph_rag/grouping.py +372 -0
  76. brain/graph_rag/person_resolver.py +167 -0
  77. brain/graph_rag/reconcile.py +792 -0
  78. brain/graph_rag/relational.py +353 -0
  79. brain/graph_rag/retrieve.py +526 -0
  80. brain/graph_rag/router.py +288 -0
  81. brain/graph_rag/schema.py +320 -0
  82. brain/graph_rag/sync.py +237 -0
  83. brain/graph_rag/tenancy.py +43 -0
  84. brain/graph_rag/themes.py +501 -0
  85. brain/graph_rag/weighting.py +202 -0
  86. brain/ingest/__init__.py +1926 -0
  87. brain/ingest/chunker.py +249 -0
  88. brain/ingest/docx.py +40 -0
  89. brain/ingest/gmail.py +621 -0
  90. brain/ingest/markdown.py +37 -0
  91. brain/ingest/pdf.py +61 -0
  92. brain/ingest/stdin.py +22 -0
  93. brain/ingest/sub_tokens.py +91 -0
  94. brain/ingest/text.py +16 -0
  95. brain/interactions.py +205 -0
  96. brain/maintenance.py +355 -0
  97. brain/mcp_server.py +3405 -0
  98. brain/migrations/001_init.sql +43 -0
  99. brain/migrations/002_qwen3_embedding.sql +17 -0
  100. brain/migrations/003_vault_model.sql +41 -0
  101. brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
  102. brain/migrations/005_derived_links.sql +67 -0
  103. brain/migrations/006_dedup_file_by_source_path.sql +25 -0
  104. brain/migrations/007_email_thread_and_draft.sql +15 -0
  105. brain/migrations/008_gmail_thread_unique.sql +11 -0
  106. brain/migrations/009_chunks_weighted_tsv.sql +28 -0
  107. brain/migrations/010_interactions.sql +30 -0
  108. brain/migrations/011_documents_summary.sql +23 -0
  109. brain/migrations/012_graphrag.sql +171 -0
  110. brain/migrations/013_graphrag_communities.sql +125 -0
  111. brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
  112. brain/migrations/015_interactions_graph_targets.sql +89 -0
  113. brain/migrations/016_index_hygiene.sql +61 -0
  114. brain/migrations/017_elicit.sql +30 -0
  115. brain/migrations/018_review_gap_signal_kinds.sql +40 -0
  116. brain/migrations/019_search_queries.sql +35 -0
  117. brain/migrations/020_link_suggestions.sql +40 -0
  118. brain/migrations/021_timeline_doc_date.sql +34 -0
  119. brain/migrations/022_link_suggestions_undirected.sql +84 -0
  120. brain/migrations/023_search_queries_fts_count.sql +28 -0
  121. brain/quartz_overrides/__init__.py +8 -0
  122. brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
  123. brain/quartz_overrides/quartz/build.ts +568 -0
  124. brain/quartz_overrides/quartz/cli/args.js +152 -0
  125. brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
  126. brain/quartz_overrides/quartz/cli/handlers.js +636 -0
  127. brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
  128. brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
  129. brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
  130. brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
  131. brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
  132. brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
  133. brain/quartz_overrides/quartz/components/Search.tsx +161 -0
  134. brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
  135. brain/quartz_overrides/quartz/components/index.ts +92 -0
  136. brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
  137. brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
  138. brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
  139. brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
  140. brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
  141. brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
  142. brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
  143. brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
  144. brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
  145. brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
  146. brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
  147. brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
  148. brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
  149. brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
  150. brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
  151. brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
  152. brain/quartz_overrides/quartz/processors/parse.ts +371 -0
  153. brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
  154. brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
  155. brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
  156. brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
  157. brain/quartz_overrides/quartz/static/emailThread.js +334 -0
  158. brain/quartz_overrides/quartz/static/favicon.ico +0 -0
  159. brain/quartz_overrides/quartz/static/icon.png +0 -0
  160. brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
  161. brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
  162. brain/quartz_overrides/quartz/static/reload.js +168 -0
  163. brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
  164. brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
  165. brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
  166. brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
  167. brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
  168. brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
  169. brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
  170. brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
  171. brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
  172. brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
  173. brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
  174. brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
  175. brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
  176. brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
  177. brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
  178. brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
  179. brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
  180. brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
  181. brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
  182. brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
  183. brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
  184. brain/quartz_overrides/quartz/styles/custom.scss +89 -0
  185. brain/quartz_overrides/quartz/styles/graph.scss +505 -0
  186. brain/quartz_overrides/quartz/util/ctx.ts +92 -0
  187. brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
  188. brain/quartz_overrides/quartz/util/path.ts +358 -0
  189. brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
  190. brain/quartz_overrides/quartz.config.ts +270 -0
  191. brain/quartz_overrides/quartz.layout.ts +314 -0
  192. brain/queries.py +1188 -0
  193. brain/rank_fusion.py +8 -0
  194. brain/resurface.py +210 -0
  195. brain/review/__init__.py +26 -0
  196. brain/review/emit.py +27 -0
  197. brain/review/queries.py +436 -0
  198. brain/review/render.py +196 -0
  199. brain/review/scans.py +355 -0
  200. brain/review/weekly.py +413 -0
  201. brain/search.py +704 -0
  202. brain/set_similarity.py +15 -0
  203. brain/setup.py +1205 -0
  204. brain/tags.py +56 -0
  205. brain/templates/Caddyfile.j2 +9 -0
  206. brain/templates/__init__.py +1 -0
  207. brain/templates/bin/__init__.py +1 -0
  208. brain/templates/bin/_brain-brief-fg.sh +25 -0
  209. brain/templates/bin/_brain-build-fg.sh +53 -0
  210. brain/templates/bin/_brain-watcher-fg.sh +65 -0
  211. brain/templates/bin/brain-down.sh +89 -0
  212. brain/templates/bin/brain-status.sh +83 -0
  213. brain/templates/bin/brain-up.sh +221 -0
  214. brain/templates/docker/age/Dockerfile +79 -0
  215. brain/templates/docker-compose.stock.yml.j2 +26 -0
  216. brain/templates/docker-compose.yml.j2 +34 -0
  217. brain/templates/env.example +190 -0
  218. brain/templates/launchd/__init__.py +1 -0
  219. brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
  220. brain/templates/launchd/com.brain.build.plist.j2 +46 -0
  221. brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
  222. brain/templates/skill/SKILL.md +63 -0
  223. brain/templates/skill/__init__.py +1 -0
  224. brain/timeline.py +834 -0
  225. brain/todo.py +124 -0
  226. brain/uninstall.py +185 -0
  227. brain/vault/__init__.py +115 -0
  228. brain/vault/_atomic.py +25 -0
  229. brain/vault/daily_index.py +228 -0
  230. brain/vault/derived_links/__init__.py +50 -0
  231. brain/vault/derived_links/directory.py +683 -0
  232. brain/vault/derived_links/fence.py +408 -0
  233. brain/vault/derived_links/gws.py +64 -0
  234. brain/vault/derived_links/participants.py +143 -0
  235. brain/vault/derived_links/pass_runner.py +362 -0
  236. brain/vault/derived_links/rules.py +137 -0
  237. brain/vault/export.py +683 -0
  238. brain/vault/frontmatter.py +165 -0
  239. brain/vault/graph.py +620 -0
  240. brain/vault/graph_format.py +388 -0
  241. brain/vault/link_rewrite.py +235 -0
  242. brain/vault/links.py +260 -0
  243. brain/vault/note_builder.py +211 -0
  244. brain/vault/paths.py +55 -0
  245. brain/vault/quartz_overlay.py +236 -0
  246. brain/vault/rename.py +591 -0
  247. brain/vault/resolver.py +304 -0
  248. brain/vault/slug.py +127 -0
  249. brain/vault/sync.py +1513 -0
  250. brain/vault/sync_summaries.py +264 -0
  251. brain/vault/templates.py +145 -0
  252. brain/vault/watch.py +1052 -0
  253. brain/wiki/__init__.py +6 -0
  254. brain/wiki/_github_slugger.py +76 -0
  255. brain/wiki/_person_name.py +314 -0
  256. brain/wiki/build_homepage.py +541 -0
  257. brain/wiki/build_partial.py +273 -0
  258. brain/wiki/build_people.py +934 -0
  259. brain/wiki/build_related.py +758 -0
  260. brain/wiki/build_swap.py +585 -0
  261. brain/wiki/build_watcher.py +975 -0
  262. brain/wiki/edit_classifier.py +215 -0
  263. brain/wiki/errors.py +10 -0
  264. brain/wiki/fastpath_manifest.py +475 -0
  265. brain/wiki/fastpath_state.py +174 -0
  266. brain/wiki/install.py +296 -0
  267. brain/wiki/slug.py +111 -0
  268. secondbrain_py-0.2.1.dist-info/METADATA +195 -0
  269. secondbrain_py-0.2.1.dist-info/RECORD +273 -0
  270. secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
  271. secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
  272. secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
  273. 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()