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