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,934 @@
|
|
|
1
|
+
"""Aggregate per-person doc rosters and emit the People Hub wiki pages.
|
|
2
|
+
|
|
3
|
+
Two layers, side-by-side in this module so the page renderer can never
|
|
4
|
+
drift from the data shape it's rendering.
|
|
5
|
+
|
|
6
|
+
Phase A — aggregation (pure read, no I/O):
|
|
7
|
+
:func:`aggregate_people` SELECTs from ``directory_entries`` and
|
|
8
|
+
``documents`` and returns a sorted list of :class:`PersonRecord`,
|
|
9
|
+
one per emittable page.
|
|
10
|
+
|
|
11
|
+
Phase B — page emission (pure rendering + atomic-per-file writeback):
|
|
12
|
+
:func:`render_person_md` and :func:`render_index_md` are pure
|
|
13
|
+
string producers — same input always yields byte-identical output,
|
|
14
|
+
which is what makes :func:`emit_people_pages` idempotent.
|
|
15
|
+
:func:`emit_people_pages` is the I/O-side orchestrator: it
|
|
16
|
+
aggregates, computes the target file set, deletes any
|
|
17
|
+
``<vault>/people/*.md`` no longer in the set, and writes each
|
|
18
|
+
page atomically through :func:`brain.vault._atomic.atomic_write_text`.
|
|
19
|
+
|
|
20
|
+
The Phase A aggregation mirrors the participant-key derivation used
|
|
21
|
+
by the metadata linker (see ``brain.vault.derived_links.pass_runner
|
|
22
|
+
._build_snapshot``) so a person's roster on the hub is consistent
|
|
23
|
+
with the derived edges that link their docs to each other. The only
|
|
24
|
+
intentional divergence: this module does not bridge name↔email at
|
|
25
|
+
extraction time. Both keys are emitted per doc, and
|
|
26
|
+
``directory_entries`` is consulted at *resolution* time to map each
|
|
27
|
+
key to a canonical display_name. The end result is identical because
|
|
28
|
+
every name and email a key could resolve to also lives in
|
|
29
|
+
``directory_entries``.
|
|
30
|
+
"""
|
|
31
|
+
import logging
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import datetime
|
|
34
|
+
from email.utils import parsedate_to_datetime
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
import psycopg
|
|
39
|
+
|
|
40
|
+
from brain.vault._atomic import atomic_write_text
|
|
41
|
+
from brain.vault.derived_links.directory import _score_directory_rows
|
|
42
|
+
from brain.vault.derived_links.participants import extract_gmail_addresses
|
|
43
|
+
from brain.vault.frontmatter import dump_frontmatter
|
|
44
|
+
from brain.vault.paths import safe_wikilink_alias, strip_md_extension
|
|
45
|
+
from brain.vault.slug import slugify
|
|
46
|
+
from brain.wiki._person_name import (
|
|
47
|
+
expand_owner_keys,
|
|
48
|
+
humanize_person_name,
|
|
49
|
+
is_automated_sender,
|
|
50
|
+
normalize_person_name,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"DocRef",
|
|
57
|
+
"EmitReport",
|
|
58
|
+
"PersonRecord",
|
|
59
|
+
"aggregate_people",
|
|
60
|
+
"emit_people_pages",
|
|
61
|
+
"humanize_display_name",
|
|
62
|
+
"render_index_md",
|
|
63
|
+
"render_person_md",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class DocRef:
|
|
69
|
+
"""One document associated with a person — used to render their roster.
|
|
70
|
+
|
|
71
|
+
``vault_target`` is the doc's ``vault_path`` in POSIX form with the
|
|
72
|
+
trailing ``.md`` stripped (e.g.
|
|
73
|
+
``_ingested/krisp/2026-05-06-3508c63e-ai-cos-jam-session``). Phase B
|
|
74
|
+
feeds this into a wiki-link of the form ``[[<vault_target>|<title>]]``
|
|
75
|
+
so :func:`brain.vault.link_rewrite.rewrite_wiki_links` resolves it
|
|
76
|
+
in one shot via ``_resolve_by_vault_path`` (matching the recent-rail
|
|
77
|
+
homepage precedent in :func:`brain.wiki.build_homepage._render_bullets`).
|
|
78
|
+
|
|
79
|
+
``None`` for docs that have not been mirrored to the vault yet (rare
|
|
80
|
+
— should resolve on the next ``brain vault export`` / sync).
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
document_id: str
|
|
84
|
+
title: str
|
|
85
|
+
source_kind: str
|
|
86
|
+
date: datetime | None
|
|
87
|
+
vault_target: str | None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class PersonRecord:
|
|
92
|
+
"""Aggregated roster for one person, ready for page emission.
|
|
93
|
+
|
|
94
|
+
``display_name`` is the lowercased / normalized form stored in
|
|
95
|
+
``directory_entries`` (see :func:`brain.vault.derived_links.participants
|
|
96
|
+
.normalize_participant`). Phase B's renderer is responsible for turning
|
|
97
|
+
it into a presentable form (e.g. title-casing) — keeping the canonical
|
|
98
|
+
matching key here means the data layer round-trips losslessly.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
slug: str
|
|
102
|
+
display_name: str
|
|
103
|
+
primary_email: str
|
|
104
|
+
all_emails: list[str]
|
|
105
|
+
docs: list[DocRef]
|
|
106
|
+
in_people_yml: bool
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---- Internal helpers -------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class _DirectoryIndex:
|
|
114
|
+
"""Pre-computed views over ``directory_entries`` used during aggregation.
|
|
115
|
+
|
|
116
|
+
Built once per :func:`aggregate_people` call (one SELECT) so per-doc
|
|
117
|
+
resolution is dictionary lookups rather than repeated SQL.
|
|
118
|
+
|
|
119
|
+
Every view is keyed on the **canonical key** produced by
|
|
120
|
+
:func:`brain.wiki._person_name.normalize_person_name` — the lowercase,
|
|
121
|
+
separator-collapsed merge identity. Two directory rows whose raw
|
|
122
|
+
``display_name`` differs only by separators / mailing-list decoration /
|
|
123
|
+
``Last, First`` ordering (``Jane.Doe`` vs ``Jane Doe``) therefore collapse
|
|
124
|
+
to one canonical person here, merging their emails and curated flags.
|
|
125
|
+
Automated / org senders (no-reply, notifications, …) are dropped at build
|
|
126
|
+
time so they never become a person.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
# Set of every canonical key with at least one surviving (name, email)
|
|
130
|
+
# pair. Empty-name rows (``display_name=''``) and automated senders are
|
|
131
|
+
# excluded.
|
|
132
|
+
known_keys: set[str]
|
|
133
|
+
|
|
134
|
+
# Per canonical key, the email with the highest sum-of-occurrences across
|
|
135
|
+
# all sources / merged raw names. ``people_yml`` rows win unconditionally
|
|
136
|
+
# (mirrors the precedence in :meth:`DirectoryStore.resolve_name_to_email`).
|
|
137
|
+
primary_email_by_key: dict[str, str]
|
|
138
|
+
|
|
139
|
+
# Per canonical key, the sorted distinct list of every email seen across
|
|
140
|
+
# sources / merged raw names.
|
|
141
|
+
emails_by_key: dict[str, list[str]]
|
|
142
|
+
|
|
143
|
+
# Reverse index: email → canonical key. Used to resolve email participant
|
|
144
|
+
# keys to a person. ``people_yml`` wins; otherwise the canonical key with
|
|
145
|
+
# the highest summed occurrence_count for that email.
|
|
146
|
+
canonical_key_by_email: dict[str, str]
|
|
147
|
+
|
|
148
|
+
# Per canonical key: True iff at least one underlying ``directory_entries``
|
|
149
|
+
# row has ``source='people_yml'``. Drives the curated badge on the page and
|
|
150
|
+
# the always-emit override below the doc-count threshold.
|
|
151
|
+
in_people_yml_by_key: dict[str, bool]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _build_directory_index(
|
|
155
|
+
conn: psycopg.Connection[Any],
|
|
156
|
+
*,
|
|
157
|
+
sender_denylist: frozenset[str] = frozenset(),
|
|
158
|
+
) -> _DirectoryIndex:
|
|
159
|
+
"""Pull ``directory_entries`` once and project the canonical-key indexes the
|
|
160
|
+
resolver needs. See :class:`_DirectoryIndex` for what each index represents.
|
|
161
|
+
|
|
162
|
+
Each raw ``display_name`` is run through
|
|
163
|
+
:func:`brain.wiki._person_name.normalize_person_name` to derive its
|
|
164
|
+
canonical key, and each ``(display_name, email)`` row is dropped when
|
|
165
|
+
:func:`brain.wiki._person_name.is_automated_sender` flags it as a non-human
|
|
166
|
+
/ org sender. ``sender_denylist`` (``BRAIN_GRAPH_SENDER_DENYLIST``) adds
|
|
167
|
+
corpus-specific entries to the always-on generic heuristic.
|
|
168
|
+
"""
|
|
169
|
+
rows = conn.execute(
|
|
170
|
+
"SELECT display_name, email, source, occurrence_count "
|
|
171
|
+
"FROM directory_entries "
|
|
172
|
+
"WHERE display_name <> ''"
|
|
173
|
+
).fetchall()
|
|
174
|
+
|
|
175
|
+
# (canonical_key, email) → summed count across sources / merged raw names,
|
|
176
|
+
# plus a flag tracking whether any underlying row is people_yml.
|
|
177
|
+
counts: dict[tuple[str, str], int] = {}
|
|
178
|
+
is_people_yml: dict[tuple[str, str], bool] = {}
|
|
179
|
+
|
|
180
|
+
for name, email, source, count in rows:
|
|
181
|
+
# Drop automated / non-human senders (no-reply, notifications, mailer,
|
|
182
|
+
# …) before they can ever become a person. Matches the email's local
|
|
183
|
+
# part only — the directory ``display_name`` is normalized separately.
|
|
184
|
+
if is_automated_sender(str(email), denylist=sender_denylist):
|
|
185
|
+
continue
|
|
186
|
+
normalized = normalize_person_name(str(name))
|
|
187
|
+
if normalized is None:
|
|
188
|
+
continue
|
|
189
|
+
key = (normalized.canonical_key, str(email))
|
|
190
|
+
counts[key] = counts.get(key, 0) + int(count)
|
|
191
|
+
if source == "people_yml":
|
|
192
|
+
is_people_yml[key] = True
|
|
193
|
+
|
|
194
|
+
known_keys: set[str] = set()
|
|
195
|
+
key_emails: dict[str, list[tuple[str, int, bool]]] = {}
|
|
196
|
+
email_keys: dict[str, list[tuple[str, int, bool]]] = {}
|
|
197
|
+
|
|
198
|
+
for (canonical_key, email), total in counts.items():
|
|
199
|
+
people_yml = is_people_yml.get((canonical_key, email), False)
|
|
200
|
+
known_keys.add(canonical_key)
|
|
201
|
+
key_emails.setdefault(canonical_key, []).append((email, total, people_yml))
|
|
202
|
+
email_keys.setdefault(email, []).append((canonical_key, total, people_yml))
|
|
203
|
+
|
|
204
|
+
primary_email_by_key: dict[str, str] = {}
|
|
205
|
+
emails_by_key: dict[str, list[str]] = {}
|
|
206
|
+
in_people_yml_by_key: dict[str, bool] = {}
|
|
207
|
+
|
|
208
|
+
# Both per-key primary-email picks and per-email canonical-key picks share
|
|
209
|
+
# the same precedence rules as ``DirectoryStore.resolve_name_to_email``
|
|
210
|
+
# (people_yml wins → highest summed count → alpha tiebreak); reuse that
|
|
211
|
+
# module's helper so the rules cannot drift. ``skip_ambiguous=False``
|
|
212
|
+
# because every person needs *some* primary email and every email needs
|
|
213
|
+
# *some* canonical key — alpha tiebreak is the deterministic fallback.
|
|
214
|
+
for canonical_key, items in key_emails.items():
|
|
215
|
+
emails_by_key[canonical_key] = sorted({email for email, _, _ in items})
|
|
216
|
+
in_people_yml_by_key[canonical_key] = any(p for _, _, p in items)
|
|
217
|
+
winner = _score_directory_rows(items, skip_ambiguous=False)
|
|
218
|
+
# ``items`` is non-empty by construction (the outer loop only adds a
|
|
219
|
+
# key when at least one (key, email) row exists); ``_score_directory_rows``
|
|
220
|
+
# therefore returns a non-None winner. ``assert`` keeps mypy honest.
|
|
221
|
+
assert winner is not None
|
|
222
|
+
primary_email_by_key[canonical_key] = winner
|
|
223
|
+
|
|
224
|
+
canonical_key_by_email: dict[str, str] = {}
|
|
225
|
+
for email, candidates in email_keys.items():
|
|
226
|
+
winner = _score_directory_rows(candidates, skip_ambiguous=False)
|
|
227
|
+
assert winner is not None
|
|
228
|
+
canonical_key_by_email[email] = winner
|
|
229
|
+
|
|
230
|
+
return _DirectoryIndex(
|
|
231
|
+
known_keys=known_keys,
|
|
232
|
+
primary_email_by_key=primary_email_by_key,
|
|
233
|
+
emails_by_key=emails_by_key,
|
|
234
|
+
canonical_key_by_email=canonical_key_by_email,
|
|
235
|
+
in_people_yml_by_key=in_people_yml_by_key,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _doc_participant_keys(
|
|
240
|
+
*, source_kind: str, metadata: dict[str, Any]
|
|
241
|
+
) -> set[str]:
|
|
242
|
+
"""Extract the raw participant key set for one doc.
|
|
243
|
+
|
|
244
|
+
Mirrors :func:`brain.vault.derived_links.pass_runner._build_snapshot`'s
|
|
245
|
+
extraction step but emits *both* the email and the display name (when
|
|
246
|
+
present) for each Gmail header pair, leaving directory bridging to the
|
|
247
|
+
resolution step. Result is identical to the pass-runner's output once
|
|
248
|
+
resolution runs.
|
|
249
|
+
|
|
250
|
+
For Krisp, reads ``metadata['_participant_keys']`` (a sorted list set at
|
|
251
|
+
ingest time by :func:`extract_krisp_speakers`). Strings are returned
|
|
252
|
+
as-is; bridging from name to email happens during resolution.
|
|
253
|
+
"""
|
|
254
|
+
keys: set[str] = set()
|
|
255
|
+
if source_kind == "gmail":
|
|
256
|
+
for display, email in extract_gmail_addresses(metadata):
|
|
257
|
+
keys.add(email)
|
|
258
|
+
if display:
|
|
259
|
+
keys.add(display)
|
|
260
|
+
elif source_kind == "krisp":
|
|
261
|
+
raw = metadata.get("_participant_keys")
|
|
262
|
+
if isinstance(raw, list):
|
|
263
|
+
for entry in raw:
|
|
264
|
+
if isinstance(entry, str) and entry.strip():
|
|
265
|
+
keys.add(entry.strip())
|
|
266
|
+
return keys
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _resolve_key_to_person(
|
|
270
|
+
key: str,
|
|
271
|
+
*,
|
|
272
|
+
directory: _DirectoryIndex,
|
|
273
|
+
) -> str | None:
|
|
274
|
+
"""Map a participant key to a canonical key, or None.
|
|
275
|
+
|
|
276
|
+
``key`` is either an email (``fixture@example.com``) or a raw display name.
|
|
277
|
+
Emails resolve via ``canonical_key_by_email``; an email with no directory
|
|
278
|
+
match returns ``None`` (long-tail one-off senders are dropped, not
|
|
279
|
+
surfaced). Names are normalized via
|
|
280
|
+
:func:`brain.wiki._person_name.normalize_person_name` and resolve only when
|
|
281
|
+
their canonical key appears in ``known_keys`` — so a handle-style key like
|
|
282
|
+
``Jane.Doe`` matches the directory's ``jane doe`` entry, while a one-off
|
|
283
|
+
Krisp speaker label with no directory entry is silently dropped.
|
|
284
|
+
"""
|
|
285
|
+
stripped = key.strip()
|
|
286
|
+
if "@" in stripped:
|
|
287
|
+
return directory.canonical_key_by_email.get(stripped.lower())
|
|
288
|
+
normalized = normalize_person_name(stripped)
|
|
289
|
+
if normalized is None:
|
|
290
|
+
return None
|
|
291
|
+
if normalized.canonical_key in directory.known_keys:
|
|
292
|
+
return normalized.canonical_key
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _doc_date(
|
|
297
|
+
*,
|
|
298
|
+
source_kind: str,
|
|
299
|
+
metadata: dict[str, Any],
|
|
300
|
+
sent_at: datetime | None,
|
|
301
|
+
) -> datetime | None:
|
|
302
|
+
"""Pick the best timestamp for a doc. ``sent_at`` (Gmail typed column)
|
|
303
|
+
wins; otherwise parse ``metadata['date']`` per source convention.
|
|
304
|
+
|
|
305
|
+
Returns ``None`` for docs we can't date — the renderer sorts these last.
|
|
306
|
+
"""
|
|
307
|
+
if sent_at is not None:
|
|
308
|
+
return sent_at
|
|
309
|
+
raw = metadata.get("date")
|
|
310
|
+
if not isinstance(raw, str):
|
|
311
|
+
return None
|
|
312
|
+
text = raw.strip()
|
|
313
|
+
if not text:
|
|
314
|
+
return None
|
|
315
|
+
if source_kind == "krisp":
|
|
316
|
+
# Krisp ingest passes ISO date strings (YYYY-MM-DD or YYYY-MM-DDTHH:MM).
|
|
317
|
+
# Slice the first 10 chars so the strict ``date.fromisoformat`` grammar
|
|
318
|
+
# still accepts the time-suffix variant.
|
|
319
|
+
try:
|
|
320
|
+
return datetime.fromisoformat(text[:10])
|
|
321
|
+
except ValueError:
|
|
322
|
+
logger.debug("krisp date parse failed: %r", text)
|
|
323
|
+
return None
|
|
324
|
+
# Gmail (and any RFC-5322 source).
|
|
325
|
+
try:
|
|
326
|
+
parsed = parsedate_to_datetime(text)
|
|
327
|
+
except (TypeError, ValueError):
|
|
328
|
+
logger.debug("gmail date parse failed: %r", text)
|
|
329
|
+
return None
|
|
330
|
+
return parsed
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _vault_target(vault_path: str | None) -> str | None:
|
|
334
|
+
"""Project ``vault_path`` to the inner of a wiki-link, sans ``.md``.
|
|
335
|
+
|
|
336
|
+
Phase B feeds the result into ``[[<vault_target>|<title>]]``. The full
|
|
337
|
+
POSIX-form path is preserved (rather than just the basename) so
|
|
338
|
+
:func:`brain.vault.resolver._resolve_by_vault_path` matches the link
|
|
339
|
+
against ``documents.vault_path`` during ``brain vault sync`` —
|
|
340
|
+
bare-basename inner text would only resolve in Quartz, leaving every
|
|
341
|
+
person-page wiki-link recorded in ``unresolved_links`` on the brain
|
|
342
|
+
side. Mirrors the canonical wiki-link target shape emitted by
|
|
343
|
+
:func:`brain.wiki.build_homepage._render_bullets` for the recent rail.
|
|
344
|
+
|
|
345
|
+
Delegates the ``.md``-stripping + POSIX normalization to
|
|
346
|
+
:func:`brain.vault.paths.strip_md_extension` so the three vault
|
|
347
|
+
renderers (homepage, people, daily-index) cannot drift on the
|
|
348
|
+
canonical link-target shape.
|
|
349
|
+
|
|
350
|
+
Returns ``None`` for docs without a ``vault_path`` (rare — Krisp/Gmail
|
|
351
|
+
rows that haven't been mirrored to the vault yet). The renderer
|
|
352
|
+
surfaces those as plain-text titles with no wiki-link.
|
|
353
|
+
"""
|
|
354
|
+
if not vault_path:
|
|
355
|
+
return None
|
|
356
|
+
return strip_md_extension(vault_path) or None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _sort_docs(docs: list[DocRef]) -> list[DocRef]:
|
|
360
|
+
"""Sort by date desc, then title asc. Docs with ``date=None`` sort last."""
|
|
361
|
+
def key(d: DocRef) -> tuple[int, float, str]:
|
|
362
|
+
if d.date is None:
|
|
363
|
+
return (1, 0.0, d.title)
|
|
364
|
+
# Negate timestamp so ``sorted`` (asc) yields date-desc.
|
|
365
|
+
return (0, -d.date.timestamp(), d.title)
|
|
366
|
+
|
|
367
|
+
return sorted(docs, key=key)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _assign_slugs(records: list[PersonRecord]) -> list[PersonRecord]:
|
|
371
|
+
"""Resolve slug collisions in a deterministic alpha-by-display_name pass.
|
|
372
|
+
|
|
373
|
+
First person at a given base slug keeps it; subsequent collisions get
|
|
374
|
+
``-2``, ``-3``, … per the plan. Mutates each record's ``slug`` in place
|
|
375
|
+
and returns the same list for caller convenience.
|
|
376
|
+
"""
|
|
377
|
+
counts: dict[str, int] = {}
|
|
378
|
+
for rec in sorted(records, key=lambda r: r.display_name):
|
|
379
|
+
base = slugify(rec.display_name)
|
|
380
|
+
used = counts.get(base, 0)
|
|
381
|
+
rec.slug = base if used == 0 else f"{base}-{used + 1}"
|
|
382
|
+
counts[base] = used + 1
|
|
383
|
+
return records
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---- Public API -------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def aggregate_people(
|
|
390
|
+
conn: psycopg.Connection[Any],
|
|
391
|
+
*,
|
|
392
|
+
owner_keys: frozenset[str],
|
|
393
|
+
min_docs: int,
|
|
394
|
+
sender_denylist: frozenset[str] = frozenset(),
|
|
395
|
+
) -> list[PersonRecord]:
|
|
396
|
+
"""Aggregate per-person doc rosters from ``directory_entries`` + ``documents``.
|
|
397
|
+
|
|
398
|
+
The returned list is sorted alphabetically by ``display_name`` (the
|
|
399
|
+
lowercase canonical key) and ready for page emission (slugs assigned,
|
|
400
|
+
collisions resolved).
|
|
401
|
+
|
|
402
|
+
Arguments:
|
|
403
|
+
conn: Live Postgres connection. Read-only — no writes performed.
|
|
404
|
+
owner_keys: Identifiers (emails AND/OR display names) that count as the
|
|
405
|
+
corpus owner. Expanded via
|
|
406
|
+
:func:`brain.wiki._person_name.expand_owner_keys` to also cover
|
|
407
|
+
first-name-only and email-local-part variants, then stripped from
|
|
408
|
+
every doc's participant key set so the owner doesn't appear in
|
|
409
|
+
*every* doc list. Persons whose canonical identity (canonical key
|
|
410
|
+
OR primary_email) sits inside the expanded set are dropped from the
|
|
411
|
+
result — same semantics as the derived-link owner filter in
|
|
412
|
+
:func:`brain.vault.derived_links.pass_runner._build_snapshot`.
|
|
413
|
+
min_docs: Threshold for non-curated emission. Persons with strictly
|
|
414
|
+
fewer than ``min_docs`` docs and no ``_people.yml`` entry are
|
|
415
|
+
dropped. Curated persons (``in_people_yml=True``) always emit
|
|
416
|
+
regardless. Required positional — the default lives one layer up
|
|
417
|
+
in :class:`brain.config.Config` (Phase C).
|
|
418
|
+
sender_denylist: Extra ``BRAIN_GRAPH_SENDER_DENYLIST`` substrings /
|
|
419
|
+
addresses forwarded to the automated-sender filter on top of the
|
|
420
|
+
always-on generic heuristic. Default empty.
|
|
421
|
+
|
|
422
|
+
Raises:
|
|
423
|
+
ValueError: ``min_docs`` is negative.
|
|
424
|
+
"""
|
|
425
|
+
if min_docs < 0:
|
|
426
|
+
raise ValueError(f"min_docs must be >= 0 (got {min_docs!r})")
|
|
427
|
+
|
|
428
|
+
# Expand the owner set with first-name-only / email-local-part variants so
|
|
429
|
+
# the owner can never leak in as a person under a partial form. The result
|
|
430
|
+
# is already lowercased, so downstream comparisons normalize the needle.
|
|
431
|
+
owner_keys = expand_owner_keys(owner_keys)
|
|
432
|
+
|
|
433
|
+
directory = _build_directory_index(conn, sender_denylist=sender_denylist)
|
|
434
|
+
|
|
435
|
+
# Pull every gmail/krisp document. Drafts (``draft=TRUE``) are excluded
|
|
436
|
+
# — they're already filtered from the rendered wiki, so a People Hub page
|
|
437
|
+
# listing a draft would surface a doc the user doesn't see anywhere else.
|
|
438
|
+
rows = conn.execute(
|
|
439
|
+
"""
|
|
440
|
+
SELECT d.id::text, d.title, s.kind, d.metadata, d.vault_path, d.sent_at
|
|
441
|
+
FROM documents d
|
|
442
|
+
JOIN sources s ON s.id = d.source_id
|
|
443
|
+
WHERE s.kind IN ('gmail', 'krisp')
|
|
444
|
+
AND d.draft = FALSE
|
|
445
|
+
"""
|
|
446
|
+
).fetchall()
|
|
447
|
+
|
|
448
|
+
# canonical_key → list of DocRefs (deduped within a doc — multiple keys for
|
|
449
|
+
# the same doc resolving to one person count once).
|
|
450
|
+
person_docs: dict[str, list[DocRef]] = {}
|
|
451
|
+
|
|
452
|
+
for doc_id, title, source_kind, metadata, vault_path, sent_at in rows:
|
|
453
|
+
meta: dict[str, Any] = dict(metadata) if metadata else {}
|
|
454
|
+
keys = _doc_participant_keys(source_kind=source_kind, metadata=meta)
|
|
455
|
+
# Strip owner keys before resolution so the owner contributes nothing
|
|
456
|
+
# to anyone's roster — including their own (no /people/pat-owner).
|
|
457
|
+
keys = {k for k in keys if k.strip().lower() not in owner_keys}
|
|
458
|
+
|
|
459
|
+
persons_for_doc: set[str] = set()
|
|
460
|
+
for key in keys:
|
|
461
|
+
person = _resolve_key_to_person(key, directory=directory)
|
|
462
|
+
# Belt-and-suspenders: a key that survived the raw strip but
|
|
463
|
+
# resolves to an owner canonical (e.g. a first-name-only leak) is
|
|
464
|
+
# dropped here too.
|
|
465
|
+
if person is not None and person not in owner_keys:
|
|
466
|
+
persons_for_doc.add(person)
|
|
467
|
+
|
|
468
|
+
if not persons_for_doc:
|
|
469
|
+
continue
|
|
470
|
+
|
|
471
|
+
date = _doc_date(source_kind=source_kind, metadata=meta, sent_at=sent_at)
|
|
472
|
+
ref = DocRef(
|
|
473
|
+
document_id=doc_id,
|
|
474
|
+
title=title,
|
|
475
|
+
source_kind=source_kind,
|
|
476
|
+
date=date,
|
|
477
|
+
vault_target=_vault_target(vault_path),
|
|
478
|
+
)
|
|
479
|
+
for person in persons_for_doc:
|
|
480
|
+
person_docs.setdefault(person, []).append(ref)
|
|
481
|
+
|
|
482
|
+
# Build records for every canonical key in directory_entries (so curated
|
|
483
|
+
# 0-doc entries still emit). Skip owner-identified keys entirely — strict
|
|
484
|
+
# interpretation per Phase A.2: a person whose primary identifier is an
|
|
485
|
+
# owner key never gets a page.
|
|
486
|
+
records: list[PersonRecord] = []
|
|
487
|
+
for canonical_key in directory.known_keys:
|
|
488
|
+
if canonical_key in owner_keys:
|
|
489
|
+
continue
|
|
490
|
+
primary = directory.primary_email_by_key[canonical_key]
|
|
491
|
+
if primary.lower() in owner_keys:
|
|
492
|
+
# ``primary_email`` is always a member of ``all_emails`` (it's
|
|
493
|
+
# picked from that set), so this single check is equivalent to
|
|
494
|
+
# the "every email is an owner key" rule when the user has
|
|
495
|
+
# listed an alias as an owner. No need for a second pass over
|
|
496
|
+
# ``all_emails``.
|
|
497
|
+
continue
|
|
498
|
+
all_emails = directory.emails_by_key[canonical_key]
|
|
499
|
+
in_yml = directory.in_people_yml_by_key.get(canonical_key, False)
|
|
500
|
+
docs = _sort_docs(person_docs.get(canonical_key, []))
|
|
501
|
+
if not in_yml and len(docs) < min_docs:
|
|
502
|
+
continue
|
|
503
|
+
records.append(
|
|
504
|
+
PersonRecord(
|
|
505
|
+
slug="", # filled in by _assign_slugs
|
|
506
|
+
display_name=canonical_key,
|
|
507
|
+
primary_email=primary,
|
|
508
|
+
all_emails=all_emails,
|
|
509
|
+
docs=docs,
|
|
510
|
+
in_people_yml=in_yml,
|
|
511
|
+
)
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
_assign_slugs(records)
|
|
515
|
+
records.sort(key=lambda r: r.display_name)
|
|
516
|
+
return records
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# ---- Phase B — page rendering -----------------------------------------------
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# Frontmatter fields emitted on every people page. ``kind: people`` is the
|
|
523
|
+
# marker Phase C's Quartz overlay uses for distinct styling. ``slug`` lets
|
|
524
|
+
# Quartz resolve the canonical URL even when filename collisions force a
|
|
525
|
+
# numeric suffix on the on-disk path.
|
|
526
|
+
_PERSON_KIND_FRONTMATTER: str = "people"
|
|
527
|
+
|
|
528
|
+
# Frontmatter ``kind`` for the index page itself. Distinct from ``people``
|
|
529
|
+
# so a future Quartz override can style "directory of directories" rows
|
|
530
|
+
# differently from individual roster pages.
|
|
531
|
+
_PEOPLE_INDEX_KIND_FRONTMATTER: str = "people-index"
|
|
532
|
+
|
|
533
|
+
# Per-page directory under the vault. Kept as a module-level constant so
|
|
534
|
+
# the emitter, the cleanup pass, and tests refer to the same name.
|
|
535
|
+
_PEOPLE_DIR_NAME: str = "people"
|
|
536
|
+
|
|
537
|
+
# Plain-text placeholder for curated persons with zero matched documents.
|
|
538
|
+
# Phase A allows this case (a ``_people.yml`` entry the user added before
|
|
539
|
+
# any Krisp/Gmail data accumulated) — the page must still render so the
|
|
540
|
+
# user sees their entry made it into the directory.
|
|
541
|
+
_NO_DOCS_PLACEHOLDER: str = "*No documents yet.*"
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def humanize_display_name(display_name: str) -> str:
|
|
545
|
+
"""Re-cap the lowercased canonical name for use in headings + frontmatter.
|
|
546
|
+
|
|
547
|
+
PersonRecord stores names in the normalized lowercase form
|
|
548
|
+
(``"person-person-luke"``) so the matching layer can compare cleanly.
|
|
549
|
+
The page heading wants the human form (``"person-person-luke"``).
|
|
550
|
+
``str.title()`` is the standard multi-word capitalizer; rare
|
|
551
|
+
apostrophe / camel-case names ("d'Arcy", "person-person-marc") are slightly
|
|
552
|
+
mangled to "D'Arcy" / "person-person-marc" — acceptable trade-off, given the
|
|
553
|
+
directory layer normalized the input in the first place. The user
|
|
554
|
+
can fix any specific name by adjusting their ``_people.yml`` if it
|
|
555
|
+
matters for their corpus.
|
|
556
|
+
|
|
557
|
+
Public so the ``brain people`` CLI (Phase C) can render the same
|
|
558
|
+
title-cased form in its terminal output without re-implementing
|
|
559
|
+
the rule. Keep the internal alias below for backwards compat
|
|
560
|
+
inside this module — every call site already routes through it.
|
|
561
|
+
|
|
562
|
+
Delegates to :func:`brain.wiki._person_name.humanize_person_name` so the
|
|
563
|
+
People Hub, the graph reconcile resolver, and the CLI share one
|
|
564
|
+
presentation transform.
|
|
565
|
+
"""
|
|
566
|
+
return humanize_person_name(display_name)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# Internal alias — every existing call site reads from this name. Public
|
|
570
|
+
# helper above is the supported import surface; this private alias is
|
|
571
|
+
# preserved to keep the module's render functions readable.
|
|
572
|
+
_humanize_display_name = humanize_display_name
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _render_doc_line(doc: DocRef) -> str:
|
|
576
|
+
"""Render one document line of a person's roster.
|
|
577
|
+
|
|
578
|
+
Format: ``### YYYY-MM-DD · [[<vault_target>|<safe_title>]] (<source_kind>)``.
|
|
579
|
+
|
|
580
|
+
H3 (rather than a bullet) so each document becomes its own anchor
|
|
581
|
+
in Quartz's table-of-contents — the user can jump to a specific
|
|
582
|
+
doc on a person's page from the right rail. Date defaults to
|
|
583
|
+
``"undated"`` when ``DocRef.date is None`` (the renderer never
|
|
584
|
+
fabricates a date — a missing one is information).
|
|
585
|
+
|
|
586
|
+
When ``vault_target`` is ``None`` (doc not yet mirrored to the
|
|
587
|
+
vault), emit the title as plain text rather than a link — the
|
|
588
|
+
next sync that exports the doc will populate ``vault_path`` and
|
|
589
|
+
the next refresh cycle will turn it into a link.
|
|
590
|
+
"""
|
|
591
|
+
date_str = doc.date.strftime("%Y-%m-%d") if doc.date is not None else "undated"
|
|
592
|
+
safe_title = safe_wikilink_alias(doc.title)
|
|
593
|
+
link_text = (
|
|
594
|
+
f"[[{doc.vault_target}|{safe_title}]]" if doc.vault_target else safe_title
|
|
595
|
+
)
|
|
596
|
+
return f"### {date_str} · {link_text} ({doc.source_kind})"
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _render_person_body(record: PersonRecord, *, title: str) -> str:
|
|
600
|
+
"""Render the body of a single person page (everything below frontmatter).
|
|
601
|
+
|
|
602
|
+
Layout, in order:
|
|
603
|
+
- ``# <title>`` (H1) — the human-readable name.
|
|
604
|
+
- Primary email as an ``mailto:`` link.
|
|
605
|
+
- "Other emails:" — comma-separated list of every email in
|
|
606
|
+
``all_emails`` that isn't the primary. Omitted when the person
|
|
607
|
+
has only one email.
|
|
608
|
+
- ``## Documents (N)`` (H2) — count is the doc total.
|
|
609
|
+
- One ``### YYYY-MM-DD · [[…|…]] (kind)`` line per document, in
|
|
610
|
+
the order Phase A's :func:`_sort_docs` produced (date desc,
|
|
611
|
+
then title asc).
|
|
612
|
+
- When ``record.docs`` is empty, the placeholder
|
|
613
|
+
:data:`_NO_DOCS_PLACEHOLDER` line.
|
|
614
|
+
|
|
615
|
+
The body always ends with a trailing newline so
|
|
616
|
+
:func:`brain.vault.frontmatter.dump_frontmatter` round-trips
|
|
617
|
+
cleanly through the parser. Same input always produces byte-
|
|
618
|
+
identical output (idempotency contract).
|
|
619
|
+
"""
|
|
620
|
+
lines: list[str] = [f"# {title}", ""]
|
|
621
|
+
primary = record.primary_email
|
|
622
|
+
lines.append(f"**Primary email:** [{primary}](mailto:{primary})")
|
|
623
|
+
others = [email for email in record.all_emails if email != primary]
|
|
624
|
+
if others:
|
|
625
|
+
lines.append(f"**Other emails:** {', '.join(others)}")
|
|
626
|
+
lines.extend(["", f"## Documents ({len(record.docs)})", ""])
|
|
627
|
+
if not record.docs:
|
|
628
|
+
lines.append(_NO_DOCS_PLACEHOLDER)
|
|
629
|
+
else:
|
|
630
|
+
lines.extend(_render_doc_line(doc) for doc in record.docs)
|
|
631
|
+
lines.append("") # ensure trailing newline
|
|
632
|
+
return "\n".join(lines)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def render_person_md(record: PersonRecord) -> str:
|
|
636
|
+
"""Render one ``<vault>/people/<slug>.md`` page as a Markdown string.
|
|
637
|
+
|
|
638
|
+
Output shape (frontmatter + body):
|
|
639
|
+
|
|
640
|
+
.. code-block:: markdown
|
|
641
|
+
|
|
642
|
+
---
|
|
643
|
+
title: person-person-luke
|
|
644
|
+
slug: person-person-luke
|
|
645
|
+
kind: people
|
|
646
|
+
emails:
|
|
647
|
+
- person-person-luke@example.com
|
|
648
|
+
- person-person-luke-alt@example.com
|
|
649
|
+
doc_count: 12
|
|
650
|
+
in_people_yml: true
|
|
651
|
+
---
|
|
652
|
+
|
|
653
|
+
# person-person-luke
|
|
654
|
+
|
|
655
|
+
**Primary email:** [person-person-luke@example.com](mailto:person-person-luke@example.com)
|
|
656
|
+
**Other emails:** person-person-luke-alt@example.com
|
|
657
|
+
|
|
658
|
+
## Documents (12)
|
|
659
|
+
|
|
660
|
+
### 2026-05-06 · [[_ingested/krisp/…|AI CoS Jam Session]] (krisp)
|
|
661
|
+
### 2026-04-29 · [[_ingested/gmail/…|Fwd: April Hiring Thread]] (gmail)
|
|
662
|
+
…
|
|
663
|
+
|
|
664
|
+
Frontmatter notes:
|
|
665
|
+
- ``title`` is the title-cased rebuild of ``display_name``.
|
|
666
|
+
- ``slug`` mirrors ``record.slug`` — the canonical URL component,
|
|
667
|
+
used by the index page's wiki-links and consumed by the Phase C
|
|
668
|
+
Quartz overlay.
|
|
669
|
+
- ``kind: people`` is the page-class marker the overlay uses.
|
|
670
|
+
- ``emails`` is the alphabetized list from
|
|
671
|
+
``record.all_emails`` (already sorted by Phase A).
|
|
672
|
+
- ``doc_count`` matches ``len(record.docs)`` so the heading
|
|
673
|
+
``## Documents (N)`` and the frontmatter never disagree.
|
|
674
|
+
- ``in_people_yml`` drives the curated-badge rendering.
|
|
675
|
+
|
|
676
|
+
Pure: no I/O, no DB. Stable across runs given the same record —
|
|
677
|
+
that's the property :func:`emit_people_pages` relies on for its
|
|
678
|
+
"skip if byte-identical" idempotency gate.
|
|
679
|
+
"""
|
|
680
|
+
title = _humanize_display_name(record.display_name)
|
|
681
|
+
fields: dict[str, Any] = {
|
|
682
|
+
"title": title,
|
|
683
|
+
"slug": record.slug,
|
|
684
|
+
"kind": _PERSON_KIND_FRONTMATTER,
|
|
685
|
+
"emails": list(record.all_emails),
|
|
686
|
+
"doc_count": len(record.docs),
|
|
687
|
+
"in_people_yml": record.in_people_yml,
|
|
688
|
+
}
|
|
689
|
+
body = _render_person_body(record, title=title)
|
|
690
|
+
return dump_frontmatter(fields, body)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def render_index_md(records: list[PersonRecord]) -> str:
|
|
694
|
+
"""Render the ``<vault>/people/index.md`` roster page.
|
|
695
|
+
|
|
696
|
+
Alphabetized list of every person with a page. The plan calls for
|
|
697
|
+
a Markdown table; we emit a bullet list instead because Markdown
|
|
698
|
+
tables and Obsidian wiki-link aliases collide on the ``|``
|
|
699
|
+
separator (``[[X|Y]]`` inside a cell breaks the table parser, and
|
|
700
|
+
``\\|`` escaping breaks the wiki-link parser). A bullet list
|
|
701
|
+
preserves every signal the table would have carried — name,
|
|
702
|
+
doc count, primary email, curated indicator — without the
|
|
703
|
+
syntactic conflict, and matches the recent-rail bullets in
|
|
704
|
+
:func:`brain.wiki.build_homepage._render_bullets`.
|
|
705
|
+
|
|
706
|
+
Each line is shaped:
|
|
707
|
+
|
|
708
|
+
.. code-block:: markdown
|
|
709
|
+
|
|
710
|
+
- [✅ ] [[people/<slug>|<Display Name>]] — N docs · email@x.com
|
|
711
|
+
|
|
712
|
+
The leading ``✅`` (always followed by a space, even when blank,
|
|
713
|
+
so column alignment is byte-stable across lines) flags
|
|
714
|
+
:attr:`PersonRecord.in_people_yml=True` rows. Curated entries
|
|
715
|
+
with zero docs render as ``— 0 docs · …`` rather than being hidden.
|
|
716
|
+
|
|
717
|
+
Pure: no I/O. Returns the empty-state placeholder body when
|
|
718
|
+
``records`` is empty so the index page always renders.
|
|
719
|
+
"""
|
|
720
|
+
fields: dict[str, Any] = {
|
|
721
|
+
"title": "People",
|
|
722
|
+
"slug": "people",
|
|
723
|
+
"kind": _PEOPLE_INDEX_KIND_FRONTMATTER,
|
|
724
|
+
}
|
|
725
|
+
sorted_records = sorted(records, key=lambda r: r.display_name)
|
|
726
|
+
lines: list[str] = ["# People", ""]
|
|
727
|
+
if not sorted_records:
|
|
728
|
+
lines.append("*No people yet — add entries to `_people.yml` "
|
|
729
|
+
"or wait for the directory threshold to fill in.*")
|
|
730
|
+
else:
|
|
731
|
+
for rec in sorted_records:
|
|
732
|
+
display = _humanize_display_name(rec.display_name)
|
|
733
|
+
badge = "✅ " if rec.in_people_yml else ""
|
|
734
|
+
lines.append(
|
|
735
|
+
f"- {badge}[[people/{rec.slug}|{display}]] — "
|
|
736
|
+
f"{len(rec.docs)} docs · {rec.primary_email}"
|
|
737
|
+
)
|
|
738
|
+
lines.append("") # ensure trailing newline
|
|
739
|
+
body = "\n".join(lines)
|
|
740
|
+
return dump_frontmatter(fields, body)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
# ---- Phase B — page emission ------------------------------------------------
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
@dataclass
|
|
747
|
+
class EmitReport:
|
|
748
|
+
"""Counters surfaced by :func:`emit_people_pages` for the CLI summary.
|
|
749
|
+
|
|
750
|
+
All four fields default to zero so callers don't need a sentinel
|
|
751
|
+
None-check. ``pages_written`` only counts pages whose bytes
|
|
752
|
+
actually changed on disk — a re-run with no DB drift returns
|
|
753
|
+
``pages_written=0`` (idempotency contract). ``pages_deleted``
|
|
754
|
+
counts pages removed because the corresponding person was
|
|
755
|
+
dropped from the directory (e.g. a ``_people.yml`` entry the
|
|
756
|
+
user removed). ``index_written`` is a bool surfaced as int so
|
|
757
|
+
the CLI's "rows changed" line is one consistent format.
|
|
758
|
+
"""
|
|
759
|
+
|
|
760
|
+
pages_written: int = 0
|
|
761
|
+
pages_deleted: int = 0
|
|
762
|
+
index_written: bool = False
|
|
763
|
+
skipped_unchanged: int = 0
|
|
764
|
+
deleted_paths: list[Path] = field(default_factory=list)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _people_dir(vault_path: Path) -> Path:
|
|
768
|
+
"""Return ``<vault_path>/people``. Created on demand by the emitter."""
|
|
769
|
+
return vault_path / _PEOPLE_DIR_NAME
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _existing_person_pages(people_dir: Path) -> set[Path]:
|
|
773
|
+
"""Return every ``<people_dir>/<slug>.md`` currently on disk.
|
|
774
|
+
|
|
775
|
+
Excludes ``index.md`` (managed separately) and any path that isn't
|
|
776
|
+
a regular ``.md`` file (subdirectories, dotfiles). The set is the
|
|
777
|
+
"what's there now" half of the cleanup contract — the emitter
|
|
778
|
+
diffs it against the freshly-aggregated target set and removes
|
|
779
|
+
anything in the prior-set-but-not-target.
|
|
780
|
+
|
|
781
|
+
Returns an empty set when ``people_dir`` doesn't exist yet (first
|
|
782
|
+
run on a fresh vault) — the emitter creates the directory on the
|
|
783
|
+
first write.
|
|
784
|
+
"""
|
|
785
|
+
if not people_dir.is_dir():
|
|
786
|
+
return set()
|
|
787
|
+
out: set[Path] = set()
|
|
788
|
+
for entry in people_dir.iterdir():
|
|
789
|
+
if not entry.is_file():
|
|
790
|
+
continue
|
|
791
|
+
if entry.suffix != ".md":
|
|
792
|
+
continue
|
|
793
|
+
if entry.name == "index.md":
|
|
794
|
+
continue
|
|
795
|
+
out.add(entry)
|
|
796
|
+
return out
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _write_if_changed(target: Path, rendered: str) -> bool:
|
|
800
|
+
"""Write ``rendered`` to ``target`` iff its bytes differ from what's there.
|
|
801
|
+
|
|
802
|
+
Returns ``True`` iff the file was actually written (bytes drifted),
|
|
803
|
+
``False`` on a no-op skip. The skip preserves the file's mtime so
|
|
804
|
+
the Quartz watcher doesn't fire a needless rebuild — same property
|
|
805
|
+
the recent-rail emitter relies on.
|
|
806
|
+
|
|
807
|
+
Atomic — sibling tempfile + ``os.replace`` via
|
|
808
|
+
:func:`brain.vault._atomic.atomic_write_text`. A crash mid-write
|
|
809
|
+
leaves the prior version intact.
|
|
810
|
+
|
|
811
|
+
Creates the parent directory on demand so the first run on a
|
|
812
|
+
fresh vault doesn't require a separate scaffold step.
|
|
813
|
+
"""
|
|
814
|
+
if target.is_file():
|
|
815
|
+
try:
|
|
816
|
+
existing = target.read_text(encoding="utf-8")
|
|
817
|
+
except OSError as exc:
|
|
818
|
+
# Read failure is not fatal — we'll still attempt the write.
|
|
819
|
+
# Logged at DEBUG because the more interesting signal is the
|
|
820
|
+
# write attempt that follows.
|
|
821
|
+
logger.debug("people emit: could not read %s for compare: %s", target, exc)
|
|
822
|
+
existing = None
|
|
823
|
+
if existing == rendered:
|
|
824
|
+
return False
|
|
825
|
+
|
|
826
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
827
|
+
atomic_write_text(target, rendered)
|
|
828
|
+
return True
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def emit_people_pages(
|
|
832
|
+
conn: psycopg.Connection[Any],
|
|
833
|
+
*,
|
|
834
|
+
vault_path: Path,
|
|
835
|
+
owner_keys: frozenset[str],
|
|
836
|
+
min_docs: int,
|
|
837
|
+
sender_denylist: frozenset[str] = frozenset(),
|
|
838
|
+
) -> EmitReport:
|
|
839
|
+
"""Aggregate, render, and write every ``<vault>/people/<slug>.md`` + index.
|
|
840
|
+
|
|
841
|
+
Pipeline (per run):
|
|
842
|
+
|
|
843
|
+
1. **Aggregate.** Call :func:`aggregate_people` to get the
|
|
844
|
+
canonical roster — slug-collision-resolved, owner-filtered,
|
|
845
|
+
threshold-applied.
|
|
846
|
+
2. **Compute the target set.** ``{<slug>.md for slug in records}``.
|
|
847
|
+
The index page is tracked separately (``index.md``) so its
|
|
848
|
+
presence never gets confused with a curated person.
|
|
849
|
+
3. **Cleanup pass.** List every ``<people_dir>/*.md`` currently
|
|
850
|
+
on disk (excluding ``index.md``), and ``unlink`` any path not
|
|
851
|
+
in the target set. This is what propagates "user removed
|
|
852
|
+
person-person-luke from ``_people.yml``" to the wiki: the next emit drops
|
|
853
|
+
``person-person-luke.md``.
|
|
854
|
+
4. **Write each per-person page.** Skip files whose rendered
|
|
855
|
+
bytes match what's already there (idempotency). Atomic write
|
|
856
|
+
via :func:`brain.vault._atomic.atomic_write_text`.
|
|
857
|
+
5. **Write the index page.** Same skip-if-unchanged logic.
|
|
858
|
+
|
|
859
|
+
Args:
|
|
860
|
+
conn: live DB connection. Reads only — no writes.
|
|
861
|
+
vault_path: the vault root. ``<vault_path>/people/`` is
|
|
862
|
+
managed; nothing outside it is touched.
|
|
863
|
+
owner_keys: forwarded to :func:`aggregate_people` as-is.
|
|
864
|
+
min_docs: forwarded to :func:`aggregate_people` as-is.
|
|
865
|
+
sender_denylist: forwarded to :func:`aggregate_people` as-is
|
|
866
|
+
(``BRAIN_GRAPH_SENDER_DENYLIST`` extras for the automated-sender
|
|
867
|
+
filter). Default empty.
|
|
868
|
+
|
|
869
|
+
Returns:
|
|
870
|
+
:class:`EmitReport` with counters for the CLI summary line.
|
|
871
|
+
``deleted_paths`` carries the actual unlinked paths so callers
|
|
872
|
+
(and tests) can inspect what cleanup did.
|
|
873
|
+
|
|
874
|
+
The function never raises on individual page write failures —
|
|
875
|
+
failures are logged at WARNING and the run continues so one bad
|
|
876
|
+
file doesn't drop the whole hub. Aggregation errors propagate
|
|
877
|
+
(a SQL failure means we can't safely produce a target set, and
|
|
878
|
+
deleting pages against a partial target would lose data).
|
|
879
|
+
"""
|
|
880
|
+
records = aggregate_people(
|
|
881
|
+
conn,
|
|
882
|
+
owner_keys=owner_keys,
|
|
883
|
+
min_docs=min_docs,
|
|
884
|
+
sender_denylist=sender_denylist,
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
people_dir = _people_dir(vault_path)
|
|
888
|
+
target_paths: set[Path] = {people_dir / f"{rec.slug}.md" for rec in records}
|
|
889
|
+
|
|
890
|
+
report = EmitReport()
|
|
891
|
+
|
|
892
|
+
# Cleanup pass first — deletes are independent of writes.
|
|
893
|
+
for stale in sorted(_existing_person_pages(people_dir) - target_paths):
|
|
894
|
+
try:
|
|
895
|
+
stale.unlink()
|
|
896
|
+
except OSError as exc:
|
|
897
|
+
logger.warning(
|
|
898
|
+
"people emit: could not delete stale page %s: %s — skipping",
|
|
899
|
+
stale, exc,
|
|
900
|
+
)
|
|
901
|
+
continue
|
|
902
|
+
report.pages_deleted += 1
|
|
903
|
+
report.deleted_paths.append(stale)
|
|
904
|
+
|
|
905
|
+
# Per-person pages.
|
|
906
|
+
for rec in records:
|
|
907
|
+
target = people_dir / f"{rec.slug}.md"
|
|
908
|
+
rendered = render_person_md(rec)
|
|
909
|
+
try:
|
|
910
|
+
changed = _write_if_changed(target, rendered)
|
|
911
|
+
except OSError as exc:
|
|
912
|
+
logger.warning(
|
|
913
|
+
"people emit: could not write %s: %s — skipping",
|
|
914
|
+
target, exc,
|
|
915
|
+
)
|
|
916
|
+
continue
|
|
917
|
+
if changed:
|
|
918
|
+
report.pages_written += 1
|
|
919
|
+
else:
|
|
920
|
+
report.skipped_unchanged += 1
|
|
921
|
+
|
|
922
|
+
# Index page.
|
|
923
|
+
index_target = people_dir / "index.md"
|
|
924
|
+
index_rendered = render_index_md(records)
|
|
925
|
+
try:
|
|
926
|
+
report.index_written = _write_if_changed(index_target, index_rendered)
|
|
927
|
+
except OSError as exc:
|
|
928
|
+
logger.warning(
|
|
929
|
+
"people emit: could not write index %s: %s",
|
|
930
|
+
index_target, exc,
|
|
931
|
+
)
|
|
932
|
+
report.index_written = False
|
|
933
|
+
|
|
934
|
+
return report
|