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,408 @@
|
|
|
1
|
+
"""Fenced auto-section for derived edges in `_ingested/` bodies.
|
|
2
|
+
|
|
3
|
+
Each ingested-tier vault file gets a single trailing section, bracketed by
|
|
4
|
+
HTML-comment markers, that materializes the document's metadata-derived
|
|
5
|
+
partners as native Obsidian/Quartz wiki-links so they're visible in
|
|
6
|
+
Quartz's `/graph` view (see
|
|
7
|
+
``docs/specs/2026-04-30-derived-edges-in-bodies-design.md``).
|
|
8
|
+
|
|
9
|
+
The fence is what keeps this safe across the rest of the pipeline:
|
|
10
|
+
|
|
11
|
+
- Sync's ``body_hash`` strips the fence before hashing → freshly-rendered
|
|
12
|
+
fences don't trigger the re-embed cascade.
|
|
13
|
+
- Sync's normalized-body-for-DB strips the fence → ``documents.content``
|
|
14
|
+
stays clean, so vector search isn't polluted by "Related" lists.
|
|
15
|
+
- Wiki-link parser skips the fence → ``[[stems]]`` inside the fence don't
|
|
16
|
+
double-count edges that already live in ``derived_links``.
|
|
17
|
+
|
|
18
|
+
This module ships:
|
|
19
|
+
|
|
20
|
+
- Pure-string helpers: :func:`extract_fence`, :func:`strip_fence`,
|
|
21
|
+
:func:`replace_fence`. No DB.
|
|
22
|
+
- DB-reading renderer: :func:`render_fenced_section` — produces the
|
|
23
|
+
fenced markdown for a doc id from current ``derived_links`` rows.
|
|
24
|
+
- File-rewriting renderer: :func:`rewrite_derived_fences` — for each
|
|
25
|
+
affected ingested-tier doc, regenerates the fence on disk via an
|
|
26
|
+
atomic write. Skips vault-tier docs (Q3=a) and docs without an
|
|
27
|
+
``_ingested/`` mirror (covered by export, not the fence renderer).
|
|
28
|
+
"""
|
|
29
|
+
import datetime
|
|
30
|
+
import logging
|
|
31
|
+
from email.utils import parsedate_to_datetime
|
|
32
|
+
from pathlib import Path, PurePosixPath
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
import psycopg
|
|
36
|
+
import yaml
|
|
37
|
+
|
|
38
|
+
from .._atomic import atomic_write_text
|
|
39
|
+
from ..paths import safe_wikilink_alias
|
|
40
|
+
|
|
41
|
+
# Stable HTML-comment markers. Universal CommonMark — Obsidian, Quartz, GFM,
|
|
42
|
+
# and standard Markdown all treat the line as a passthrough HTML comment.
|
|
43
|
+
# Public so the sync engine and watcher can detect fence regions without
|
|
44
|
+
# re-importing internal regex state.
|
|
45
|
+
FENCE_START_MARKER: str = "<!-- BRAIN_DERIVED_START -->"
|
|
46
|
+
FENCE_END_MARKER: str = "<!-- BRAIN_DERIVED_END -->"
|
|
47
|
+
|
|
48
|
+
# Rules surfaced in the Quartz-visible fence. ``shared_participant`` (R2) is
|
|
49
|
+
# excluded because it's high-recall / low-precision: at corpus scale it
|
|
50
|
+
# produces an N×N hairball that breaks Quartz's force-directed graph layout
|
|
51
|
+
# (the user is a participant in nearly every doc, so almost every doc shares
|
|
52
|
+
# a participant with almost every other doc). R2 edges remain queryable via
|
|
53
|
+
# ``brain backlinks`` / ``brain graph`` / MCP — only the Quartz surface is
|
|
54
|
+
# narrowed, not the underlying ``derived_links`` table.
|
|
55
|
+
#
|
|
56
|
+
# Sorted to give psycopg a stable parameter binding shape.
|
|
57
|
+
FENCE_RULES: tuple[str, ...] = ("same_day_participant", "shared_thread")
|
|
58
|
+
|
|
59
|
+
# Section heading inside the fence. Stable so users (and `git diff`) can grep
|
|
60
|
+
# for it.
|
|
61
|
+
_SECTION_HEADING: str = "## Related (auto-generated, do not edit)"
|
|
62
|
+
|
|
63
|
+
_logger = logging.getLogger(__name__)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def extract_fence(body: str) -> tuple[str, str | None]:
|
|
68
|
+
"""Split ``body`` into ``(body_without_fence, fence_text_or_None)``.
|
|
69
|
+
|
|
70
|
+
The returned ``fence_text`` includes the start/end markers; the body
|
|
71
|
+
slice excludes the entire fence region (markers + everything between).
|
|
72
|
+
|
|
73
|
+
If multiple ``BRAIN_DERIVED_START`` markers appear in the body
|
|
74
|
+
(corruption — e.g. two stacked fences from a buggy renderer), only
|
|
75
|
+
the first is treated as the fence anchor; later markers stay in
|
|
76
|
+
``body_without_fence`` as plain text. Recovery is then a re-render
|
|
77
|
+
away.
|
|
78
|
+
|
|
79
|
+
Bodies without a fence return ``(body, None)`` unchanged.
|
|
80
|
+
"""
|
|
81
|
+
start_idx = body.find(FENCE_START_MARKER)
|
|
82
|
+
if start_idx == -1:
|
|
83
|
+
return body, None
|
|
84
|
+
|
|
85
|
+
# Search for the matching END marker AFTER the START. A stray END before
|
|
86
|
+
# the first START is treated as content (no fence detected — earlier
|
|
87
|
+
# ``find`` returned -1 — so we never reach this branch in that case).
|
|
88
|
+
end_search_from = start_idx + len(FENCE_START_MARKER)
|
|
89
|
+
end_idx = body.find(FENCE_END_MARKER, end_search_from)
|
|
90
|
+
if end_idx == -1:
|
|
91
|
+
# START with no matching END — treat as malformed and leave body
|
|
92
|
+
# untouched so the user sees the corruption rather than us silently
|
|
93
|
+
# eating the rest of the file.
|
|
94
|
+
return body, None
|
|
95
|
+
|
|
96
|
+
fence_close = end_idx + len(FENCE_END_MARKER)
|
|
97
|
+
fence_text = body[start_idx:fence_close]
|
|
98
|
+
body_without = body[:start_idx] + body[fence_close:]
|
|
99
|
+
return body_without, fence_text
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def strip_fence(body: str) -> str:
|
|
103
|
+
"""Return ``body`` with the fenced region removed.
|
|
104
|
+
|
|
105
|
+
Idempotent — calling on a body that has no fence returns it unchanged,
|
|
106
|
+
so ``strip_fence(strip_fence(x)) == strip_fence(x)``. This is what the
|
|
107
|
+
sync engine uses to compute a fence-stable ``body_hash`` (and a
|
|
108
|
+
fence-free ``documents.content`` projection).
|
|
109
|
+
|
|
110
|
+
When a fence is removed, trailing whitespace it introduced (the blank
|
|
111
|
+
line that separated user content from the fence, plus any newline
|
|
112
|
+
after the END marker) is collapsed and replaced with a single trailing
|
|
113
|
+
``\\n``. Two fenced bodies that only differ in their fence content
|
|
114
|
+
therefore strip to byte-identical strings — that's the property the
|
|
115
|
+
``body_hash`` strip relies on to avoid re-embed loops.
|
|
116
|
+
|
|
117
|
+
Bodies without a fence are returned unchanged so the function is a
|
|
118
|
+
pure no-op for files the renderer has never touched.
|
|
119
|
+
"""
|
|
120
|
+
body_without, fence = extract_fence(body)
|
|
121
|
+
if fence is None:
|
|
122
|
+
return body
|
|
123
|
+
trimmed = body_without.rstrip()
|
|
124
|
+
return trimmed + "\n" if trimmed else ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def replace_fence(body: str, new_fence: str) -> str:
|
|
128
|
+
"""Return ``body`` with its fence swapped for ``new_fence`` (appending if absent).
|
|
129
|
+
|
|
130
|
+
``new_fence`` is the fully-rendered fenced section — including the
|
|
131
|
+
start/end markers — typically produced by :func:`render_fenced_section`.
|
|
132
|
+
The function strips any pre-existing fence first, then appends
|
|
133
|
+
``new_fence`` after exactly one blank-line separator and finishes with a
|
|
134
|
+
single trailing newline.
|
|
135
|
+
|
|
136
|
+
Edge cases handled:
|
|
137
|
+
|
|
138
|
+
- Body without trailing newline → fence appended cleanly with separator.
|
|
139
|
+
- Body with one or more trailing newlines → trailing whitespace is
|
|
140
|
+
normalized so the result ends with exactly one ``\\n`` after the END
|
|
141
|
+
marker (no double-blank, no missing-newline).
|
|
142
|
+
- Body already containing a fence → that fence is removed before the
|
|
143
|
+
new one is appended (so callers can pass the renderer's latest output
|
|
144
|
+
directly without manual orchestration).
|
|
145
|
+
"""
|
|
146
|
+
base, _existing = extract_fence(body)
|
|
147
|
+
base = base.rstrip()
|
|
148
|
+
if not base:
|
|
149
|
+
return new_fence + "\n"
|
|
150
|
+
return f"{base}\n\n{new_fence}\n"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def render_fenced_section(
|
|
154
|
+
conn: psycopg.Connection[Any], doc_id: str
|
|
155
|
+
) -> str | None:
|
|
156
|
+
"""Build the fenced section for ``doc_id`` from the current ``derived_links``.
|
|
157
|
+
|
|
158
|
+
Queries every derived edge whose ``src`` or ``dst`` is ``doc_id`` AND
|
|
159
|
+
whose ``rule`` is in :data:`FENCE_RULES` (i.e. R1/R3 — ``shared_thread``
|
|
160
|
+
and ``same_day_participant``). R2 (``shared_participant``) is filtered
|
|
161
|
+
out at the SQL layer because it's high-recall / low-precision and at
|
|
162
|
+
corpus scale produces a graph hairball that breaks Quartz's
|
|
163
|
+
force-directed layout. R2 edges remain queryable via every other read
|
|
164
|
+
surface (``brain backlinks`` / ``brain graph`` / MCP); only the
|
|
165
|
+
Quartz-visible fence is narrowed.
|
|
166
|
+
|
|
167
|
+
Joins ``documents`` for each partner's title / vault_path / metadata
|
|
168
|
+
and emits one bullet per edge:
|
|
169
|
+
|
|
170
|
+
- [[<partner-filename-stem>|<partner-title>]] *(<rule>)*
|
|
171
|
+
|
|
172
|
+
Sort order (deterministic, primary→tertiary):
|
|
173
|
+
|
|
174
|
+
1. ``rule weight DESC`` — highest-weight rules surface first (R1
|
|
175
|
+
``shared_thread`` weight 1.0 before R3 ``same_day_participant``
|
|
176
|
+
weight 0.7).
|
|
177
|
+
2. Partner ``metadata->>'date'`` DESC — within a weight tier, newer
|
|
178
|
+
partners win. Partners with a missing or unparseable date sort
|
|
179
|
+
AFTER partners with a date.
|
|
180
|
+
3. Partner document ``id`` ASC — final tie-breaker so two partners
|
|
181
|
+
with identical weight + identical date (or both undated) always
|
|
182
|
+
emit in the same order across runs. Without this every relink →
|
|
183
|
+
sync round-trip risked reshuffling the bullet list and bumping
|
|
184
|
+
file mtimes unnecessarily.
|
|
185
|
+
|
|
186
|
+
Both the SQL query and the in-memory sort are stable: SQL
|
|
187
|
+
``ORDER BY`` pins the row delivery order, and Python's ``list.sort``
|
|
188
|
+
is guaranteed stable, so the tertiary id key dominates only when the
|
|
189
|
+
weight + date keys tie.
|
|
190
|
+
|
|
191
|
+
Partners without a ``vault_path`` (not exported yet) are skipped — the
|
|
192
|
+
wiki-link wouldn't resolve in Quartz anyway. If skipping leaves the
|
|
193
|
+
bullet list empty (or the rule filter dropped every edge for this
|
|
194
|
+
doc), returns ``None`` (caller should remove the fence rather than
|
|
195
|
+
emit an empty section).
|
|
196
|
+
"""
|
|
197
|
+
rows = conn.execute(
|
|
198
|
+
"""
|
|
199
|
+
SELECT
|
|
200
|
+
partner.id::text,
|
|
201
|
+
partner.title,
|
|
202
|
+
partner.vault_path,
|
|
203
|
+
partner.metadata,
|
|
204
|
+
dl.rule,
|
|
205
|
+
dl.weight
|
|
206
|
+
FROM derived_links dl
|
|
207
|
+
JOIN documents partner ON partner.id = (
|
|
208
|
+
CASE WHEN dl.src_document_id = %s::uuid
|
|
209
|
+
THEN dl.dst_document_id
|
|
210
|
+
ELSE dl.src_document_id
|
|
211
|
+
END
|
|
212
|
+
)
|
|
213
|
+
WHERE (dl.src_document_id = %s::uuid
|
|
214
|
+
OR dl.dst_document_id = %s::uuid)
|
|
215
|
+
AND dl.rule = ANY(%s)
|
|
216
|
+
ORDER BY dl.weight DESC,
|
|
217
|
+
partner.metadata->>'date' DESC NULLS LAST,
|
|
218
|
+
partner.id ASC
|
|
219
|
+
""",
|
|
220
|
+
(doc_id, doc_id, doc_id, list(FENCE_RULES)),
|
|
221
|
+
).fetchall()
|
|
222
|
+
|
|
223
|
+
bullets: list[tuple[float, bool, int, str, str]] = []
|
|
224
|
+
# Sort key tuple meaning:
|
|
225
|
+
# index 0: -weight → ascending sort puts highest weight first
|
|
226
|
+
# index 1: date_missing (False<True) → with-date partners first within tier
|
|
227
|
+
# index 2: -date.toordinal() → ascending puts newest date first
|
|
228
|
+
# index 3: partner_id ASC → deterministic tertiary tie-breaker so
|
|
229
|
+
# partners with identical weight + identical date emit in
|
|
230
|
+
# the same order across runs (no mtime churn from
|
|
231
|
+
# relink → sync cycles re-shuffling tied bullets).
|
|
232
|
+
for partner_id, title, vault_path, metadata, rule, weight in rows:
|
|
233
|
+
if not vault_path:
|
|
234
|
+
_logger.debug(
|
|
235
|
+
"fence: skipping partner %s (no vault_path yet)", partner_id
|
|
236
|
+
)
|
|
237
|
+
continue
|
|
238
|
+
stem = PurePosixPath(str(vault_path)).stem
|
|
239
|
+
partner_date = _parse_metadata_date(dict(metadata or {}))
|
|
240
|
+
date_missing = partner_date is None
|
|
241
|
+
date_key = -partner_date.toordinal() if partner_date else 0
|
|
242
|
+
bullet = f"- [[{stem}|{safe_wikilink_alias(title)}]] *({rule})*"
|
|
243
|
+
bullets.append(
|
|
244
|
+
(-float(weight), date_missing, date_key, str(partner_id), bullet)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if not bullets:
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
bullets.sort(key=lambda b: (b[0], b[1], b[2], b[3]))
|
|
251
|
+
body_lines = [FENCE_START_MARKER, _SECTION_HEADING]
|
|
252
|
+
body_lines.extend(b[4] for b in bullets)
|
|
253
|
+
body_lines.append(FENCE_END_MARKER)
|
|
254
|
+
return "\n".join(body_lines)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _parse_metadata_date(metadata: dict[str, Any]) -> datetime.date | None:
|
|
258
|
+
"""Best-effort parse of ``metadata['date']`` into a :class:`datetime.date`.
|
|
259
|
+
|
|
260
|
+
Mirrors the shapes ``brain.vault.derived_links.pass_runner._parse_date``
|
|
261
|
+
accepts (ISO ``YYYY-MM-DD`` for Krisp, RFC 5322 for Gmail) but doesn't
|
|
262
|
+
require knowing the source kind — the renderer is sort-only and tolerant
|
|
263
|
+
of either form. Returns ``None`` for missing, non-string, or
|
|
264
|
+
unparseable values; partners with a missing date sort last within the
|
|
265
|
+
same weight tier.
|
|
266
|
+
"""
|
|
267
|
+
raw = metadata.get("date")
|
|
268
|
+
if not isinstance(raw, str):
|
|
269
|
+
return None
|
|
270
|
+
text = raw.strip()
|
|
271
|
+
if not text:
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
# Try ISO YYYY-MM-DD first (Krisp). Slicing tolerates "YYYY-MM-DDTHH:MM"
|
|
275
|
+
# without pulling in the wider ``datetime.fromisoformat`` grammar.
|
|
276
|
+
try:
|
|
277
|
+
return datetime.date.fromisoformat(text[:10])
|
|
278
|
+
except ValueError:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
# Fall back to RFC 5322 (Gmail).
|
|
282
|
+
try:
|
|
283
|
+
parsed = parsedate_to_datetime(text)
|
|
284
|
+
except (TypeError, ValueError):
|
|
285
|
+
return None
|
|
286
|
+
if parsed is None:
|
|
287
|
+
return None
|
|
288
|
+
return parsed.date()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def rewrite_derived_fences(
|
|
292
|
+
conn: psycopg.Connection[Any],
|
|
293
|
+
doc_ids: set[str],
|
|
294
|
+
*,
|
|
295
|
+
vault_path: Path,
|
|
296
|
+
) -> int:
|
|
297
|
+
"""Regenerate the derived-edges fence for every affected ``_ingested/`` file.
|
|
298
|
+
|
|
299
|
+
Driven by ``rebuild_derived_for``'s ``affected_ids`` return value:
|
|
300
|
+
every doc whose edges changed (gained or lost) gets its fence rewritten
|
|
301
|
+
on disk, so Quartz's ``/graph`` view stays consistent with the latest
|
|
302
|
+
``derived_links`` rows.
|
|
303
|
+
|
|
304
|
+
Behavior per the user's Q3–Q5 decisions (with the 2026-05-08 idempotency fix):
|
|
305
|
+
|
|
306
|
+
- **Q3=a** vault-tier files are silently skipped — user-authored notes
|
|
307
|
+
stay untouched in v1. Only docs with ``kind='ingested'`` are
|
|
308
|
+
candidates.
|
|
309
|
+
- **Skip on byte-identical** (supersedes the original Q4=b "always
|
|
310
|
+
rewrite"): if the freshly-rendered file text is byte-identical to
|
|
311
|
+
what's already on disk, the file is NOT written and is NOT counted.
|
|
312
|
+
The returned ``written`` count reflects actual disk writes, so
|
|
313
|
+
``relink-derived → sync`` is a true no-op for unchanged docs (no
|
|
314
|
+
mtime bumps, no Quartz rebuild churn) and the caller's reported
|
|
315
|
+
counter cannot lie about disk effect.
|
|
316
|
+
- **Q5=b** + Q2a fence content uses ``[[<filename-stem>|<title>]]
|
|
317
|
+
*(<rule>)*`` (rendered by :func:`render_fenced_section`).
|
|
318
|
+
|
|
319
|
+
Returns the count of files actually written. Docs in ``doc_ids`` that
|
|
320
|
+
map to a vault-tier row, have no ``vault_path`` set, whose mirror
|
|
321
|
+
file is missing on disk, OR whose freshly-rendered text matches the
|
|
322
|
+
on-disk text byte-for-byte are silently dropped from the count —
|
|
323
|
+
those skips aren't failures, they're "the renderer has no work to do
|
|
324
|
+
here."
|
|
325
|
+
|
|
326
|
+
Atomicity: each write goes through a sibling temp file plus
|
|
327
|
+
:func:`os.replace`, which is atomic on POSIX (rename(2)). A crash
|
|
328
|
+
mid-write leaves the original file intact; the temp file is cleaned
|
|
329
|
+
up on the next pass over the same doc id.
|
|
330
|
+
|
|
331
|
+
Empty input short-circuits to ``0`` — no DB round-trip, no FS scan.
|
|
332
|
+
"""
|
|
333
|
+
if not doc_ids:
|
|
334
|
+
return 0
|
|
335
|
+
|
|
336
|
+
# Local imports break a cycle: ``brain.vault.frontmatter`` imports
|
|
337
|
+
# :func:`strip_fence` from this module so its ``body_hash`` ignores the
|
|
338
|
+
# fence content; we in turn need its parse/dump helpers to round-trip
|
|
339
|
+
# the YAML frontmatter while editing the body. Lazy-loading at call
|
|
340
|
+
# time keeps both modules' top-level imports cycle-free.
|
|
341
|
+
from ..frontmatter import dump_frontmatter, parse_frontmatter
|
|
342
|
+
|
|
343
|
+
# Single batch lookup: kind + vault_path for every affected doc id. At
|
|
344
|
+
# production scale (~500 ingested docs) the IN-list fits comfortably in
|
|
345
|
+
# one query and saves N round-trips on a full corpus relink.
|
|
346
|
+
rows = conn.execute(
|
|
347
|
+
"SELECT id::text, kind, vault_path FROM documents "
|
|
348
|
+
"WHERE id = ANY(%s)",
|
|
349
|
+
(sorted(doc_ids),),
|
|
350
|
+
).fetchall()
|
|
351
|
+
|
|
352
|
+
written = 0
|
|
353
|
+
for doc_id, kind, vp in rows:
|
|
354
|
+
# Q3=a: vault-tier files stay untouched in v1.
|
|
355
|
+
if kind != "ingested":
|
|
356
|
+
continue
|
|
357
|
+
# No mirror exported yet — the fence renderer has no file to
|
|
358
|
+
# rewrite. Export will produce one on the next pass; this is not
|
|
359
|
+
# an error.
|
|
360
|
+
if not vp:
|
|
361
|
+
continue
|
|
362
|
+
target = vault_path / str(vp)
|
|
363
|
+
if not target.is_file():
|
|
364
|
+
_logger.debug(
|
|
365
|
+
"fence: skipping %s — vault_path %r has no file on disk",
|
|
366
|
+
doc_id, vp,
|
|
367
|
+
)
|
|
368
|
+
continue
|
|
369
|
+
try:
|
|
370
|
+
text = target.read_text(encoding="utf-8")
|
|
371
|
+
except OSError as e:
|
|
372
|
+
_logger.warning(
|
|
373
|
+
"fence: could not read %s: %s — skipping", target, e
|
|
374
|
+
)
|
|
375
|
+
continue
|
|
376
|
+
try:
|
|
377
|
+
frontmatter, body = parse_frontmatter(text)
|
|
378
|
+
except (ValueError, yaml.YAMLError) as e:
|
|
379
|
+
_logger.warning(
|
|
380
|
+
"fence: malformed frontmatter in %s: %s — skipping",
|
|
381
|
+
target, e,
|
|
382
|
+
)
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
new_fence = render_fenced_section(conn, doc_id)
|
|
386
|
+
new_body = (
|
|
387
|
+
strip_fence(body) if new_fence is None
|
|
388
|
+
else replace_fence(body, new_fence)
|
|
389
|
+
)
|
|
390
|
+
new_text = dump_frontmatter(frontmatter, new_body)
|
|
391
|
+
|
|
392
|
+
# Idempotency: skip the write entirely when the rendered text is
|
|
393
|
+
# byte-identical to what's already on disk. This is the contract
|
|
394
|
+
# that lets ``relink-derived → sync`` round-trip without bumping
|
|
395
|
+
# mtimes — counters reflect real disk effect, not "we ran the
|
|
396
|
+
# renderer." See ``docs/plans/2026-05-08-vault-sync-fence-strip-bug.md``.
|
|
397
|
+
if new_text == text:
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
atomic_write_text(target, new_text)
|
|
402
|
+
except OSError as e:
|
|
403
|
+
_logger.warning(
|
|
404
|
+
"fence: could not rewrite %s: %s — skipping", target, e
|
|
405
|
+
)
|
|
406
|
+
continue
|
|
407
|
+
written += 1
|
|
408
|
+
return written
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Production ``gws`` subprocess runner conforming to the GwsRunner Protocol.
|
|
2
|
+
|
|
3
|
+
Mirrors the shell-out shape used by ``brain.ingest.gmail._run`` but routes
|
|
4
|
+
all expected subprocess failures (missing binary, non-zero exit, timeout,
|
|
5
|
+
unexpected ``OSError``) through :class:`brain.errors.DirectoryRefreshError`
|
|
6
|
+
so the refresh helpers in ``directory.py`` can downgrade them to soft
|
|
7
|
+
warnings (``refresh_calendar`` / ``refresh_contacts`` already catch
|
|
8
|
+
``DirectoryRefreshError`` from the runner).
|
|
9
|
+
|
|
10
|
+
CLAUDE.md security rules: explicit timeout (30s), narrow exception catch,
|
|
11
|
+
no ``shell=True``, output truncation in error messages so logs stay
|
|
12
|
+
readable when ``gws`` chatters on stderr.
|
|
13
|
+
"""
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
|
|
17
|
+
from brain.errors import DirectoryRefreshError
|
|
18
|
+
|
|
19
|
+
# Hard cap on stderr included in the translated error message — keeps logs
|
|
20
|
+
# readable when ``gws`` panics with a long Python traceback.
|
|
21
|
+
_STDERR_SNIPPET_LIMIT = 200
|
|
22
|
+
|
|
23
|
+
# Default subprocess timeout. ``gws`` calendar / people calls are I/O bound
|
|
24
|
+
# against Google APIs; 30s is generous for a single page.
|
|
25
|
+
_DEFAULT_TIMEOUT_SECONDS = 30
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def real_gws_runner(args: list[str]) -> str:
|
|
29
|
+
"""Shell out to the ``gws`` CLI; return stdout, translate failures.
|
|
30
|
+
|
|
31
|
+
Translates ``FileNotFoundError`` (gws missing), ``CalledProcessError``
|
|
32
|
+
(non-zero exit), ``TimeoutExpired`` (timeout), and any other
|
|
33
|
+
``OSError`` into :class:`DirectoryRefreshError` so the refresh helpers
|
|
34
|
+
in ``directory.py`` log them as warnings and return 0.
|
|
35
|
+
|
|
36
|
+
Conforms to the :class:`brain.vault.derived_links.directory.GwsRunner`
|
|
37
|
+
Protocol so it can be passed straight into
|
|
38
|
+
``refresh_calendar`` / ``refresh_contacts``.
|
|
39
|
+
"""
|
|
40
|
+
if not args:
|
|
41
|
+
raise DirectoryRefreshError("gws runner invoked with empty args")
|
|
42
|
+
if not shutil.which(args[0]):
|
|
43
|
+
raise DirectoryRefreshError(f"`{args[0]}` CLI not found on PATH")
|
|
44
|
+
try:
|
|
45
|
+
proc = subprocess.run(
|
|
46
|
+
args,
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
check=True,
|
|
50
|
+
timeout=_DEFAULT_TIMEOUT_SECONDS,
|
|
51
|
+
)
|
|
52
|
+
except subprocess.CalledProcessError as exc:
|
|
53
|
+
stderr_snippet = (exc.stderr or "").strip()[:_STDERR_SNIPPET_LIMIT]
|
|
54
|
+
raise DirectoryRefreshError(
|
|
55
|
+
f"gws command failed (exit {exc.returncode}): {stderr_snippet}"
|
|
56
|
+
) from exc
|
|
57
|
+
except subprocess.TimeoutExpired as exc:
|
|
58
|
+
raise DirectoryRefreshError(
|
|
59
|
+
f"gws command timed out after {_DEFAULT_TIMEOUT_SECONDS}s: "
|
|
60
|
+
f"{' '.join(args)}"
|
|
61
|
+
) from exc
|
|
62
|
+
except OSError as exc:
|
|
63
|
+
raise DirectoryRefreshError(f"gws command failed: {exc}") from exc
|
|
64
|
+
return proc.stdout
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Pure participant-extraction helpers — Krisp speaker labels + Gmail headers."""
|
|
2
|
+
import re
|
|
3
|
+
from email.utils import getaddresses
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
# Krisp writes unidentified speakers as ``Speaker <digits>`` (real transcripts,
|
|
7
|
+
# space separator) or ``Speaker_<digits>`` (legacy / synthetic test data,
|
|
8
|
+
# underscore). Either form is dropped because it would over-link unrelated
|
|
9
|
+
# calls — every transcript starts numbering at 1, so ``Speaker 2`` from one
|
|
10
|
+
# call has nothing to do with ``Speaker 2`` from another.
|
|
11
|
+
_SPEAKER_PLACEHOLDER_RE = re.compile(r"^speaker[ _]\d+$")
|
|
12
|
+
|
|
13
|
+
# Krisp inline speaker label: ``**<name-or-email> | mm:ss**`` (or ``H:MM:SS``
|
|
14
|
+
# for calls over an hour). Capture the label text only. The optional
|
|
15
|
+
# ``(?:\d{1,2}:)?`` head allows the H prefix without forcing it.
|
|
16
|
+
_KRISP_SPEAKER_RE = re.compile(
|
|
17
|
+
r"\*\*([^|*]+?)\s*\|\s*(?:\d{1,2}:)?\d{1,2}:\d{2}\s*\*\*",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Internal-whitespace collapse used by name normalization.
|
|
21
|
+
_WHITESPACE_RE = re.compile(r"\s+")
|
|
22
|
+
|
|
23
|
+
# Single-letter "names" (A, J) are too noisy to link on; require at least two
|
|
24
|
+
# characters after normalization.
|
|
25
|
+
_MIN_NAME_LENGTH = 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_email_like(addr: str) -> bool:
|
|
29
|
+
"""Heuristic: exactly one ``@``, both halves non-empty, RHS has a ``.``, no whitespace."""
|
|
30
|
+
if any(ch.isspace() for ch in addr):
|
|
31
|
+
return False
|
|
32
|
+
if addr.count("@") != 1:
|
|
33
|
+
return False
|
|
34
|
+
local, _, domain = addr.partition("@")
|
|
35
|
+
if not local or not domain:
|
|
36
|
+
return False
|
|
37
|
+
return "." in domain
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalize_email(addr: str) -> str | None:
|
|
41
|
+
"""Strip surrounding angle brackets + whitespace, lowercase, validate.
|
|
42
|
+
|
|
43
|
+
Brackets are stripped independently — a token with only a leading ``<`` or
|
|
44
|
+
only a trailing ``>`` still has the stray bracket removed before
|
|
45
|
+
validation. ``is_email_like`` is the final gate.
|
|
46
|
+
"""
|
|
47
|
+
cleaned = addr.strip().lower()
|
|
48
|
+
if cleaned.startswith("<"):
|
|
49
|
+
cleaned = cleaned[1:]
|
|
50
|
+
if cleaned.endswith(">"):
|
|
51
|
+
cleaned = cleaned[:-1]
|
|
52
|
+
cleaned = cleaned.strip()
|
|
53
|
+
if not is_email_like(cleaned):
|
|
54
|
+
return None
|
|
55
|
+
return cleaned
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def normalize_participant(token: str) -> str | None:
|
|
59
|
+
"""Lowercase + strip; return email if `@` present, normalized name otherwise.
|
|
60
|
+
|
|
61
|
+
Returns None for empty / Speaker_N / clearly-malformed tokens.
|
|
62
|
+
"""
|
|
63
|
+
stripped = token.strip()
|
|
64
|
+
if not stripped:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
# Drop unidentified Krisp speakers (case-insensitive).
|
|
68
|
+
if _SPEAKER_PLACEHOLDER_RE.match(stripped.lower()):
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
# Email branch: any token containing ``@`` is treated as email.
|
|
72
|
+
if "@" in stripped:
|
|
73
|
+
return _normalize_email(stripped)
|
|
74
|
+
|
|
75
|
+
# Name branch: lowercase, collapse whitespace, strip outer punctuation.
|
|
76
|
+
lowered = stripped.lower()
|
|
77
|
+
collapsed = _WHITESPACE_RE.sub(" ", lowered)
|
|
78
|
+
# Strip leading/trailing punctuation (anything that's not alphanumeric or
|
|
79
|
+
# an internal space). We re-strip whitespace afterwards because removing
|
|
80
|
+
# punctuation can leave dangling spaces (``", Pat."`` → ``" pat "``).
|
|
81
|
+
name = collapsed.strip(" \t\n\r\f\v.,;:!?\"'()[]{}<>-_/\\|")
|
|
82
|
+
name = name.strip()
|
|
83
|
+
if len(name) < _MIN_NAME_LENGTH:
|
|
84
|
+
return None
|
|
85
|
+
return name
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def extract_krisp_speakers(body: str) -> set[str]:
|
|
89
|
+
"""Parse `**name-or-email | mm:ss**` labels from a Krisp transcript body.
|
|
90
|
+
|
|
91
|
+
Drops Speaker_N placeholders. Returns the set of normalized participant
|
|
92
|
+
keys (emails preferred, names where no email is present).
|
|
93
|
+
"""
|
|
94
|
+
if not body:
|
|
95
|
+
return set()
|
|
96
|
+
|
|
97
|
+
speakers: set[str] = set()
|
|
98
|
+
for match in _KRISP_SPEAKER_RE.finditer(body):
|
|
99
|
+
normalized = normalize_participant(match.group(1))
|
|
100
|
+
if normalized is not None:
|
|
101
|
+
speakers.add(normalized)
|
|
102
|
+
return speakers
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def extract_gmail_addresses(metadata: dict[str, Any]) -> list[tuple[str | None, str]]:
|
|
106
|
+
"""Use `email.utils.getaddresses` over metadata['from'] + metadata['to'].
|
|
107
|
+
|
|
108
|
+
Returns list of (display_name, email) tuples. display_name is None when
|
|
109
|
+
absent. Both elements are normalized (lowercased, stripped). Display
|
|
110
|
+
names are run through `normalize_participant` for cross-source matching
|
|
111
|
+
consistency — that drops outer punctuation and rejects sub-2-char or
|
|
112
|
+
Speaker_N values that would never resolve through the directory anyway.
|
|
113
|
+
"""
|
|
114
|
+
raw_strings: list[str] = []
|
|
115
|
+
for key in ("from", "to"):
|
|
116
|
+
value = metadata.get(key)
|
|
117
|
+
if isinstance(value, str) and value.strip():
|
|
118
|
+
raw_strings.append(value)
|
|
119
|
+
|
|
120
|
+
if not raw_strings:
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
seen_emails: set[str] = set()
|
|
124
|
+
pairs: list[tuple[str | None, str]] = []
|
|
125
|
+
for realname, addr in getaddresses(raw_strings):
|
|
126
|
+
email = (addr or "").strip().lower()
|
|
127
|
+
if not email or not is_email_like(email):
|
|
128
|
+
continue
|
|
129
|
+
if email in seen_emails:
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
display_raw = (realname or "").strip()
|
|
133
|
+
display: str | None
|
|
134
|
+
if display_raw:
|
|
135
|
+
collapsed = _WHITESPACE_RE.sub(" ", display_raw.lower())
|
|
136
|
+
display = normalize_participant(collapsed)
|
|
137
|
+
else:
|
|
138
|
+
display = None
|
|
139
|
+
|
|
140
|
+
seen_emails.add(email)
|
|
141
|
+
pairs.append((display, email))
|
|
142
|
+
|
|
143
|
+
return pairs
|