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,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
|