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
brain/wiki/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Wiki build pipeline: atomic blue/green static site swaps."""
|
|
2
|
+
|
|
3
|
+
# Pinned Quartz commit SHA. Bumping this is a code change, not a
|
|
4
|
+
# runtime gamble — the overlay tree is tested against this exact commit.
|
|
5
|
+
QUARTZ_PINNED_COMMIT = "d25a6eabf96751ffca56f8a8139272def7a65041"
|
|
6
|
+
QUARTZ_REPO_URL = "https://github.com/jackyzha0/quartz.git"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Minimal Python reimplementation of github-slugger heading-anchor semantics.
|
|
2
|
+
|
|
3
|
+
Used by ``fastpath_manifest.py`` to compute heading anchor strings that match
|
|
4
|
+
what Quartz produces via the ``github-slugger`` npm package (v2.x).
|
|
5
|
+
|
|
6
|
+
Algorithm (mirrors github-slugger source):
|
|
7
|
+
1. NFC-normalise the text.
|
|
8
|
+
2. Lowercase.
|
|
9
|
+
3. Strip: Unicode general punctuation \\u2000-\\u206F, supplemental
|
|
10
|
+
\\u2E00-\\u2E7F, and ASCII punctuation: ``\\'!"#$%&()*+,./:;<=>?@[]^`{|}~``.
|
|
11
|
+
4. Replace whitespace runs with ``-``.
|
|
12
|
+
5. Track duplicate headings within a document; suffix with ``-1``, ``-2``, etc.
|
|
13
|
+
|
|
14
|
+
Parity with TS is enforced by ``tests/wiki/test_fastpath_fingerprint_parity.py``.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import unicodedata
|
|
20
|
+
|
|
21
|
+
# ASCII characters stripped by github-slugger v2.
|
|
22
|
+
# Matches the regex in github-slugger/index.js:
|
|
23
|
+
# /[\\u2000-\\u206F\\u2E00-\\u2E7F\\\\'!"#$%&()*+,./:;<=>?@[\\]^`{|}~]/g
|
|
24
|
+
_STRIP_ASCII: frozenset[str] = frozenset({
|
|
25
|
+
"\\", "'", '"', "!", "#", "$", "%", "&", "(", ")", "*", "+",
|
|
26
|
+
",", ".", "/", ":", ";", "<", "=", ">", "?", "@", "[", "]",
|
|
27
|
+
"^", "`", "{", "|", "}", "~",
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _normalize(text: str) -> str:
|
|
32
|
+
"""Apply NFC normalisation + github-slugger stripping to heading text."""
|
|
33
|
+
text = unicodedata.normalize("NFC", text)
|
|
34
|
+
text = text.lower().strip()
|
|
35
|
+
result: list[str] = []
|
|
36
|
+
for ch in text:
|
|
37
|
+
cp = ord(ch)
|
|
38
|
+
if ch in _STRIP_ASCII:
|
|
39
|
+
continue
|
|
40
|
+
if 0x2000 <= cp <= 0x206F or 0x2E00 <= cp <= 0x2E7F:
|
|
41
|
+
continue
|
|
42
|
+
result.append(ch)
|
|
43
|
+
text = "".join(result)
|
|
44
|
+
# Replace whitespace runs (including Unicode whitespace) with a single hyphen.
|
|
45
|
+
text = re.sub(r"\s+", "-", text)
|
|
46
|
+
return text
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Slugger:
|
|
50
|
+
"""Stateful github-slugger: tracks duplicates within one document.
|
|
51
|
+
|
|
52
|
+
Matches the ``Slugger`` class from the github-slugger npm package (v2.x).
|
|
53
|
+
Create one instance per document; call :meth:`slug` for each heading in
|
|
54
|
+
document order; call :meth:`reset` before processing a new document.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self._seen: dict[str, int] = {}
|
|
59
|
+
|
|
60
|
+
def slug(self, text: str) -> str:
|
|
61
|
+
"""Return heading anchor for ``text``, with duplicate disambiguation.
|
|
62
|
+
|
|
63
|
+
First occurrence of a heading → plain normalised string.
|
|
64
|
+
Second → ``<base>-1``. Third → ``<base>-2``. Etc.
|
|
65
|
+
"""
|
|
66
|
+
base = _normalize(text)
|
|
67
|
+
if base not in self._seen:
|
|
68
|
+
self._seen[base] = 0
|
|
69
|
+
return base
|
|
70
|
+
count = self._seen[base] + 1
|
|
71
|
+
self._seen[base] = count
|
|
72
|
+
return f"{base}-{count}"
|
|
73
|
+
|
|
74
|
+
def reset(self) -> None:
|
|
75
|
+
"""Reset per-document state (call between documents)."""
|
|
76
|
+
self._seen.clear()
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Shared person-name normalization for the People Hub + graph reconcile.
|
|
2
|
+
|
|
3
|
+
Pure, DB-free, and heavily unit-tested. This module is the single source of
|
|
4
|
+
truth for turning a raw participant identifier (a display name from a Gmail
|
|
5
|
+
header, a Krisp speaker label, or a bare email address) into:
|
|
6
|
+
|
|
7
|
+
* a ``canonical_key`` — the lowercase, separator-collapsed *merge identity*
|
|
8
|
+
(``Jane.Doe``, ``jane_doe``, and ``Jane Doe`` all collapse to ``jane doe``),
|
|
9
|
+
and
|
|
10
|
+
* a ``display_name`` — the human-facing presentation form.
|
|
11
|
+
|
|
12
|
+
It also owns the two deterministic *filters* that gate person extraction:
|
|
13
|
+
:func:`is_automated_sender` (drop no-reply / notification / mailer org senders)
|
|
14
|
+
and :func:`expand_owner_keys` (widen the corpus-owner key set so the owner can
|
|
15
|
+
never leak in under a first-name-only or email-local-part variant).
|
|
16
|
+
|
|
17
|
+
Both the People-Hub aggregator (:mod:`brain.wiki.build_people`) and the graph
|
|
18
|
+
person reconcile (:mod:`brain.graph_rag.reconcile`) route through here, so the
|
|
19
|
+
graph's person entities and the rendered ``<vault>/people/`` roster derive from
|
|
20
|
+
exactly the same cleaned identity — they can never drift.
|
|
21
|
+
|
|
22
|
+
Design notes:
|
|
23
|
+
|
|
24
|
+
* Hyphens and apostrophes are preserved inside the canonical key (``anne-marie``,
|
|
25
|
+
``o'brien`` are real names). Only ``.``, ``_`` and whitespace runs collapse to
|
|
26
|
+
a single space — that is what merges handle-style keys with their spaced
|
|
27
|
+
display form.
|
|
28
|
+
* An email-shaped input is humanized from its *local part only* — the local part
|
|
29
|
+
is canonicalized like any other name, the domain is discarded. A raw
|
|
30
|
+
``jane.doe@example.com`` therefore becomes ``jane doe`` / ``Jane Doe`` and is
|
|
31
|
+
never title-cased into ``Jane.Doe@Example.Com``.
|
|
32
|
+
* Presentation casing uses :meth:`str.title`, matching the prior
|
|
33
|
+
``humanize_display_name`` behavior. Rare apostrophe / camel-case names
|
|
34
|
+
(``d'arcy`` → ``D'Arcy``) are slightly mangled — an accepted trade-off the
|
|
35
|
+
user can override via ``_people.yml``.
|
|
36
|
+
"""
|
|
37
|
+
import re
|
|
38
|
+
from collections.abc import Iterable
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
|
|
41
|
+
from brain.vault.derived_links.participants import is_email_like
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"NormalizedName",
|
|
45
|
+
"expand_owner_keys",
|
|
46
|
+
"humanize_person_name",
|
|
47
|
+
"is_automated_sender",
|
|
48
|
+
"normalize_person_name",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# Minimum length (after canonicalization) for a name to be considered real.
|
|
52
|
+
# Single-letter "names" (``A``, ``J``) are too noisy to link on — mirrors
|
|
53
|
+
# ``brain.vault.derived_links.participants._MIN_NAME_LENGTH``.
|
|
54
|
+
_MIN_NAME_LENGTH = 2
|
|
55
|
+
|
|
56
|
+
# Mailing-list "via X" decoration (Google Groups rewrites the ``From`` header to
|
|
57
|
+
# ``"Jane Doe via Acme Members" <list@…>``). Strip ``via`` and everything after.
|
|
58
|
+
_VIA_RE = re.compile(r"\s+via\s+.+$", re.IGNORECASE)
|
|
59
|
+
|
|
60
|
+
# Trailing ``(Org…`` fragment — e.g. ``Smith, John (Acme Tech`` (often with no
|
|
61
|
+
# closing paren because the header was truncated). Drops from the first ``(``.
|
|
62
|
+
_ORG_PAREN_RE = re.compile(r"\s*\(.*$")
|
|
63
|
+
|
|
64
|
+
# Separators collapsed to a single space when building the canonical key. NOTE:
|
|
65
|
+
# hyphen is intentionally absent — ``anne-marie`` keeps its hyphen.
|
|
66
|
+
_SEPARATORS_RE = re.compile(r"[._\s]+")
|
|
67
|
+
|
|
68
|
+
# Outer quotes / brackets stripped from the whole token (stray ``'`` / ``<>`` /
|
|
69
|
+
# ``[]`` / ``{}`` left by header rewrites). Internal characters are untouched.
|
|
70
|
+
_OUTER_JUNK = " \t\r\n\f\v\"'`<>[]{}"
|
|
71
|
+
|
|
72
|
+
# Outer punctuation stripped after separator collapse — mirrors the strip set in
|
|
73
|
+
# ``normalize_participant`` so the two layers agree. Internal hyphens /
|
|
74
|
+
# apostrophes survive (only leading/trailing ones are removed).
|
|
75
|
+
_OUTER_PUNCT = " \t\n\r\f\v.,;:!?\"'`()[]{}<>-_/\\|"
|
|
76
|
+
|
|
77
|
+
# Markers (matched against the email's LOCAL PART ONLY — never the domain, which
|
|
78
|
+
# would drop real humans like ``john@mailer-corp.example.com`` or
|
|
79
|
+
# ``jane@notifications.acme.com``) that flag an automated / non-human sender.
|
|
80
|
+
# Kept GENERIC — structural words only, no corpus-specific company names. The
|
|
81
|
+
# match uses word-boundary semantics (see :func:`_local_is_automated`): the
|
|
82
|
+
# local part must EQUAL a marker, or start with ``marker + sep``, or end with
|
|
83
|
+
# ``sep + marker`` (``sep`` ∈ :data:`_MARKER_SEPS`). That catches ``no-reply``,
|
|
84
|
+
# ``mailer-daemon``, ``bounce``, ``acme.noreply`` but NOT ``dmailer`` (Dana
|
|
85
|
+
# Mailer) or ``jbounce`` (Jane Bounce).
|
|
86
|
+
_AUTOMATED_MARKERS: frozenset[str] = frozenset(
|
|
87
|
+
{
|
|
88
|
+
"no-reply",
|
|
89
|
+
"noreply",
|
|
90
|
+
"no_reply",
|
|
91
|
+
"donotreply",
|
|
92
|
+
"do-not-reply",
|
|
93
|
+
"do_not_reply",
|
|
94
|
+
"notifications",
|
|
95
|
+
"notification",
|
|
96
|
+
"mailer-daemon",
|
|
97
|
+
"mailer_daemon",
|
|
98
|
+
"mailerdaemon",
|
|
99
|
+
"postmaster",
|
|
100
|
+
"bounce",
|
|
101
|
+
"bounces",
|
|
102
|
+
"auto-reply",
|
|
103
|
+
"autoreply",
|
|
104
|
+
"automated",
|
|
105
|
+
"mailer",
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Separators that delimit a marker inside a local part for the boundary match.
|
|
110
|
+
_MARKER_SEPS: tuple[str, ...] = ("-", ".", "_", "+")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class NormalizedName:
|
|
115
|
+
"""A cleaned person identity: a merge key plus a presentation form.
|
|
116
|
+
|
|
117
|
+
``canonical_key`` is the lowercase, separator-collapsed identity used to
|
|
118
|
+
merge variants of the same person and key ``graph_entities`` rows.
|
|
119
|
+
``display_name`` is the human-facing form (title-cased canonical key).
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
canonical_key: str
|
|
123
|
+
display_name: str
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def humanize_person_name(canonical_key: str) -> str:
|
|
127
|
+
"""Re-cap a lowercase canonical key for headings / frontmatter.
|
|
128
|
+
|
|
129
|
+
Title-cases the canonical key. Inputs are always clean canonical keys (no
|
|
130
|
+
``@``, separators already collapsed) so this is a thin, deterministic
|
|
131
|
+
presentation transform. Shared by the People Hub renderer, the graph
|
|
132
|
+
reconcile resolver, and the ``brain people`` CLI.
|
|
133
|
+
"""
|
|
134
|
+
return canonical_key.title()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _canonical_from_raw(text: str) -> str | None:
|
|
138
|
+
"""Lowercase, collapse ``. _`` + whitespace, strip outer punctuation.
|
|
139
|
+
|
|
140
|
+
Returns ``None`` when fewer than :data:`_MIN_NAME_LENGTH` characters
|
|
141
|
+
survive — the caller drops such tokens.
|
|
142
|
+
"""
|
|
143
|
+
collapsed = _SEPARATORS_RE.sub(" ", text.lower())
|
|
144
|
+
cleaned = collapsed.strip(_OUTER_PUNCT).strip()
|
|
145
|
+
if len(cleaned) < _MIN_NAME_LENGTH:
|
|
146
|
+
return None
|
|
147
|
+
return cleaned
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _flip_last_first(text: str) -> str:
|
|
151
|
+
"""Flip a single ``Last, First`` token to ``First Last``.
|
|
152
|
+
|
|
153
|
+
Only acts when a comma is present. Any trailing ``(Org…`` fragment on the
|
|
154
|
+
``First`` side is dropped. Degenerate inputs (empty half after the flip)
|
|
155
|
+
are returned unchanged for the downstream canonicalizer to handle.
|
|
156
|
+
"""
|
|
157
|
+
if "," not in text:
|
|
158
|
+
return text
|
|
159
|
+
last, _, rest = text.partition(",")
|
|
160
|
+
first = _ORG_PAREN_RE.sub("", rest.split(",")[0]).strip()
|
|
161
|
+
last = last.strip()
|
|
162
|
+
if not first or not last:
|
|
163
|
+
return text
|
|
164
|
+
return f"{first} {last}"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _clean_name_to_canonical(text: str) -> str | None:
|
|
168
|
+
"""Apply the full name-cleaning pipeline, returning a canonical key or None.
|
|
169
|
+
|
|
170
|
+
Order matters: strip outer junk first (so a leading quote doesn't hide the
|
|
171
|
+
``via`` delimiter), then drop mailing-list decoration, flip ``Last, First``,
|
|
172
|
+
drop any surviving ``(Org`` fragment, and finally canonicalize.
|
|
173
|
+
"""
|
|
174
|
+
text = text.strip(_OUTER_JUNK)
|
|
175
|
+
text = _VIA_RE.sub("", text)
|
|
176
|
+
text = _flip_last_first(text)
|
|
177
|
+
text = _ORG_PAREN_RE.sub("", text)
|
|
178
|
+
return _canonical_from_raw(text)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def normalize_person_name(raw: str) -> NormalizedName | None:
|
|
182
|
+
"""Normalize a raw participant identifier into a :class:`NormalizedName`.
|
|
183
|
+
|
|
184
|
+
Handles every Phase-1 pattern:
|
|
185
|
+
|
|
186
|
+
1. Mailing-list ``via X`` decoration is stripped.
|
|
187
|
+
2. ``Last, First (Org`` is flipped to ``First Last`` and the org fragment
|
|
188
|
+
dropped.
|
|
189
|
+
3. ``. _`` and whitespace runs collapse so handle-style and spaced forms
|
|
190
|
+
share a canonical key.
|
|
191
|
+
4. An email-shaped input is humanized from its local part only (never the
|
|
192
|
+
full ``local@domain``).
|
|
193
|
+
|
|
194
|
+
Returns ``None`` when nothing usable survives (empty / sub-2-char tokens).
|
|
195
|
+
"""
|
|
196
|
+
text = (raw or "").strip()
|
|
197
|
+
if not text:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
# Email-shaped input: derive the name from the local part only.
|
|
201
|
+
if is_email_like(text):
|
|
202
|
+
local = text.split("@", 1)[0]
|
|
203
|
+
canonical = _canonical_from_raw(local)
|
|
204
|
+
else:
|
|
205
|
+
canonical = _clean_name_to_canonical(text)
|
|
206
|
+
|
|
207
|
+
if canonical is None:
|
|
208
|
+
return None
|
|
209
|
+
return NormalizedName(canonical, humanize_person_name(canonical))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _local_is_automated(local: str) -> bool:
|
|
213
|
+
"""True iff the email local part is an automated marker by word boundary.
|
|
214
|
+
|
|
215
|
+
Boundary semantics (NOT substring): the local part must EQUAL a marker, or
|
|
216
|
+
start with ``marker + sep``, or end with ``sep + marker`` for some ``sep``
|
|
217
|
+
in :data:`_MARKER_SEPS`. This catches ``no-reply`` / ``mailer-daemon`` /
|
|
218
|
+
``acme.noreply`` / ``bounce`` while leaving ``nmailer`` and ``jbounce``
|
|
219
|
+
(real names that merely contain a marker substring) untouched.
|
|
220
|
+
"""
|
|
221
|
+
for marker in _AUTOMATED_MARKERS:
|
|
222
|
+
if local == marker:
|
|
223
|
+
return True
|
|
224
|
+
for sep in _MARKER_SEPS:
|
|
225
|
+
if local.startswith(marker + sep) or local.endswith(sep + marker):
|
|
226
|
+
return True
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def is_automated_sender(
|
|
231
|
+
email: str,
|
|
232
|
+
*,
|
|
233
|
+
denylist: frozenset[str] = frozenset(),
|
|
234
|
+
) -> bool:
|
|
235
|
+
"""Is this ``email`` an automated / non-human sender?
|
|
236
|
+
|
|
237
|
+
Generic, corpus-agnostic rules (in order):
|
|
238
|
+
|
|
239
|
+
1. Any ``denylist`` entry (a substring or full address) found in the
|
|
240
|
+
lowercased email — the configurable ``BRAIN_GRAPH_SENDER_DENYLIST``
|
|
241
|
+
escape hatch for org / bulk senders.
|
|
242
|
+
2. The email's LOCAL PART (lowercased, ``+tag`` stripped) matches a known
|
|
243
|
+
automated marker (``no-reply`` / ``mailer-daemon`` / ``bounce`` /
|
|
244
|
+
``notifications`` / ``postmaster`` / …) by word boundary — see
|
|
245
|
+
:func:`_local_is_automated`.
|
|
246
|
+
|
|
247
|
+
The DOMAIN is never inspected by rule 2 (it would drop real humans like
|
|
248
|
+
``john@mailer-corp.example.com`` / ``jane@notifications.acme.com``), and there is no
|
|
249
|
+
display-name heuristic (it false-positived ``bob@bob.com``). A
|
|
250
|
+
non-email-shaped value only matches the denylist; an empty value is never
|
|
251
|
+
automated.
|
|
252
|
+
"""
|
|
253
|
+
addr = email.strip().lower()
|
|
254
|
+
if not addr:
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
for entry in denylist:
|
|
258
|
+
token = entry.strip().lower()
|
|
259
|
+
if token and token in addr:
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
if not is_email_like(addr):
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
# Local part only — strip any ``+tag`` suffix before the boundary match.
|
|
266
|
+
local = addr.split("@", 1)[0].split("+", 1)[0]
|
|
267
|
+
return _local_is_automated(local)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _owner_variants(key: str) -> set[str]:
|
|
271
|
+
"""Derive the owner-key variants implied by a single owner identifier.
|
|
272
|
+
|
|
273
|
+
Adds the raw key, its canonical form, the first-name-only canonical token,
|
|
274
|
+
and (for emails) the bare local part — so an owner listed only as
|
|
275
|
+
``pat.owner@example.com`` or ``Pat Owner`` also matches the leaked
|
|
276
|
+
first-name-only / local-part forms ``pat``.
|
|
277
|
+
"""
|
|
278
|
+
variants: set[str] = set()
|
|
279
|
+
k = key.strip().lower()
|
|
280
|
+
if not k:
|
|
281
|
+
return variants
|
|
282
|
+
variants.add(k)
|
|
283
|
+
|
|
284
|
+
if "@" in k and is_email_like(k):
|
|
285
|
+
local = k.split("@", 1)[0]
|
|
286
|
+
variants.add(local)
|
|
287
|
+
local_first = _SEPARATORS_RE.sub(" ", local).strip().split(" ", 1)[0]
|
|
288
|
+
if len(local_first) >= _MIN_NAME_LENGTH:
|
|
289
|
+
variants.add(local_first)
|
|
290
|
+
|
|
291
|
+
normalized = normalize_person_name(k)
|
|
292
|
+
if normalized is not None:
|
|
293
|
+
canonical = normalized.canonical_key
|
|
294
|
+
variants.add(canonical)
|
|
295
|
+
first = canonical.split(" ", 1)[0]
|
|
296
|
+
if len(first) >= _MIN_NAME_LENGTH:
|
|
297
|
+
variants.add(first)
|
|
298
|
+
|
|
299
|
+
return variants
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def expand_owner_keys(owner_keys: Iterable[str]) -> frozenset[str]:
|
|
303
|
+
"""Widen an owner-key set with first-name-only + email-local-part variants.
|
|
304
|
+
|
|
305
|
+
The corpus owner must never surface as a person, even under a partial form
|
|
306
|
+
(just their first name, or the local part of their email). This expands the
|
|
307
|
+
configured ``BRAIN_OWNER_PARTICIPANTS`` set so the People Hub and graph
|
|
308
|
+
reconcile owner filters catch those leaks. Pure + deterministic; every
|
|
309
|
+
entry is lowercased.
|
|
310
|
+
"""
|
|
311
|
+
expanded: set[str] = set()
|
|
312
|
+
for key in owner_keys:
|
|
313
|
+
expanded |= _owner_variants(key)
|
|
314
|
+
return frozenset(expanded)
|