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,437 @@
1
+ """Eager community summaries + embeddings (wave G3-c, spec §17c Q10).
2
+
3
+ The second half of ``brain graphrag communities build|refresh`` (G3-f wires it in
4
+ after :func:`brain.graph_rag.communities.build_communities`). G3-b detects +
5
+ persists the tenant's communities (memberships, stats, the per-community
6
+ ``members_hash`` identity); THIS module gives each community a natural-language
7
+ ``summary`` and a ``summary_embedding`` so the global-retrieval RRF (§17c Q4/Q5)
8
+ has an FTS leg and a vector leg to rank over.
9
+
10
+ **EAGER, not per-query (§17c Q10).** "Lazy embedded summaries" in §4 D5 means
11
+ deferred relative to *ingest* — batched at community build/refresh — NOT lazy
12
+ relative to the query. Query time NEVER calls Ollama for a community summary; it
13
+ only embeds the *query* and degrades to FTS-only when ``summary_embedding`` is
14
+ NULL. So summaries/embeddings are produced here, ahead of any query.
15
+
16
+ **Best-effort, never-raise (§17c Q10 / §7).** Summary + embedding generation is a
17
+ hard live-Ollama dependency we refuse to let break the build:
18
+
19
+ * ``enricher.summarize_group(...) -> None`` (Ollama down / timeout / invalid /
20
+ empty) → the community's summary fields are left NULL and
21
+ ``summary_members_hash`` is NOT set, so the community stays a candidate and is
22
+ retried on the next run. The build still succeeds.
23
+ * An embedding failure (Ollama down, dim mismatch, …) → ``summary_embedding`` is
24
+ left NULL (the global path degrades that community to FTS-only) while the
25
+ ``summary`` text is still written. Retried next run.
26
+ * ``enricher`` is ``None`` → the whole pass is a logged no-op
27
+ (``skipped=True``): summaries cannot be produced without it. ``embedder`` is
28
+ ``None`` (but ``enricher`` present) → summaries are STILL written and only the
29
+ embedding phase is skipped (``summary_embedding`` left NULL → the global path
30
+ degrades that community to FTS-only; ``skipped=False``). The summary and
31
+ embedding phases are decoupled (§17c Q10): a broken/missing embedder must NOT
32
+ block summaries. Production injects
33
+ :func:`brain.enrichment.make_enricher` / :func:`brain.embeddings.make_embedder`
34
+ via G3-f; tests inject fakes. Either way the call never raises (an empty
35
+ ``tenant`` is the one exception — a caller bug, mirroring
36
+ :func:`~brain.graph_rag.communities.build_communities`).
37
+
38
+ **Staleness predicate (§17c Q3/Q10).** A community NEEDS a (re)summary when
39
+ ``summary IS NULL OR summary_members_hash IS DISTINCT FROM members_hash``: a
40
+ never-summarized community, or one whose membership changed since its last
41
+ summary. The G3-b delta-gate moves ``members_hash`` on a membership change while
42
+ PRESERVING the old summary, and migration 014's ``summary_members_hash`` records
43
+ which membership the live summary was built from — so staleness is detectable
44
+ WITHOUT ever blanking the live summary (it stays queryable until a fresh one
45
+ replaces it). ``IS DISTINCT FROM`` makes the predicate NULL-safe.
46
+
47
+ **Idempotent.** A second run with no membership change is a no-op: every
48
+ community already has ``summary_members_hash == members_hash`` (excluded from the
49
+ summary candidates) and a non-NULL ``summary_embedding`` (excluded from the embed
50
+ candidates). Mirrors ``brain enrich --backfill``'s NULL-only idempotency.
51
+
52
+ **Tenant-scoped + DRY embeddings.** Every read/write carries ``tenant_id``. The
53
+ embedding leg reuses the generalized dim-reconciliation machinery
54
+ (:func:`brain.db.ensure_embedding_column` over the
55
+ ``('graph_communities','summary_embedding')`` allowlist entry) rather than
56
+ hand-rolling embedding DDL/SQL — exactly as ``brain reembed`` does for
57
+ ``chunks.embedding``. ``summary_embedding`` stays NULLABLE with no HNSW (small
58
+ community counts → sequential cosine scan, spec §5; the global path guards on
59
+ ``IS NOT NULL``), so there is nothing to ``finalize_embedding_index`` — a
60
+ NOT NULL constraint would break the best-effort contract.
61
+ """
62
+ from __future__ import annotations
63
+
64
+ import logging
65
+ from dataclasses import dataclass
66
+ from typing import Any, Protocol
67
+
68
+ import psycopg
69
+
70
+ from ..config import Config
71
+ from ..db import ensure_embedding_column
72
+ from ..errors import GraphTenantError
73
+ from ..ingest import Embedder
74
+
75
+ __all__ = [
76
+ "CommunitySummaryResult",
77
+ "summarize_communities",
78
+ ]
79
+
80
+ _logger = logging.getLogger(__name__)
81
+
82
+ # Prompt-budget caps. A community can have many members / documents; the summary
83
+ # prompt only needs the most-central entities + the most-mentioning documents to
84
+ # characterize the cluster. Capping keeps the Ollama prompt bounded regardless of
85
+ # community size (the ops budget on TOTAL communities is the §17c Q8
86
+ # graph_community_max cap applied at detection time).
87
+ _SUMMARY_ENTITY_LIMIT = 20
88
+ _SUMMARY_DOC_LIMIT = 10
89
+
90
+
91
+ class _CommunitySummarizer(Protocol):
92
+ """Structural type for the injected summary backend (DI seam).
93
+
94
+ The summary pass depends only on ``summarize_group`` (best-effort,
95
+ never-raises — returns ``None`` + WARN on Ollama failure) and the ``model``
96
+ fingerprint recorded onto ``graph_communities.summary_model``. Production
97
+ injects a :class:`brain.enrichment.OllamaEnricher`; tests inject a fake.
98
+ Neither is imported here, mirroring :class:`brain.graph_rag.themes.
99
+ _GroupSummarizer`.
100
+ """
101
+
102
+ @property
103
+ def model(self) -> str: ...
104
+
105
+ def summarize_group(
106
+ self,
107
+ *,
108
+ person: str | None,
109
+ entity_names: list[str],
110
+ doc_titles: list[str],
111
+ ) -> str | None: ...
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class CommunitySummaryResult:
116
+ """Tally of a :func:`summarize_communities` run.
117
+
118
+ ``candidates`` is the number of communities found NEEDING a summary this run
119
+ (after the optional ``limit`` cap). ``summarized`` counts summaries actually
120
+ written; ``summary_failures`` counts candidates where the enricher returned
121
+ ``None`` / raised (left NULL, retried next run) — so
122
+ ``summarized + summary_failures == candidates``. ``embedded`` counts
123
+ ``summary_embedding`` vectors written; ``embed_failures`` counts communities
124
+ that had a summary but whose embedding step failed (left NULL, retried next
125
+ run). ``skipped`` is True only when the whole pass was a no-op because
126
+ ``enricher`` was ``None`` (no summaries possible). A ``None`` ``embedder``
127
+ does NOT set ``skipped`` — summaries are still written and only the embedding
128
+ phase is skipped (``embedded == 0``, ``summary_embedding`` left NULL).
129
+ """
130
+
131
+ tenant_id: str
132
+ candidates: int = 0
133
+ summarized: int = 0
134
+ summary_failures: int = 0
135
+ embedded: int = 0
136
+ embed_failures: int = 0
137
+ skipped: bool = False
138
+
139
+
140
+ def summarize_communities(
141
+ conn: psycopg.Connection[Any],
142
+ cfg: Config,
143
+ *,
144
+ tenant: str,
145
+ enricher: _CommunitySummarizer | None = None,
146
+ embedder: Embedder | None = None,
147
+ limit: int | None = None,
148
+ ) -> CommunitySummaryResult:
149
+ """Eagerly (re)summarize + embed the tenant's stale/new communities (G3-c).
150
+
151
+ Two best-effort phases over the communities NEEDING a summary
152
+ (``summary IS NULL OR summary_members_hash IS DISTINCT FROM members_hash``),
153
+ ordered by ``community_key`` for determinism and capped by ``limit`` when
154
+ given:
155
+
156
+ 1. **Summaries.** For each candidate, gather its representative entity
157
+ display-names (top members by ``member_rank``) + representative document
158
+ titles (docs whose mentions include the community's entities), then call
159
+ ``enricher.summarize_group(person=None, ...)``. On a non-``None`` summary,
160
+ write ``summary`` / ``summary_model`` / ``summary_at`` /
161
+ ``summary_members_hash = members_hash`` and reset ``summary_embedding`` to
162
+ NULL (it must be re-embedded from the fresh text). On ``None`` (Ollama
163
+ failure), leave everything NULL and do NOT set ``summary_members_hash`` —
164
+ the community stays a candidate and is retried next run.
165
+ 2. **Embeddings.** Reconcile ``graph_communities.summary_embedding`` to the
166
+ active embedder's dim via :func:`brain.db.ensure_embedding_column`, then
167
+ embed every community with ``summary IS NOT NULL AND summary_embedding IS
168
+ NULL`` (the freshly-summarized ones plus any whose prior embed failed).
169
+ Best-effort: any failure leaves those embeddings NULL (the global path
170
+ degrades to FTS-only) and the build still succeeds.
171
+
172
+ ``enricher`` / ``embedder`` are injected (production: ``make_enricher(cfg)`` /
173
+ ``make_embedder(cfg)``; tests: fakes). A ``None`` ``enricher`` skips the whole
174
+ pass (``skipped=True`` — no summaries possible); a ``None`` ``embedder`` (with
175
+ the enricher present) still writes summaries and only skips the embedding
176
+ phase (``summary_embedding`` stays NULL, ``skipped=False``) — the two phases
177
+ are decoupled so a broken/missing embedder never blocks summaries. Never
178
+ raises on an Ollama / embedding failure; an empty ``tenant`` is a caller bug
179
+ and raises :class:`brain.errors.GraphTenantError` before any DB work (mirrors
180
+ :func:`brain.graph_rag.communities.build_communities`).
181
+
182
+ ``limit`` caps how many stale/new communities are (re)summarized this run;
183
+ when ``None`` it falls back to ``cfg.graph_community_max`` (the §17c Q8 ops
184
+ cap, itself ``None`` == unlimited by default). The universe is already
185
+ bounded because detection (G3-b) materializes at most
186
+ ``graph_community_max`` communities, so this fallback is a behavior-neutral
187
+ safety net rather than a second independent cap.
188
+ """
189
+ if not tenant:
190
+ raise GraphTenantError(
191
+ "summarize_communities requires a non-empty tenant_id "
192
+ "(resolve via brain.graph_rag.tenancy.resolve_tenant first)"
193
+ )
194
+
195
+ effective_limit = limit if limit is not None else cfg.graph_community_max
196
+
197
+ if enricher is None:
198
+ _logger.warning(
199
+ "summarize_communities: enricher is None — skipping the whole "
200
+ "community summary/embedding pass (cannot summarize without an "
201
+ "enricher; best-effort no-op, no fields written)"
202
+ )
203
+ return CommunitySummaryResult(tenant_id=tenant, skipped=True)
204
+
205
+ summarized, summary_failures, candidates = _run_summary_phase(
206
+ conn, tenant=tenant, enricher=enricher, limit=effective_limit
207
+ )
208
+
209
+ if embedder is None:
210
+ # Decoupled phases (§17c Q10): a missing/unavailable embedder must NOT
211
+ # block summaries. The summary text/model/at + summary_members_hash are
212
+ # already written above; only the embedding phase is skipped, leaving
213
+ # summary_embedding NULL (the global path degrades that community to
214
+ # FTS-only) — re-embedded on the next run once an embedder is available.
215
+ _logger.warning(
216
+ "summarize_communities: embedder is None — summaries written but "
217
+ "the embedding phase was skipped (summary_embedding left NULL; "
218
+ "global retrieval degrades to FTS-only; retried next run)"
219
+ )
220
+ return CommunitySummaryResult(
221
+ tenant_id=tenant,
222
+ candidates=candidates,
223
+ summarized=summarized,
224
+ summary_failures=summary_failures,
225
+ embedded=0,
226
+ embed_failures=0,
227
+ skipped=False,
228
+ )
229
+
230
+ embedded, embed_failures = _run_embedding_phase(
231
+ conn, tenant=tenant, embedder=embedder
232
+ )
233
+ return CommunitySummaryResult(
234
+ tenant_id=tenant,
235
+ candidates=candidates,
236
+ summarized=summarized,
237
+ summary_failures=summary_failures,
238
+ embedded=embedded,
239
+ embed_failures=embed_failures,
240
+ skipped=False,
241
+ )
242
+
243
+
244
+ # --------------------------------------------------------------------------- #
245
+ # Phase 1 — summaries (best-effort, never-raise).
246
+ # --------------------------------------------------------------------------- #
247
+ def _run_summary_phase(
248
+ conn: psycopg.Connection[Any],
249
+ *,
250
+ tenant: str,
251
+ enricher: _CommunitySummarizer,
252
+ limit: int | None,
253
+ ) -> tuple[int, int, int]:
254
+ """Generate + persist summaries for stale/new communities.
255
+
256
+ Returns ``(summarized, summary_failures, candidates)``. The Ollama calls run
257
+ OUTSIDE any open DB transaction (so a slow model never holds a write lock);
258
+ successes are then written in a single transaction. Each
259
+ ``summarize_group`` call is wrapped in a defence-in-depth ``try/except``
260
+ (it already returns ``None`` on failure; the guard covers a misbehaving
261
+ injected fake) so the phase never raises.
262
+ """
263
+ candidate_rows = _read_summary_candidates(conn, tenant=tenant, limit=limit)
264
+ if not candidate_rows:
265
+ return 0, 0, 0
266
+
267
+ # (community_key, summary_text, model, members_hash) for each success.
268
+ writes: list[tuple[str, str, str, str]] = []
269
+ summary_failures = 0
270
+ for community_key, members_hash in candidate_rows:
271
+ entity_names = _representative_entities(conn, tenant=tenant, key=community_key)
272
+ doc_titles = _representative_doc_titles(conn, tenant=tenant, key=community_key)
273
+ try:
274
+ summary = enricher.summarize_group(
275
+ person=None, entity_names=entity_names, doc_titles=doc_titles
276
+ )
277
+ except Exception as exc: # noqa: BLE001 — best-effort: never fail the build
278
+ _logger.warning(
279
+ "summarize_communities: summary for community %s failed (%s); "
280
+ "leaving NULL (retried next run)",
281
+ community_key,
282
+ exc,
283
+ )
284
+ summary = None
285
+ if summary is None:
286
+ summary_failures += 1
287
+ continue
288
+ writes.append((community_key, summary, enricher.model, members_hash))
289
+
290
+ if writes:
291
+ with conn.transaction():
292
+ for community_key, summary, model, members_hash in writes:
293
+ conn.execute(
294
+ "UPDATE graph_communities SET "
295
+ "summary = %s, summary_model = %s, summary_at = NOW(), "
296
+ "summary_members_hash = %s, summary_embedding = NULL "
297
+ "WHERE tenant_id = %s AND community_key = %s",
298
+ (summary, model, members_hash, tenant, community_key),
299
+ )
300
+ return len(writes), summary_failures, len(candidate_rows)
301
+
302
+
303
+ def _read_summary_candidates(
304
+ conn: psycopg.Connection[Any], *, tenant: str, limit: int | None
305
+ ) -> list[tuple[str, str]]:
306
+ """Return ``(community_key, members_hash)`` for communities needing a summary.
307
+
308
+ Staleness predicate (§17c Q3/Q10): ``summary IS NULL`` (never summarized) OR
309
+ ``summary_members_hash IS DISTINCT FROM members_hash`` (membership changed
310
+ since the last summary). Ordered by ``community_key`` for deterministic,
311
+ resumable processing; capped by ``limit`` when given (``None`` == all
312
+ candidates — the universe is already bounded by the §17c Q8
313
+ ``graph_community_max`` materialization cap applied at detection).
314
+ """
315
+ base = (
316
+ "SELECT community_key::text, members_hash FROM graph_communities "
317
+ "WHERE tenant_id = %s "
318
+ "AND (summary IS NULL OR summary_members_hash IS DISTINCT FROM members_hash) "
319
+ "ORDER BY community_key"
320
+ )
321
+ if limit is not None:
322
+ rows = conn.execute(base + " LIMIT %s", (tenant, limit)).fetchall()
323
+ else:
324
+ rows = conn.execute(base, (tenant,)).fetchall()
325
+ return [(str(key), str(members_hash)) for key, members_hash in rows]
326
+
327
+
328
+ def _representative_entities(
329
+ conn: psycopg.Connection[Any], *, tenant: str, key: str
330
+ ) -> list[str]:
331
+ """Top member entity display-names for the summary prompt (by ``member_rank``).
332
+
333
+ ``member_rank`` is 0-based most-central-first (G3-b ranks by weighted degree),
334
+ so ``ORDER BY member_rank`` surfaces the cluster's hub entities. Capped at
335
+ :data:`_SUMMARY_ENTITY_LIMIT` to keep the prompt bounded.
336
+ """
337
+ rows = conn.execute(
338
+ "SELECT ge.name FROM graph_community_members cm "
339
+ "JOIN graph_entities ge "
340
+ " ON ge.tenant_id = cm.tenant_id AND ge.id = cm.entity_id "
341
+ "WHERE cm.tenant_id = %s AND cm.community_key = %s "
342
+ "ORDER BY cm.member_rank ASC, ge.name ASC "
343
+ "LIMIT %s",
344
+ (tenant, key, _SUMMARY_ENTITY_LIMIT),
345
+ ).fetchall()
346
+ return [str(row[0]) for row in rows]
347
+
348
+
349
+ def _representative_doc_titles(
350
+ conn: psycopg.Connection[Any], *, tenant: str, key: str
351
+ ) -> list[str]:
352
+ """Titles of the documents that most mention the community's entities.
353
+
354
+ Joins ``graph_community_members`` → ``graph_entity_mentions`` →
355
+ ``documents`` (all tenant-scoped) and ranks documents by how many of the
356
+ community's entities they mention (``COUNT(*)`` desc, title asc for a
357
+ deterministic tie-break). Capped at :data:`_SUMMARY_DOC_LIMIT`. No document
358
+ BODY is read — only titles feed the prompt (mirrors
359
+ :func:`brain.graph_rag.themes._fetch_doc_titles`).
360
+ """
361
+ rows = conn.execute(
362
+ "SELECT d.title, COUNT(*) AS n FROM graph_community_members cm "
363
+ "JOIN graph_entity_mentions m "
364
+ " ON m.tenant_id = cm.tenant_id AND m.entity_id = cm.entity_id "
365
+ "JOIN documents d ON d.id = m.document_id "
366
+ "WHERE cm.tenant_id = %s AND cm.community_key = %s "
367
+ "GROUP BY d.id, d.title "
368
+ "ORDER BY n DESC, d.title ASC "
369
+ "LIMIT %s",
370
+ (tenant, key, _SUMMARY_DOC_LIMIT),
371
+ ).fetchall()
372
+ return [str(row[0]) for row in rows]
373
+
374
+
375
+ # --------------------------------------------------------------------------- #
376
+ # Phase 2 — embeddings (best-effort, never-raise; reuses the dim machinery).
377
+ # --------------------------------------------------------------------------- #
378
+ def _run_embedding_phase(
379
+ conn: psycopg.Connection[Any],
380
+ *,
381
+ tenant: str,
382
+ embedder: Embedder,
383
+ ) -> tuple[int, int]:
384
+ """Embed every summary that lacks a ``summary_embedding``. Best-effort.
385
+
386
+ Returns ``(embedded, embed_failures)``. Reconciles the
387
+ ``graph_communities.summary_embedding`` dim to the active embedder via
388
+ :func:`brain.db.ensure_embedding_column` (the same generalized machinery
389
+ ``brain reembed`` uses for ``chunks.embedding``), reads the tenant's
390
+ ``summary IS NOT NULL AND summary_embedding IS NULL`` rows, embeds their
391
+ summary text (``input_type="document"``), and writes the vectors in one
392
+ transaction. Any failure (Ollama down, dim mismatch with populated
393
+ embeddings, transport error) is caught: a WARN is logged, the affected
394
+ embeddings stay NULL (the global path degrades to FTS-only for those
395
+ communities), and the build still succeeds — nothing re-raises.
396
+ """
397
+ pending: list[tuple[str, str]] = []
398
+ try:
399
+ # Reconcile dim FIRST so the column matches the active backend before any
400
+ # vector is bound. On a fresh DB with no populated summary embeddings this
401
+ # is a cheap no-op (matching dim) or a safe drop+re-add (dim change, zero
402
+ # rows to lose); with populated embeddings at a different dim it raises —
403
+ # caught below and surfaced as a WARN (a destructive backend swap, not an
404
+ # Ollama hiccup, but still best-effort so the build never breaks).
405
+ ensure_embedding_column(
406
+ conn, embedder, "graph_communities", "summary_embedding"
407
+ )
408
+ pending = conn.execute(
409
+ "SELECT community_key::text, summary FROM graph_communities "
410
+ "WHERE tenant_id = %s "
411
+ "AND summary IS NOT NULL AND summary_embedding IS NULL "
412
+ "ORDER BY community_key",
413
+ (tenant,),
414
+ ).fetchall()
415
+ if not pending:
416
+ return 0, 0
417
+ vectors = embedder.embed(
418
+ [str(summary) for _key, summary in pending], input_type="document"
419
+ )
420
+ with conn.transaction():
421
+ for (community_key, _summary), vector in zip(
422
+ pending, vectors, strict=True
423
+ ):
424
+ conn.execute(
425
+ "UPDATE graph_communities SET summary_embedding = %s "
426
+ "WHERE tenant_id = %s AND community_key = %s",
427
+ (vector, tenant, str(community_key)),
428
+ )
429
+ except Exception as exc: # noqa: BLE001 — best-effort: never fail the build
430
+ _logger.warning(
431
+ "summarize_communities: embedding pass failed (%s); leaving "
432
+ "summary_embedding NULL for %d community/-ies (retried next run)",
433
+ exc,
434
+ len(pending),
435
+ )
436
+ return 0, len(pending)
437
+ return len(pending), 0
@@ -0,0 +1,202 @@
1
+ """Concept-aspect relational helpers for graph reconcile (wave G2-c, GraphRAG).
2
+
3
+ The concept counterpart of the person-aspect helpers in
4
+ :mod:`brain.graph_rag.reconcile`. People are derived *for free* from the
5
+ participants pipeline; **concepts** — topics, projects, organizations, tools —
6
+ are extracted from raw document text by the gated
7
+ :class:`brain.graph_rag.extract.EntityExtractor` (default
8
+ :class:`~brain.graph_rag.extract.OllamaExtractor`), then upserted +
9
+ positioned + co-occurrence-windowed here.
10
+
11
+ This module owns ONLY the concept-aspect's relational + derive-time pieces,
12
+ keeping :mod:`brain.graph_rag.reconcile` the lean orchestrator (mirroring the
13
+ G1 boundary that moved the shared aggregate recompute into
14
+ :mod:`brain.graph_rag.aggregates`):
15
+
16
+ * :data:`CONCEPTS_ASPECT` / :data:`CONCEPT_ENTITY_TYPES` — the
17
+ ``graph_index_state.aspect`` value (migration 012 ``CHECK IN
18
+ ('people','concepts')``) and the four concept ``entity_type``s the extractor
19
+ emits (people are a separate aspect — never double-counted).
20
+ * :func:`concept_mention_source` — the ``graph_entity_mentions.source``
21
+ provenance string ``"extractor:<model>@<ver>"`` (spec §5a) from the
22
+ extractor's ``version``.
23
+ * :func:`concept_inputs_hash` — the per-aspect watermark ``inputs_hash``. Unlike
24
+ the person aspect (which folds the *resolved persons* into ``inputs_hash`` so a
25
+ metadata-only edit that does not change ``content_hash`` still re-indexes),
26
+ concept extraction depends ONLY on the document text + the model/algorithm, so
27
+ ``content_hash`` + ``extractor_ver`` already capture the extraction inputs and
28
+ ``inputs_hash`` carries only the co-occurrence config (window + per-doc cap).
29
+ This is what lets the G2-c concept skip-check run **before** the LLM call —
30
+ an unchanged watermark short-circuits with no extraction (spec §7 step 1).
31
+ * :func:`upsert_concept_entities` — upsert the extracted entities into
32
+ ``graph_entities`` (keyed ``(tenant_id, entity_type, canonical_key)``),
33
+ returning :class:`~brain.graph_rag.schema.GraphEntity` rows with their ids.
34
+ * :func:`build_concept_rows` — turn the extracted entities + their upserted ids
35
+ into the doc's concept ``graph_entity_mentions`` + ``graph_edge_contributions``
36
+ rows. Concepts use **real raw-text word positions** (spec §4 D4) so the
37
+ co-occurrence window pairs only entities that occur within ``window`` words of
38
+ each other — distinct from the person aspect's doc-level co-presence (every
39
+ participant at notional position 0, so any ``window >= 1`` yields the complete
40
+ graph over the doc's persons).
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import hashlib
45
+ import json
46
+ from typing import Any
47
+
48
+ import psycopg
49
+
50
+ from .cooccur import EntityOccurrence, cooccurrence_counts, to_contributions
51
+ from .extract import CONCEPT_ENTITY_TYPES as _CONCEPT_ENTITY_TYPES
52
+ from .extract import ExtractedEntity
53
+ from .schema import EdgeContribution, EntityMention, GraphEntity
54
+
55
+ __all__ = [
56
+ "CONCEPTS_ASPECT",
57
+ "CONCEPT_ENTITY_TYPES",
58
+ "build_concept_rows",
59
+ "concept_inputs_hash",
60
+ "concept_mention_source",
61
+ "upsert_concept_entities",
62
+ ]
63
+
64
+ # The migration-012 ``graph_index_state.aspect`` value this aspect owns. People
65
+ # and concepts re-index independently under their own watermark rows (spec §7).
66
+ CONCEPTS_ASPECT = "concepts"
67
+
68
+ # The four concept ``entity_type``s (migration 012 ``CHECK`` minus ``person``),
69
+ # as a sorted tuple for deterministic ``entity_type = ANY(%s)`` scoping in the
70
+ # aspect-scoped relational rewrite. People are derived from the participants
71
+ # pipeline and handled by the person aspect — never extracted here (spec §17b
72
+ # decision 2: "people excluded"), so concept reconcile never touches a person
73
+ # row and the two aspects never double-count an entity.
74
+ CONCEPT_ENTITY_TYPES: tuple[str, ...] = tuple(sorted(_CONCEPT_ENTITY_TYPES))
75
+
76
+
77
+ def concept_mention_source(extractor_version: str) -> str:
78
+ """``graph_entity_mentions.source`` provenance for concepts (spec §5a).
79
+
80
+ ``"extractor:<model>@<ver>"`` — i.e. ``f"extractor:{extractor.version}"``,
81
+ where ``extractor.version`` is the ``"<model>@concepts-v5"`` fingerprint
82
+ (:attr:`brain.graph_rag.extract.OllamaExtractor.version`). Distinguishes
83
+ concept mentions from the person pipeline's ``"people"`` source.
84
+ """
85
+ return f"extractor:{extractor_version}"
86
+
87
+
88
+ def concept_inputs_hash(
89
+ window: int,
90
+ max_entities: int | None,
91
+ stopwords: frozenset[str] = frozenset(),
92
+ ) -> str:
93
+ """Stable fingerprint of the concept-aspect's config inputs (watermark).
94
+
95
+ Captures the co-occurrence config (window + per-doc cap) and the
96
+ operator-curated stopword set (Phase B). The document content is tracked by
97
+ ``graph_index_state.content_hash`` and the model/algorithm by
98
+ ``extractor_ver``, so this deliberately excludes the extracted entities —
99
+ letting the G2-c concept skip-check run before any LLM call (an unchanged
100
+ watermark short-circuits extraction; spec §7 step 1).
101
+
102
+ ``stopwords`` is folded as a *sorted* list so order does not affect the
103
+ fingerprint (frozenset iteration order is undefined). Adding or removing a
104
+ stopword changes the hash and forces re-extraction even when the
105
+ ``EXTRACTOR_VERSION`` itself has not changed (Phase B F6 requirement).
106
+ """
107
+ payload = {
108
+ "aspect": CONCEPTS_ASPECT,
109
+ "window": window,
110
+ "max_entities": max_entities,
111
+ "stopwords": sorted(stopwords),
112
+ }
113
+ serialized = json.dumps(payload, sort_keys=True, separators=(",", ":"))
114
+ return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
115
+
116
+
117
+ def upsert_concept_entities(
118
+ conn: psycopg.Connection[Any],
119
+ tenant_id: str,
120
+ entities: list[ExtractedEntity],
121
+ ) -> list[GraphEntity]:
122
+ """Upsert extracted concept ``graph_entities`` rows, returning them with ids.
123
+
124
+ Keyed on ``(tenant_id, entity_type, canonical_key)`` (migration 012) so
125
+ re-running reuses the existing row (refreshing its surface ``name``). Order
126
+ is preserved 1:1 with ``entities`` so the caller can zip each returned
127
+ :class:`GraphEntity` back to its source :class:`ExtractedEntity` (positions /
128
+ mention_count). Mirrors
129
+ :func:`brain.graph_rag.reconcile._upsert_person_entities` but carries each
130
+ entity's own concept ``entity_type`` rather than the hardcoded ``person``.
131
+ """
132
+ result: list[GraphEntity] = []
133
+ for entity in entities:
134
+ row = conn.execute(
135
+ """
136
+ INSERT INTO graph_entities (tenant_id, entity_type, name, canonical_key)
137
+ VALUES (%s, %s, %s, %s)
138
+ ON CONFLICT (tenant_id, entity_type, canonical_key) DO UPDATE SET
139
+ name = EXCLUDED.name,
140
+ updated_at = NOW()
141
+ RETURNING id::text
142
+ """,
143
+ (tenant_id, entity.entity_type, entity.display_name, entity.canonical_key),
144
+ ).fetchone()
145
+ # RETURNING on an INSERT ... ON CONFLICT DO UPDATE always yields one row.
146
+ assert row is not None
147
+ result.append(
148
+ GraphEntity(
149
+ id=str(row[0]),
150
+ entity_type=entity.entity_type,
151
+ name=entity.display_name,
152
+ canonical_key=entity.canonical_key,
153
+ tenant_id=tenant_id,
154
+ )
155
+ )
156
+ return result
157
+
158
+
159
+ def build_concept_rows(
160
+ extracted: list[ExtractedEntity],
161
+ concept_entities: list[GraphEntity],
162
+ *,
163
+ document_id: str,
164
+ tenant_id: str,
165
+ window: int,
166
+ source: str,
167
+ ) -> tuple[list[EntityMention], list[EdgeContribution]]:
168
+ """Build a doc's concept mentions + co-occurrence contributions.
169
+
170
+ ``extracted`` and ``concept_entities`` are positional twins (the latter is
171
+ :func:`upsert_concept_entities`'s output). Each extracted entity becomes one
172
+ ``graph_entity_mentions`` row (provenance ``source``); its real raw-text word
173
+ positions (spec §4 D4) become :class:`~brain.graph_rag.cooccur.EntityOccurrence`
174
+ inputs to the windowed co-occurrence — so two concepts co-occur iff their
175
+ word positions differ by at most ``window`` (genuine text proximity, distinct
176
+ from the person aspect's doc-level co-presence). An extracted entity the
177
+ model named but that never appears verbatim has empty positions: it is a
178
+ mention (it is a concept of the doc) but pairs with nothing. The per-doc
179
+ distinct-entity cap was already applied by the extractor, so the
180
+ co-occurrence pass disables its own cap (``max_entities=None``), mirroring the
181
+ person aspect.
182
+ """
183
+ mentions = [
184
+ EntityMention(
185
+ entity_id=entity.id,
186
+ document_id=document_id,
187
+ source=source,
188
+ tenant_id=tenant_id,
189
+ mention_count=ext.mention_count,
190
+ )
191
+ for ext, entity in zip(extracted, concept_entities, strict=True)
192
+ ]
193
+ occurrences = [
194
+ EntityOccurrence(entity_id=entity.id, position=position)
195
+ for ext, entity in zip(extracted, concept_entities, strict=True)
196
+ for position in ext.positions
197
+ ]
198
+ counts = cooccurrence_counts(occurrences, window=window, max_entities=None)
199
+ contributions = to_contributions(
200
+ counts, document_id=document_id, tenant_id=tenant_id
201
+ )
202
+ return mentions, contributions