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,193 @@
|
|
|
1
|
+
"""Raw-text sliding-window entity co-occurrence (wave G1, GraphRAG).
|
|
2
|
+
|
|
3
|
+
Pure logic, no DB: given one document's ordered entity *occurrences* (each an
|
|
4
|
+
``(entity_id, position)`` pair over the raw-text token stream) this module
|
|
5
|
+
computes the per-document, per-entity-pair raw co-occurrence counts that feed
|
|
6
|
+
``graph_edge_contributions`` (migration 012; spec §4 D4, §5a).
|
|
7
|
+
|
|
8
|
+
**Spec interpretation (documented choice).** The design (spec §4 D4, §13) fixes
|
|
9
|
+
that edges come from a *window co-occurrence over raw text* with the default
|
|
10
|
+
window ``BRAIN_GRAPH_COOCCUR_WINDOW = 3`` (spec §10), but does not pin the exact
|
|
11
|
+
window predicate or the unit of ``position``. This module makes the most
|
|
12
|
+
spec-faithful, testable choice and documents it:
|
|
13
|
+
|
|
14
|
+
* ``position`` is a caller-supplied integer offset into the raw-text token
|
|
15
|
+
stream (chunker-independent, per spec §4 D4). The pure function is unit-
|
|
16
|
+
agnostic — the caller (G1 ``reconcile.py``) owns tokenization and decides what
|
|
17
|
+
a position means; this module only compares positions.
|
|
18
|
+
* Two occurrences **co-occur** iff they belong to *distinct* entities and their
|
|
19
|
+
positions differ by at most ``window`` (an inclusive maximum distance, i.e. a
|
|
20
|
+
radius). ``window = 3`` ⇒ ``abs(pos_i - pos_j) <= 3``.
|
|
21
|
+
* Each unordered occurrence-pair is counted **at most once**, so overlapping
|
|
22
|
+
sliding windows never double-count a pair (spec §13 "overlap regression").
|
|
23
|
+
|
|
24
|
+
Endpoints are canonicalized ``src_id < dst_id`` to match the
|
|
25
|
+
``graph_edge_contributions`` ``CHECK (src_id < dst_id)``. UUIDs are compared as
|
|
26
|
+
strings; for the canonical lowercase UUID text form this lexicographic order
|
|
27
|
+
matches PostgreSQL's ``uuid`` byte ordering (hyphens sit at fixed positions and
|
|
28
|
+
do not affect relative order), so the canonical pair always satisfies the DB
|
|
29
|
+
constraint.
|
|
30
|
+
|
|
31
|
+
The module is a single-responsibility derive-time helper (SOLID): occurrences
|
|
32
|
+
in, raw counts out. Generic-entity suppression and lift weighting are *not* done
|
|
33
|
+
here — they are separate derive-time concerns owned by
|
|
34
|
+
:mod:`brain.graph_rag.weighting`.
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from collections import Counter
|
|
39
|
+
from collections.abc import Mapping, Sequence
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
|
|
42
|
+
from ..errors import CooccurrenceError
|
|
43
|
+
from .schema import EdgeContribution
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"DEFAULT_COOCCUR_WINDOW",
|
|
47
|
+
"DEFAULT_MAX_ENTITIES_PER_DOC",
|
|
48
|
+
"EntityOccurrence",
|
|
49
|
+
"cooccurrence_counts",
|
|
50
|
+
"to_contributions",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
# Default sliding-window radius: two occurrences co-occur when their positions
|
|
54
|
+
# differ by at most this many units. Mirrors ``BRAIN_GRAPH_COOCCUR_WINDOW``
|
|
55
|
+
# (spec §10, default 3); reconcile.py passes the configured value.
|
|
56
|
+
DEFAULT_COOCCUR_WINDOW = 3
|
|
57
|
+
|
|
58
|
+
# Default cap on the number of DISTINCT entities considered per document
|
|
59
|
+
# (spec §8 "cooccur.py — ... + caps", §10 ``BRAIN_GRAPH_MAX_ENTITIES_PER_DOC``).
|
|
60
|
+
# Bounds the O(pairs) blow-up on entity-dense documents. When more distinct
|
|
61
|
+
# entities are present, the most-mentioned ones are kept (ties broken by
|
|
62
|
+
# ``entity_id`` ascending for determinism); pass ``None`` to disable.
|
|
63
|
+
DEFAULT_MAX_ENTITIES_PER_DOC = 40
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class EntityOccurrence:
|
|
68
|
+
"""One entity mention at a raw-text position (input to co-occurrence).
|
|
69
|
+
|
|
70
|
+
``entity_id`` is the durable entity UUID (``graph_entities.id``).
|
|
71
|
+
``position`` is a caller-defined integer offset into the raw-text token
|
|
72
|
+
stream — only relative distances matter here, so the unit is the caller's
|
|
73
|
+
choice (spec §4 D4). The same ``entity_id`` may appear at many positions
|
|
74
|
+
(a repeated mention); same-entity pairs never produce an edge (no self
|
|
75
|
+
loops — the DB ``CHECK (src_id < dst_id)`` forbids them).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
entity_id: str
|
|
79
|
+
position: int
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _canonical_pair(a: str, b: str) -> tuple[str, str]:
|
|
83
|
+
"""Return ``(src, dst)`` ordered ``src < dst`` (matches the DB CHECK).
|
|
84
|
+
|
|
85
|
+
Caller guarantees ``a != b`` (same-entity pairs are skipped upstream).
|
|
86
|
+
"""
|
|
87
|
+
return (a, b) if a < b else (b, a)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _apply_entity_cap(
|
|
91
|
+
occurrences: Sequence[EntityOccurrence], max_entities: int
|
|
92
|
+
) -> list[EntityOccurrence]:
|
|
93
|
+
"""Keep occurrences of the top ``max_entities`` most-mentioned entities.
|
|
94
|
+
|
|
95
|
+
Entities are ranked by total occurrence count (descending), ties broken by
|
|
96
|
+
``entity_id`` ascending so the selection is deterministic. Occurrences of
|
|
97
|
+
dropped entities are removed; the relative order of the survivors is
|
|
98
|
+
preserved. Returns the input list unchanged when it already references at
|
|
99
|
+
most ``max_entities`` distinct entities.
|
|
100
|
+
"""
|
|
101
|
+
freq = Counter(occ.entity_id for occ in occurrences)
|
|
102
|
+
if len(freq) <= max_entities:
|
|
103
|
+
return list(occurrences)
|
|
104
|
+
ranked = sorted(freq.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
105
|
+
kept = {entity_id for entity_id, _count in ranked[:max_entities]}
|
|
106
|
+
return [occ for occ in occurrences if occ.entity_id in kept]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cooccurrence_counts(
|
|
110
|
+
occurrences: Sequence[EntityOccurrence],
|
|
111
|
+
*,
|
|
112
|
+
window: int = DEFAULT_COOCCUR_WINDOW,
|
|
113
|
+
max_entities: int | None = DEFAULT_MAX_ENTITIES_PER_DOC,
|
|
114
|
+
) -> dict[tuple[str, str], int]:
|
|
115
|
+
"""Compute raw within-window co-occurrence counts for one document.
|
|
116
|
+
|
|
117
|
+
Two occurrences co-occur when they belong to distinct entities and their
|
|
118
|
+
positions differ by at most ``window``. Each unordered occurrence-pair is
|
|
119
|
+
counted once (overlapping windows do not double-count). The result maps each
|
|
120
|
+
canonical ``(src_id, dst_id)`` pair (``src_id < dst_id``) to its raw count.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
occurrences: One document's entity occurrences (any order). Multiple
|
|
124
|
+
occurrences of the same ``entity_id`` are allowed and only ever
|
|
125
|
+
pair with *other* entities.
|
|
126
|
+
window: Inclusive maximum positional distance for a co-occurrence
|
|
127
|
+
(default :data:`DEFAULT_COOCCUR_WINDOW`). Must be ``>= 1``.
|
|
128
|
+
max_entities: Cap on distinct entities considered (default
|
|
129
|
+
:data:`DEFAULT_MAX_ENTITIES_PER_DOC`); ``None`` disables the cap.
|
|
130
|
+
Must be ``>= 1`` when set.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
A fresh ``dict`` from canonical entity pair to raw co-occurrence count;
|
|
134
|
+
empty when no pair falls within the window.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
CooccurrenceError: if ``window < 1`` or ``max_entities`` is set ``< 1``.
|
|
138
|
+
"""
|
|
139
|
+
if window < 1:
|
|
140
|
+
raise CooccurrenceError(
|
|
141
|
+
f"co-occurrence window must be a positive integer (got {window})"
|
|
142
|
+
)
|
|
143
|
+
if max_entities is not None and max_entities < 1:
|
|
144
|
+
raise CooccurrenceError(
|
|
145
|
+
f"max_entities must be a positive integer or None (got {max_entities})"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
considered = (
|
|
149
|
+
occurrences if max_entities is None else _apply_entity_cap(occurrences, max_entities)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Sort by position so a forward sweep can stop early once the gap to a later
|
|
153
|
+
# occurrence exceeds the window (the remaining occurrences are even farther).
|
|
154
|
+
ordered = sorted(considered, key=lambda occ: occ.position)
|
|
155
|
+
counts: dict[tuple[str, str], int] = {}
|
|
156
|
+
total = len(ordered)
|
|
157
|
+
for i in range(total):
|
|
158
|
+
left = ordered[i]
|
|
159
|
+
for j in range(i + 1, total):
|
|
160
|
+
right = ordered[j]
|
|
161
|
+
if right.position - left.position > window:
|
|
162
|
+
break
|
|
163
|
+
if left.entity_id == right.entity_id:
|
|
164
|
+
continue # no self loops
|
|
165
|
+
pair = _canonical_pair(left.entity_id, right.entity_id)
|
|
166
|
+
counts[pair] = counts.get(pair, 0) + 1
|
|
167
|
+
return counts
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def to_contributions(
|
|
171
|
+
counts: Mapping[tuple[str, str], int],
|
|
172
|
+
*,
|
|
173
|
+
document_id: str,
|
|
174
|
+
tenant_id: str = "default",
|
|
175
|
+
) -> list[EdgeContribution]:
|
|
176
|
+
"""Wrap raw pair counts as :class:`EdgeContribution` rows for one document.
|
|
177
|
+
|
|
178
|
+
The thin adapter between the unit-agnostic core (:func:`cooccurrence_counts`)
|
|
179
|
+
and the ``graph_edge_contributions`` source-of-truth shape (spec §5a). Rows
|
|
180
|
+
are emitted in canonical ``(src_id, dst_id)`` order for deterministic
|
|
181
|
+
persistence; ``tenant_id`` defaults to the single-user ``"default"`` tenant
|
|
182
|
+
(spec §4 D9).
|
|
183
|
+
"""
|
|
184
|
+
return [
|
|
185
|
+
EdgeContribution(
|
|
186
|
+
document_id=document_id,
|
|
187
|
+
src_id=src_id,
|
|
188
|
+
dst_id=dst_id,
|
|
189
|
+
tenant_id=tenant_id,
|
|
190
|
+
cooccur_count=count,
|
|
191
|
+
)
|
|
192
|
+
for (src_id, dst_id), count in sorted(counts.items())
|
|
193
|
+
]
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Automatic cross-document concept type-collapse (Bug A — no schema migration).
|
|
2
|
+
|
|
3
|
+
The catalog uniqueness key is type-scoped — ``UNIQUE (tenant_id, entity_type,
|
|
4
|
+
canonical_key)`` (migration 012) — so one ``canonical_key`` may exist once *per
|
|
5
|
+
type*. The LLM concept extractor assigns the same real concept a different
|
|
6
|
+
``entity_type`` across documents (``acmeplatform`` as ``org`` in one doc,
|
|
7
|
+
``project`` in another), and each satisfies the type-scoped key, landing as a
|
|
8
|
+
SEPARATE row. :func:`brain.graph_rag.extract._dedupe_cross_type` already collapses
|
|
9
|
+
this **within a single document** using :data:`~brain.graph_rag.extract._TYPE_PRECEDENCE`;
|
|
10
|
+
this module is its **cross-document** counterpart.
|
|
11
|
+
|
|
12
|
+
**Path 1 (no migration).** Rather than widening the schema, this reuses the
|
|
13
|
+
shipped, tested repoint + AGE-upsert + ``refresh_aggregates`` + orphan-GC wrapper
|
|
14
|
+
:func:`brain.graph_rag.aliases.merge_aliases`. The collapse:
|
|
15
|
+
|
|
16
|
+
1. scans the tenant's concept catalog for every ``canonical_key`` carried by more
|
|
17
|
+
than one CONCEPT ``entity_type`` (:func:`generate_cross_type_collapse_rules`),
|
|
18
|
+
2. generates :class:`~brain.graph_rag.aliases.AliasRule` rows merging each
|
|
19
|
+
lower-precedence type into the highest-precedence type present (same precedence
|
|
20
|
+
as the per-document dedupe), then
|
|
21
|
+
3. validates them (chain/cycle/dup-source — :func:`brain.graph_rag.aliases
|
|
22
|
+
._validate_alias_graph`) and applies them via ``merge_aliases``.
|
|
23
|
+
|
|
24
|
+
**Scope — concept types ONLY.** The scan is restricted to the four concept types
|
|
25
|
+
(``org``/``project``/``tool``/``topic``); ``person`` rows are never selected, so a
|
|
26
|
+
person can never be a merge source, and the precedence winner is always a concept
|
|
27
|
+
type, so a person can never be a merge target. A person and a concept that happen
|
|
28
|
+
to share a ``canonical_key`` are left untouched (D1b — persons are a separate
|
|
29
|
+
aspect, derived from the participants pipeline, not from this catalog).
|
|
30
|
+
|
|
31
|
+
**Hook placement (correctness).** This MUST run as a corpus-level pass AFTER a
|
|
32
|
+
document's mentions/contributions are written + committed — never mid-reconcile
|
|
33
|
+
(a mid-flow GC of a zero-mention source would orphan an id the pending
|
|
34
|
+
mention-insert then references). It is invoked from
|
|
35
|
+
:meth:`brain.graph_rag.sync.GraphSyncer.reconcile` (the incremental ingest path,
|
|
36
|
+
after ``reconcile_document`` commits) and from the ``brain graphrag build`` /
|
|
37
|
+
``refresh`` CLI (after the per-document loop / the curated alias merge).
|
|
38
|
+
|
|
39
|
+
**Idempotent.** A second run finds no ``canonical_key`` under more than one type
|
|
40
|
+
(the first run collapsed them) → empty rules → ``merge_aliases`` short-circuits to
|
|
41
|
+
a zero-valued no-op. Communities go stale after any merge (entity GC cascades to
|
|
42
|
+
``graph_community_members``); the rollout runs ``brain graphrag communities
|
|
43
|
+
refresh`` after the collapse.
|
|
44
|
+
"""
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from typing import TYPE_CHECKING, Any
|
|
48
|
+
|
|
49
|
+
import psycopg
|
|
50
|
+
|
|
51
|
+
from .aliases import AliasResult, AliasRule, _validate_alias_graph, merge_aliases
|
|
52
|
+
from .schema import GraphEntity
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from .backends.base import GraphBackend
|
|
56
|
+
from .reconcile import ReconcileConfig
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"collapse_cross_type_concepts",
|
|
60
|
+
"generate_cross_type_collapse_rules",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _concept_precedence() -> tuple[tuple[str, ...], list[str]]:
|
|
65
|
+
"""Return ``(ordered_concept_types, concept_types)`` for the collapse.
|
|
66
|
+
|
|
67
|
+
``ordered_concept_types`` is :data:`brain.graph_rag.extract._TYPE_PRECEDENCE`
|
|
68
|
+
filtered to the concept types (``person`` removed), highest-precedence first
|
|
69
|
+
— ``("org", "project", "tool", "topic")``. ``concept_types`` is the same set
|
|
70
|
+
as a list for parameterized ``= ANY(%s)`` scoping. Late import keeps this
|
|
71
|
+
module import-cheap (``extract`` pulls in the enrichment transport) and
|
|
72
|
+
mirrors the late-import discipline used across :mod:`brain.graph_rag`.
|
|
73
|
+
"""
|
|
74
|
+
from .extract import _TYPE_PRECEDENCE, CONCEPT_ENTITY_TYPES
|
|
75
|
+
|
|
76
|
+
ordered = tuple(t for t in _TYPE_PRECEDENCE if t in CONCEPT_ENTITY_TYPES)
|
|
77
|
+
return ordered, list(CONCEPT_ENTITY_TYPES)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def generate_cross_type_collapse_rules(
|
|
81
|
+
conn: psycopg.Connection[Any], tenant_id: str
|
|
82
|
+
) -> list[AliasRule]:
|
|
83
|
+
"""Build merge rules for every cross-type concept fragment in ``tenant_id``.
|
|
84
|
+
|
|
85
|
+
Scans ``graph_entities`` for each ``canonical_key`` carried by more than one
|
|
86
|
+
CONCEPT ``entity_type`` (``person`` rows are excluded by the scan), and emits
|
|
87
|
+
one :class:`~brain.graph_rag.aliases.AliasRule` per lower-precedence type
|
|
88
|
+
collapsing it into the highest-precedence type present for that key (per
|
|
89
|
+
:data:`brain.graph_rag.extract._TYPE_PRECEDENCE`). Both endpoints carry the
|
|
90
|
+
SAME ``canonical_key`` — only the type changes — so the rule re-points the
|
|
91
|
+
fragment onto the winning-type row.
|
|
92
|
+
|
|
93
|
+
Returns a deterministically ordered list (sorted by ``(from_type, from_key)``)
|
|
94
|
+
so repeated runs and tests see a stable rule order. A key under a single type
|
|
95
|
+
yields no rule; distinct keys are independent (no rule ever spans two keys, so
|
|
96
|
+
the generated graph is always chain/cycle/dup-source free by construction —
|
|
97
|
+
:func:`collapse_cross_type_concepts` still validates it defensively). SQL is
|
|
98
|
+
parameterized throughout.
|
|
99
|
+
"""
|
|
100
|
+
ordered, concept_types = _concept_precedence()
|
|
101
|
+
rank = {entity_type: i for i, entity_type in enumerate(ordered)}
|
|
102
|
+
rows = conn.execute(
|
|
103
|
+
"""
|
|
104
|
+
SELECT canonical_key, array_agg(DISTINCT entity_type) AS types
|
|
105
|
+
FROM graph_entities
|
|
106
|
+
WHERE tenant_id = %s AND entity_type = ANY(%s)
|
|
107
|
+
GROUP BY canonical_key
|
|
108
|
+
HAVING COUNT(DISTINCT entity_type) > 1
|
|
109
|
+
""",
|
|
110
|
+
(tenant_id, concept_types),
|
|
111
|
+
).fetchall()
|
|
112
|
+
|
|
113
|
+
rules: list[AliasRule] = []
|
|
114
|
+
for row in rows:
|
|
115
|
+
canonical_key = str(row[0])
|
|
116
|
+
present = [t for t in (str(x) for x in row[1]) if t in rank]
|
|
117
|
+
if len(present) < 2:
|
|
118
|
+
# Defensive: every selected type should be a known concept type, but
|
|
119
|
+
# if an unranked type slipped in there is nothing to collapse.
|
|
120
|
+
continue
|
|
121
|
+
winner = min(present, key=lambda entity_type: rank[entity_type])
|
|
122
|
+
for entity_type in present:
|
|
123
|
+
if entity_type == winner:
|
|
124
|
+
continue
|
|
125
|
+
rules.append(
|
|
126
|
+
AliasRule(
|
|
127
|
+
from_type=entity_type,
|
|
128
|
+
from_key=canonical_key,
|
|
129
|
+
to_type=winner,
|
|
130
|
+
to_key=canonical_key,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
rules.sort(key=lambda rule: (rule.from_type, rule.from_key))
|
|
134
|
+
return rules
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def collapse_cross_type_concepts(
|
|
138
|
+
conn: psycopg.Connection[Any],
|
|
139
|
+
tenant_id: str,
|
|
140
|
+
backend: GraphBackend,
|
|
141
|
+
*,
|
|
142
|
+
config: ReconcileConfig | None = None,
|
|
143
|
+
dry_run: bool = False,
|
|
144
|
+
) -> AliasResult:
|
|
145
|
+
"""Collapse cross-document concept type-fragmentation for one tenant (Bug A).
|
|
146
|
+
|
|
147
|
+
Generates the auto-merge rules (:func:`generate_cross_type_collapse_rules`),
|
|
148
|
+
validates them (:func:`brain.graph_rag.aliases._validate_alias_graph` —
|
|
149
|
+
``merge_aliases`` does not re-validate a directly-passed rules list), and
|
|
150
|
+
applies them atomically via :func:`brain.graph_rag.aliases.merge_aliases`
|
|
151
|
+
(re-point mentions + contributions, provision any new target AGE vertices,
|
|
152
|
+
``refresh_aggregates`` → GC the now-zero-mention source rows + DETACH DELETE
|
|
153
|
+
their AGE vertices + rebuild ``CO_OCCURS``). Concept types only — ``person``
|
|
154
|
+
is never a source or target.
|
|
155
|
+
|
|
156
|
+
Returns the :class:`~brain.graph_rag.aliases.AliasResult`; an empty rule set
|
|
157
|
+
(nothing to collapse) returns a zero-valued result without opening a
|
|
158
|
+
transaction, so callers can invoke this unconditionally. ``dry_run=True``
|
|
159
|
+
reports the would-be merges without persisting. ``config`` is forwarded to
|
|
160
|
+
``merge_aliases`` so the post-merge edge recompute uses the caller's
|
|
161
|
+
``generic_df_ratio`` (its ``tenant_id`` MUST match ``tenant_id``).
|
|
162
|
+
|
|
163
|
+
**Display-name preservation.** ``merge_aliases``'s find-or-create
|
|
164
|
+
(:func:`brain.graph_rag.aliases._upsert_entity`) rewrites the target row's
|
|
165
|
+
``name`` to ``humanize_person_name(canonical_key)`` — fine for a curated alias
|
|
166
|
+
target the operator named by key, but for an auto-collapse the target is an
|
|
167
|
+
EXISTING concept row carrying the extractor's proper surface form (e.g.
|
|
168
|
+
``"AcmePlatform"``), which the humanized strip-all key would clobber to
|
|
169
|
+
``"Acmeplatform"`` (camelCase lost). To avoid changing shared
|
|
170
|
+
``merge_aliases`` behavior (which curated aliases + their tests rely on), this
|
|
171
|
+
captures the BEST surface form across ALL merged variants BEFORE the merge
|
|
172
|
+
(:func:`_best_display_names` / :func:`_best_surface_form` — F1: a branded
|
|
173
|
+
mixed-case form like ``"AcmePlatform"`` beats an all-lowercase ``"acmeplatform"``
|
|
174
|
+
even when the lowercase variant wins TYPE precedence) and restores it —
|
|
175
|
+
relationally AND on the AGE vertex — AFTER, so the merged concept keeps the
|
|
176
|
+
best real display name rather than the winning type's possibly-lowercase one.
|
|
177
|
+
"""
|
|
178
|
+
rules = generate_cross_type_collapse_rules(conn, tenant_id)
|
|
179
|
+
if not rules:
|
|
180
|
+
return AliasResult(tenant_id=tenant_id, rules_total=0, dry_run=dry_run)
|
|
181
|
+
_validate_alias_graph(rules) # chain/cycle/dup-source (defensive; F7)
|
|
182
|
+
# Snapshot the BEST surface form across all merged variants before
|
|
183
|
+
# merge_aliases overwrites the winner's name with the humanized key (F1).
|
|
184
|
+
best_names = _best_display_names(conn, tenant_id, rules)
|
|
185
|
+
result = merge_aliases(
|
|
186
|
+
conn, tenant_id, rules, backend, dry_run=dry_run, config=config
|
|
187
|
+
)
|
|
188
|
+
if not dry_run and result.rules_applied > 0:
|
|
189
|
+
_restore_winner_display_names(conn, tenant_id, backend, best_names)
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _uppercase_count(name: str) -> int:
|
|
194
|
+
"""Count uppercase letters in ``name`` — a proxy for proper branding/casing."""
|
|
195
|
+
return sum(1 for char in name if char.isupper())
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _is_all_caps(name: str) -> bool:
|
|
199
|
+
"""True when ``name`` has letters but NO lowercase — an all-caps "shout".
|
|
200
|
+
|
|
201
|
+
Distinguishes ``"CONTOSO"`` / ``"SDKS"`` (shouting) from properly-cased
|
|
202
|
+
``"Contoso"`` / ``"Sdks"``. A genuine all-caps acronym (``"API"``) is still
|
|
203
|
+
kept when it is the only / best variant — the guard only DEMOTES an all-caps
|
|
204
|
+
form when a mixed-case sibling exists.
|
|
205
|
+
"""
|
|
206
|
+
return any(char.isalpha() for char in name) and not any(
|
|
207
|
+
char.islower() for char in name
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _best_surface_form(variants: list[tuple[str, int]]) -> str:
|
|
212
|
+
"""Pick the best display name among merged variants (F1, deterministic).
|
|
213
|
+
|
|
214
|
+
``variants`` is ``[(name, doc_count), …]`` — the surface forms of every
|
|
215
|
+
concept row sharing one ``canonical_key`` that the collapse merges. The
|
|
216
|
+
heuristic is a total order (never a tie), in priority:
|
|
217
|
+
|
|
218
|
+
1. prefer a mixed/cased form over an ALL-CAPS "shout" (``"Contoso"`` over
|
|
219
|
+
``"CONTOSO"``, ``"Sdks"`` over ``"SDKS"``) — a lone all-caps acronym is
|
|
220
|
+
still kept when no mixed-case sibling exists;
|
|
221
|
+
2. more uppercase letters (proper branding) — a mixed/branded form beats an
|
|
222
|
+
all-lowercase one (``AcmePlatform`` over ``acmeplatform``) and a
|
|
223
|
+
better-cased branded form beats a worse one (``AI::Client`` over
|
|
224
|
+
``Ai::Client``);
|
|
225
|
+
3. higher ``doc_count`` (the most-attested form);
|
|
226
|
+
4. longer name;
|
|
227
|
+
5. lexicographically smallest (final deterministic tiebreak).
|
|
228
|
+
|
|
229
|
+
Known trade-off: a one-off variant with spurious extra capitals (a typo like
|
|
230
|
+
``"ALBa"``) can outrank a more-attested correctly-cased form, because casing
|
|
231
|
+
signal is ranked above ``doc_count``. Acceptable for a personal corpus, and
|
|
232
|
+
curated aliases can override any specific case. Returns ``""`` only for empty
|
|
233
|
+
input (caller guards).
|
|
234
|
+
"""
|
|
235
|
+
if not variants:
|
|
236
|
+
return ""
|
|
237
|
+
return min(
|
|
238
|
+
variants,
|
|
239
|
+
key=lambda v: (_is_all_caps(v[0]), -_uppercase_count(v[0]), -v[1], -len(v[0]), v[0]),
|
|
240
|
+
)[0]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _best_display_names(
|
|
244
|
+
conn: psycopg.Connection[Any], tenant_id: str, rules: list[AliasRule]
|
|
245
|
+
) -> dict[tuple[str, str], str]:
|
|
246
|
+
"""Capture the BEST surface form to assign each merge winner (F1).
|
|
247
|
+
|
|
248
|
+
For every distinct rule TARGET ``(to_type, to_key)`` — the concept row the
|
|
249
|
+
lower-precedence fragments collapse into — collect the ``(name, doc_count)``
|
|
250
|
+
of EVERY concept row sharing that ``canonical_key`` (the winner plus all the
|
|
251
|
+
soon-to-be-GC'd source variants) and pick the best via
|
|
252
|
+
:func:`_best_surface_form`. Returns ``{(to_type, to_key): best_name}``. Read
|
|
253
|
+
BEFORE ``merge_aliases`` because the source variants are deleted by the
|
|
254
|
+
merge's orphan GC, so their surface forms must be captured first. Restricted
|
|
255
|
+
to concept types (``person`` is never part of a cross-type collapse).
|
|
256
|
+
Parameterized SQL.
|
|
257
|
+
"""
|
|
258
|
+
names: dict[tuple[str, str], str] = {}
|
|
259
|
+
_, concept_types = _concept_precedence()
|
|
260
|
+
for to_type, to_key in sorted({(r.to_type, r.to_key) for r in rules}):
|
|
261
|
+
rows = conn.execute(
|
|
262
|
+
"SELECT name, doc_count FROM graph_entities "
|
|
263
|
+
"WHERE tenant_id = %s AND canonical_key = %s AND entity_type = ANY(%s)",
|
|
264
|
+
(tenant_id, to_key, concept_types),
|
|
265
|
+
).fetchall()
|
|
266
|
+
variants = [(str(r[0]), int(r[1])) for r in rows]
|
|
267
|
+
best = _best_surface_form(variants)
|
|
268
|
+
if best:
|
|
269
|
+
names[(to_type, to_key)] = best
|
|
270
|
+
return names
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _restore_winner_display_names(
|
|
274
|
+
conn: psycopg.Connection[Any],
|
|
275
|
+
tenant_id: str,
|
|
276
|
+
backend: GraphBackend,
|
|
277
|
+
winner_names: dict[tuple[str, str], str],
|
|
278
|
+
) -> None:
|
|
279
|
+
"""Restore the captured target ``name``s relationally + on the AGE vertex.
|
|
280
|
+
|
|
281
|
+
Undoes ``merge_aliases``'s humanized-key overwrite of each winning concept
|
|
282
|
+
row's ``name`` (see :func:`collapse_cross_type_concepts`). Runs in one
|
|
283
|
+
transaction: UPDATE the relational ``name`` back to its captured surface form,
|
|
284
|
+
then re-MERGE the corrected vertices via
|
|
285
|
+
:meth:`~brain.graph_rag.backends.base.GraphBackend.upsert_entities` so the AGE
|
|
286
|
+
label matches too. A winner that no longer exists is skipped (it cannot be
|
|
287
|
+
GC'd — it carries the merged mentions). Parameterized SQL.
|
|
288
|
+
"""
|
|
289
|
+
if not winner_names:
|
|
290
|
+
return
|
|
291
|
+
with conn.transaction():
|
|
292
|
+
corrected: list[GraphEntity] = []
|
|
293
|
+
for (entity_type, canonical_key), name in winner_names.items():
|
|
294
|
+
row = conn.execute(
|
|
295
|
+
"UPDATE graph_entities SET name = %s "
|
|
296
|
+
"WHERE tenant_id = %s AND entity_type = %s AND canonical_key = %s "
|
|
297
|
+
"RETURNING id::text",
|
|
298
|
+
(name, tenant_id, entity_type, canonical_key),
|
|
299
|
+
).fetchone()
|
|
300
|
+
if row is None:
|
|
301
|
+
continue
|
|
302
|
+
corrected.append(
|
|
303
|
+
GraphEntity(
|
|
304
|
+
id=str(row[0]),
|
|
305
|
+
entity_type=entity_type,
|
|
306
|
+
name=name,
|
|
307
|
+
canonical_key=canonical_key,
|
|
308
|
+
tenant_id=tenant_id,
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
if corrected:
|
|
312
|
+
backend.upsert_entities(conn, tenant_id, corrected)
|