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,683 @@
|
|
|
1
|
+
"""Persistent name↔email directory + refresh logic for the metadata linker."""
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
import psycopg
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from brain.errors import DirectoryRefreshError
|
|
13
|
+
from brain.vault.derived_links.participants import (
|
|
14
|
+
extract_gmail_addresses,
|
|
15
|
+
is_email_like,
|
|
16
|
+
normalize_participant,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Sources accepted by ``directory_entries.source`` (mirrors the CHECK
|
|
22
|
+
# constraint in migration 005). Kept in sync with the migration.
|
|
23
|
+
_VALID_SOURCES: frozenset[str] = frozenset(
|
|
24
|
+
{"gmail", "calendar", "contacts", "people_yml"}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _score_directory_rows(
|
|
29
|
+
rows: Iterable[tuple[str, int, bool]],
|
|
30
|
+
*,
|
|
31
|
+
skip_ambiguous: bool = False,
|
|
32
|
+
) -> str | None:
|
|
33
|
+
"""Pick a canonical winner among candidate ``(target, total, is_people_yml)`` rows.
|
|
34
|
+
|
|
35
|
+
Shared scoring core for the directory layer — used by both
|
|
36
|
+
:meth:`DirectoryStore.resolve_name_to_email` (name → email) and the
|
|
37
|
+
per-person index built by :func:`brain.wiki.build_people.aggregate_people`
|
|
38
|
+
(name → primary email *and* email → canonical name). One helper keeps
|
|
39
|
+
the precedence rules from drifting between surfaces.
|
|
40
|
+
|
|
41
|
+
Precedence (identical across both call sites, deterministic regardless
|
|
42
|
+
of input row order):
|
|
43
|
+
|
|
44
|
+
1. ``people_yml`` rows win unconditionally. When multiple rows carry
|
|
45
|
+
``is_people_yml=True`` (caller-side bug — ``_people.yml`` should
|
|
46
|
+
map one name to one email), the alphabetically-first ``target``
|
|
47
|
+
wins so the same input always produces the same answer.
|
|
48
|
+
2. Otherwise: highest summed ``total`` count. The tail behaves per
|
|
49
|
+
``skip_ambiguous``:
|
|
50
|
+
|
|
51
|
+
- ``skip_ambiguous=False`` (default — used by the People Hub
|
|
52
|
+
aggregator where every person needs a primary email): ties
|
|
53
|
+
broken alphabetically by ``target``.
|
|
54
|
+
- ``skip_ambiguous=True`` (used by
|
|
55
|
+
:meth:`DirectoryStore.resolve_name_to_email`, which would
|
|
56
|
+
rather refuse than guess): a tie at the top total returns
|
|
57
|
+
``None``.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
rows: Iterable of ``(target, total, is_people_yml)`` triples.
|
|
61
|
+
``target`` is the candidate string (an email when scoring
|
|
62
|
+
"name → email", a display_name when scoring "email → name").
|
|
63
|
+
``total`` is the summed ``occurrence_count`` across sources.
|
|
64
|
+
``is_people_yml`` is True iff at least one underlying row
|
|
65
|
+
for this candidate has ``source='people_yml'``.
|
|
66
|
+
skip_ambiguous: See above. Default ``False``.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The winning ``target`` string, or ``None`` when the input is
|
|
70
|
+
empty or — only with ``skip_ambiguous=True`` — when no clear
|
|
71
|
+
winner exists at the top total.
|
|
72
|
+
"""
|
|
73
|
+
materialized = list(rows)
|
|
74
|
+
if not materialized:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
people_yml_targets = sorted(
|
|
78
|
+
target for target, _total, is_yml in materialized if is_yml
|
|
79
|
+
)
|
|
80
|
+
if people_yml_targets:
|
|
81
|
+
return people_yml_targets[0]
|
|
82
|
+
|
|
83
|
+
ranked = sorted(materialized, key=lambda row: (-row[1], row[0]))
|
|
84
|
+
if (
|
|
85
|
+
skip_ambiguous
|
|
86
|
+
and len(ranked) > 1
|
|
87
|
+
and ranked[0][1] == ranked[1][1]
|
|
88
|
+
):
|
|
89
|
+
return None
|
|
90
|
+
return ranked[0][0]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class GwsRunner(Protocol):
|
|
94
|
+
"""Subprocess shell-out for the `gws` CLI used to mine Calendar / Contacts.
|
|
95
|
+
|
|
96
|
+
Production runner shells out to `gws` (must be on PATH); tests pass a
|
|
97
|
+
fake. See `src/brain/ingest/gmail.py:179` for the parallel pattern,
|
|
98
|
+
but note: that pattern raises ``GmailError`` — the refresh helpers
|
|
99
|
+
here only catch ``(OSError, DirectoryRefreshError, RuntimeError)``.
|
|
100
|
+
|
|
101
|
+
**Implementations must raise ``DirectoryRefreshError``, ``OSError``,
|
|
102
|
+
or ``RuntimeError`` on failure.** ``subprocess.CalledProcessError``
|
|
103
|
+
must be translated by the runner — it is NOT caught by the refresh
|
|
104
|
+
helpers and will propagate.
|
|
105
|
+
|
|
106
|
+
Implementations should return the raw stdout of the gws command
|
|
107
|
+
(typically JSON). The refresh helpers invoke the real ``gws`` CLI as:
|
|
108
|
+
|
|
109
|
+
- ``gws calendar events list --params <JSON> --format json --page-all``
|
|
110
|
+
where ``<JSON>`` carries Google Calendar API params
|
|
111
|
+
(``calendarId``, ``timeMin``, ``timeMax``, ``singleEvents``,
|
|
112
|
+
``maxResults``). Each NDJSON page is shaped
|
|
113
|
+
``{"items": [{"summary": ..., "attendees": [{"email": ...,
|
|
114
|
+
"displayName": ...}], ...}, ...], "nextPageToken": ...}``.
|
|
115
|
+
- ``gws people otherContacts list --params <JSON> --format json --page-all``
|
|
116
|
+
where ``<JSON>`` carries People API params (``readMask``,
|
|
117
|
+
``pageSize``). Each NDJSON page is shaped
|
|
118
|
+
``{"otherContacts": [{"names": [{"displayName": ...}],
|
|
119
|
+
"emailAddresses": [{"value": ...}, ...]}, ...], "nextPageToken": ...}``.
|
|
120
|
+
|
|
121
|
+
The refresh helpers parse three accepted shapes from a runner's
|
|
122
|
+
stdout (see ``_parse_gws_pages``): NDJSON pages, a single JSON
|
|
123
|
+
response with the page key, and — for backwards compatibility with
|
|
124
|
+
tests — a top-level JSON list of records.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __call__(self, args: list[str]) -> str: ...
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class DirectoryStore:
|
|
131
|
+
"""Read/write interface over `directory_entries` + `directory_refresh_state`.
|
|
132
|
+
|
|
133
|
+
Bridges Krisp's name-only speaker labels with Gmail's email-only headers
|
|
134
|
+
by remembering `(display_name, email)` co-occurrences mined from Gmail
|
|
135
|
+
metadata, Calendar invites, Contacts, and `_people.yml`.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, conn: psycopg.Connection[Any]) -> None:
|
|
139
|
+
self._conn = conn
|
|
140
|
+
|
|
141
|
+
def upsert_pair(
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
display_name: str | None,
|
|
145
|
+
email: str,
|
|
146
|
+
source: str,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Insert or bump occurrence_count for (display_name, email, source).
|
|
149
|
+
|
|
150
|
+
`display_name` may be None (bare-email Gmail headers); the row is
|
|
151
|
+
still recorded with display_name='' for indexability — resolution
|
|
152
|
+
helpers ignore empty-name rows.
|
|
153
|
+
"""
|
|
154
|
+
if source not in _VALID_SOURCES:
|
|
155
|
+
raise ValueError(f"invalid source: {source!r}")
|
|
156
|
+
|
|
157
|
+
normalized_name: str | None = (
|
|
158
|
+
None if display_name is None else normalize_participant(display_name)
|
|
159
|
+
)
|
|
160
|
+
# Empty-name rows are still indexable but will be skipped by name
|
|
161
|
+
# resolution; canonical "no display name" stored as ''.
|
|
162
|
+
name_value = normalized_name if normalized_name else ""
|
|
163
|
+
|
|
164
|
+
normalized_email = email.strip().lower()
|
|
165
|
+
if not normalized_email:
|
|
166
|
+
raise ValueError("email cannot be empty")
|
|
167
|
+
|
|
168
|
+
self._conn.execute(
|
|
169
|
+
"""
|
|
170
|
+
INSERT INTO directory_entries (display_name, email, source)
|
|
171
|
+
VALUES (%s, %s, %s)
|
|
172
|
+
ON CONFLICT (display_name, email, source) DO UPDATE SET
|
|
173
|
+
occurrence_count = directory_entries.occurrence_count + 1,
|
|
174
|
+
last_seen_at = NOW()
|
|
175
|
+
""",
|
|
176
|
+
(name_value, normalized_email, source),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def resolve_name_to_email(self, name: str) -> str | None:
|
|
180
|
+
"""Return canonical email for `name`, or None if zero / multiple matches.
|
|
181
|
+
|
|
182
|
+
`_people.yml` overrides win; otherwise prefer entries where exactly
|
|
183
|
+
one (display_name, email) pair has highest occurrence_count across
|
|
184
|
+
sources. Returns None on ambiguity.
|
|
185
|
+
|
|
186
|
+
Precedence rules are evaluated by :func:`_score_directory_rows`
|
|
187
|
+
with ``skip_ambiguous=True`` so a tied top total refuses to guess
|
|
188
|
+
— same helper backs the People Hub aggregator's primary-email
|
|
189
|
+
picker, keeping the two surfaces in lockstep.
|
|
190
|
+
"""
|
|
191
|
+
normalized = normalize_participant(name)
|
|
192
|
+
if not normalized:
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
rows = self._conn.execute(
|
|
196
|
+
"""
|
|
197
|
+
SELECT email,
|
|
198
|
+
SUM(occurrence_count) AS total,
|
|
199
|
+
BOOL_OR(source = 'people_yml') AS has_people_yml
|
|
200
|
+
FROM directory_entries
|
|
201
|
+
WHERE display_name = %s AND display_name <> ''
|
|
202
|
+
GROUP BY email
|
|
203
|
+
""",
|
|
204
|
+
(normalized,),
|
|
205
|
+
).fetchall()
|
|
206
|
+
|
|
207
|
+
return _score_directory_rows(
|
|
208
|
+
(
|
|
209
|
+
(str(email), int(total), bool(has_people_yml))
|
|
210
|
+
for email, total, has_people_yml in rows
|
|
211
|
+
),
|
|
212
|
+
skip_ambiguous=True,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def all_emails(self) -> set[str]:
|
|
216
|
+
"""Return the set of every email seen in any directory row.
|
|
217
|
+
|
|
218
|
+
Used by the linker pass for fast membership checks.
|
|
219
|
+
"""
|
|
220
|
+
rows = self._conn.execute(
|
|
221
|
+
"SELECT DISTINCT email FROM directory_entries"
|
|
222
|
+
).fetchall()
|
|
223
|
+
return {str(r[0]) for r in rows}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def load_people_yml(vault_path: Path) -> dict[str, str]:
|
|
227
|
+
"""Parse `<vault_path>/_people.yml` if present; return {} otherwise.
|
|
228
|
+
|
|
229
|
+
Schema: `Display Name: canonical@example.com` per line. Returns a mapping
|
|
230
|
+
from normalized lowercase name to lowercase email. Missing file is not
|
|
231
|
+
an error.
|
|
232
|
+
"""
|
|
233
|
+
yml_path = vault_path / "_people.yml"
|
|
234
|
+
if not yml_path.exists():
|
|
235
|
+
return {}
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
with yml_path.open("r", encoding="utf-8") as fh:
|
|
239
|
+
data = yaml.safe_load(fh)
|
|
240
|
+
except yaml.YAMLError as exc:
|
|
241
|
+
_logger.warning("malformed _people.yml at %s: %s", yml_path, exc)
|
|
242
|
+
return {}
|
|
243
|
+
except OSError as exc:
|
|
244
|
+
_logger.warning("could not read _people.yml at %s: %s", yml_path, exc)
|
|
245
|
+
return {}
|
|
246
|
+
|
|
247
|
+
if data is None:
|
|
248
|
+
# Empty file — yaml.safe_load returns None. Treat as empty mapping.
|
|
249
|
+
return {}
|
|
250
|
+
|
|
251
|
+
if not isinstance(data, dict):
|
|
252
|
+
_logger.warning(
|
|
253
|
+
"_people.yml at %s: expected top-level mapping, got %s",
|
|
254
|
+
yml_path,
|
|
255
|
+
type(data).__name__,
|
|
256
|
+
)
|
|
257
|
+
return {}
|
|
258
|
+
|
|
259
|
+
result: dict[str, str] = {}
|
|
260
|
+
for raw_name, raw_email in data.items():
|
|
261
|
+
if not isinstance(raw_name, str) or not isinstance(raw_email, str):
|
|
262
|
+
_logger.warning(
|
|
263
|
+
"_people.yml: skipping non-string entry %r -> %r",
|
|
264
|
+
raw_name,
|
|
265
|
+
raw_email,
|
|
266
|
+
)
|
|
267
|
+
continue
|
|
268
|
+
normalized_name = normalize_participant(raw_name)
|
|
269
|
+
if not normalized_name:
|
|
270
|
+
_logger.warning("_people.yml: skipping unnormalizable name %r", raw_name)
|
|
271
|
+
continue
|
|
272
|
+
normalized_email = raw_email.strip().lower()
|
|
273
|
+
if not is_email_like(normalized_email):
|
|
274
|
+
_logger.warning(
|
|
275
|
+
"_people.yml: skipping invalid email for %r: %r",
|
|
276
|
+
raw_name,
|
|
277
|
+
raw_email,
|
|
278
|
+
)
|
|
279
|
+
continue
|
|
280
|
+
result[normalized_name] = normalized_email
|
|
281
|
+
return result
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def refresh_people_yml(
|
|
285
|
+
conn: psycopg.Connection[Any], vault_path: Path
|
|
286
|
+
) -> int:
|
|
287
|
+
"""Replace all ``source='people_yml'`` rows from ``<vault_path>/_people.yml``.
|
|
288
|
+
|
|
289
|
+
Authoritative semantics: the YAML file is the source of truth for the
|
|
290
|
+
``people_yml`` source. Every refresh drops the existing rows and
|
|
291
|
+
re-inserts the file's current contents — so removing an entry from
|
|
292
|
+
``_people.yml`` removes it from the directory on the next refresh.
|
|
293
|
+
|
|
294
|
+
Missing file → 0 rows kept (any prior rows are wiped). Malformed file
|
|
295
|
+
is treated identically: ``load_people_yml`` already logs a warning,
|
|
296
|
+
and the file's effective content is empty, so the directory follows.
|
|
297
|
+
|
|
298
|
+
No ``directory_refresh_state`` row is written — ``people_yml`` is
|
|
299
|
+
excluded from ``_REFRESH_STATE_SOURCES`` (it has no refresh cadence;
|
|
300
|
+
it's loaded fresh every call).
|
|
301
|
+
|
|
302
|
+
Returns the number of pairs loaded.
|
|
303
|
+
"""
|
|
304
|
+
pairs = load_people_yml(vault_path)
|
|
305
|
+
conn.execute(
|
|
306
|
+
"DELETE FROM directory_entries WHERE source = %s", ("people_yml",)
|
|
307
|
+
)
|
|
308
|
+
if not pairs:
|
|
309
|
+
return 0
|
|
310
|
+
store = DirectoryStore(conn)
|
|
311
|
+
for name, email in pairs.items():
|
|
312
|
+
store.upsert_pair(
|
|
313
|
+
display_name=name, email=email, source="people_yml"
|
|
314
|
+
)
|
|
315
|
+
return len(pairs)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
_REFRESH_STATE_SOURCES: frozenset[str] = frozenset({"gmail", "calendar", "contacts"})
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _parse_gws_pages(raw: str, *, key: str) -> list[Any] | None:
|
|
322
|
+
"""Normalize ``gws`` stdout into a flat list of records under ``key``.
|
|
323
|
+
|
|
324
|
+
Production ``gws`` emits one Google API JSON response per page; with
|
|
325
|
+
``--page-all`` those pages are concatenated as NDJSON (one object
|
|
326
|
+
per line). Without ``--page-all`` a single JSON object is returned.
|
|
327
|
+
Each page wraps its records under a service-specific key
|
|
328
|
+
(``items`` for Calendar's ``events.list``, ``otherContacts`` for
|
|
329
|
+
People's ``otherContacts.list``).
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
- ``[]`` for an empty / whitespace-only response.
|
|
333
|
+
- The flattened list of records on success.
|
|
334
|
+
- ``None`` when the response can't be parsed at all (malformed
|
|
335
|
+
JSON) or doesn't carry any recognized shape — caller logs
|
|
336
|
+
a warning and aborts the refresh.
|
|
337
|
+
|
|
338
|
+
Backwards compatibility: a top-level JSON list is also accepted and
|
|
339
|
+
returned as-is. This was the documented shape in the original Task
|
|
340
|
+
B.1 spec (before real ``gws`` subcommands were known) and remains
|
|
341
|
+
convenient for fake-runner tests that don't want to wrap fixtures
|
|
342
|
+
in a ``{"items": …}`` envelope.
|
|
343
|
+
"""
|
|
344
|
+
stripped = raw.strip()
|
|
345
|
+
if not stripped:
|
|
346
|
+
return []
|
|
347
|
+
|
|
348
|
+
# Single-document JSON path (covers no-pagination output and the
|
|
349
|
+
# legacy top-level-list shape used by tests).
|
|
350
|
+
try:
|
|
351
|
+
parsed = json.loads(stripped)
|
|
352
|
+
except json.JSONDecodeError:
|
|
353
|
+
# Fall through to the NDJSON path below.
|
|
354
|
+
parsed = None
|
|
355
|
+
|
|
356
|
+
if isinstance(parsed, list):
|
|
357
|
+
return parsed
|
|
358
|
+
if isinstance(parsed, dict):
|
|
359
|
+
records = parsed.get(key)
|
|
360
|
+
if isinstance(records, list):
|
|
361
|
+
return records
|
|
362
|
+
# Recognized as JSON but missing the expected page key — treat
|
|
363
|
+
# as an unrecoverable shape so the caller warns.
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
# NDJSON fallback: one JSON page per line.
|
|
367
|
+
accumulated: list[Any] = []
|
|
368
|
+
saw_any_page = False
|
|
369
|
+
for line in stripped.splitlines():
|
|
370
|
+
chunk = line.strip()
|
|
371
|
+
if not chunk:
|
|
372
|
+
continue
|
|
373
|
+
try:
|
|
374
|
+
page = json.loads(chunk)
|
|
375
|
+
except json.JSONDecodeError:
|
|
376
|
+
return None
|
|
377
|
+
saw_any_page = True
|
|
378
|
+
if not isinstance(page, dict):
|
|
379
|
+
return None
|
|
380
|
+
records = page.get(key)
|
|
381
|
+
if isinstance(records, list):
|
|
382
|
+
accumulated.extend(records)
|
|
383
|
+
# Pages that lack the key entirely (e.g. an error envelope) are
|
|
384
|
+
# treated as zero records but don't fail the whole refresh — gws
|
|
385
|
+
# itself raises on errors via the runner's CalledProcessError
|
|
386
|
+
# translation.
|
|
387
|
+
if not saw_any_page:
|
|
388
|
+
# ``parsed is None`` and no NDJSON lines parsed — truly garbage.
|
|
389
|
+
return None
|
|
390
|
+
return accumulated
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _update_refresh_state(
|
|
394
|
+
conn: psycopg.Connection[Any], *, source: str, records_seen: int
|
|
395
|
+
) -> None:
|
|
396
|
+
"""Insert / update the high-water mark for ``source``.
|
|
397
|
+
|
|
398
|
+
On conflict the running ``records_seen`` counter is incremented so each
|
|
399
|
+
refresh contributes to the lifetime tally; ``last_refreshed_at`` is
|
|
400
|
+
always set to ``NOW()``.
|
|
401
|
+
|
|
402
|
+
Mirrors the CHECK constraint on ``directory_refresh_state.source`` from
|
|
403
|
+
migration 005:55 — ``people_yml`` is rejected here (it has no refresh
|
|
404
|
+
cadence). Raising early keeps the failure local instead of surfacing
|
|
405
|
+
as a Postgres ``IntegrityError`` from the INSERT.
|
|
406
|
+
"""
|
|
407
|
+
if source not in _REFRESH_STATE_SOURCES:
|
|
408
|
+
raise ValueError(
|
|
409
|
+
f"invalid refresh-state source {source!r}; "
|
|
410
|
+
"see migration 005's CHECK constraint"
|
|
411
|
+
)
|
|
412
|
+
conn.execute(
|
|
413
|
+
"""
|
|
414
|
+
INSERT INTO directory_refresh_state (source, last_refreshed_at, records_seen)
|
|
415
|
+
VALUES (%s, NOW(), %s)
|
|
416
|
+
ON CONFLICT (source) DO UPDATE SET
|
|
417
|
+
last_refreshed_at = NOW(),
|
|
418
|
+
records_seen = directory_refresh_state.records_seen + EXCLUDED.records_seen
|
|
419
|
+
""",
|
|
420
|
+
(source, records_seen),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def refresh_calendar(
|
|
425
|
+
conn: psycopg.Connection[Any],
|
|
426
|
+
*,
|
|
427
|
+
since: datetime,
|
|
428
|
+
until: datetime,
|
|
429
|
+
runner: GwsRunner,
|
|
430
|
+
) -> int:
|
|
431
|
+
"""Mine name↔email pairs from Google Calendar via the `gws` CLI.
|
|
432
|
+
|
|
433
|
+
On any subprocess / JSON failure the error is logged at WARNING level
|
|
434
|
+
and the function returns 0 — the linker keeps running on a stale
|
|
435
|
+
directory rather than failing the whole sync. Successful empty
|
|
436
|
+
refreshes still bump ``last_refreshed_at`` so the high-water mark
|
|
437
|
+
advances even when no events match.
|
|
438
|
+
|
|
439
|
+
Production calls ``gws calendar events list --params <JSON> --format
|
|
440
|
+
json --page-all``: the params JSON carries ``calendarId``,
|
|
441
|
+
``timeMin``, ``timeMax``, ``singleEvents`` and ``maxResults`` as
|
|
442
|
+
Google Calendar API expects. ``--page-all`` produces NDJSON, one
|
|
443
|
+
page per line; :func:`_parse_gws_pages` flattens that into a single
|
|
444
|
+
list of event records.
|
|
445
|
+
"""
|
|
446
|
+
store = DirectoryStore(conn)
|
|
447
|
+
params = json.dumps(
|
|
448
|
+
{
|
|
449
|
+
"calendarId": "primary",
|
|
450
|
+
"timeMin": since.isoformat(),
|
|
451
|
+
"timeMax": until.isoformat(),
|
|
452
|
+
"singleEvents": True,
|
|
453
|
+
"maxResults": 250,
|
|
454
|
+
}
|
|
455
|
+
)
|
|
456
|
+
try:
|
|
457
|
+
raw = runner(
|
|
458
|
+
[
|
|
459
|
+
"gws",
|
|
460
|
+
"calendar",
|
|
461
|
+
"events",
|
|
462
|
+
"list",
|
|
463
|
+
"--params",
|
|
464
|
+
params,
|
|
465
|
+
"--format",
|
|
466
|
+
"json",
|
|
467
|
+
"--page-all",
|
|
468
|
+
]
|
|
469
|
+
)
|
|
470
|
+
except (OSError, DirectoryRefreshError, RuntimeError) as exc:
|
|
471
|
+
_logger.warning(
|
|
472
|
+
"gws calendar refresh failed [%s..%s]: %s",
|
|
473
|
+
since.isoformat(),
|
|
474
|
+
until.isoformat(),
|
|
475
|
+
exc,
|
|
476
|
+
)
|
|
477
|
+
return 0
|
|
478
|
+
|
|
479
|
+
events = _parse_gws_pages(raw, key="items")
|
|
480
|
+
if events is None:
|
|
481
|
+
_logger.warning(
|
|
482
|
+
"gws calendar JSON parse failed [%s..%s]: "
|
|
483
|
+
"could not normalize gws stdout into events",
|
|
484
|
+
since.isoformat(),
|
|
485
|
+
until.isoformat(),
|
|
486
|
+
)
|
|
487
|
+
return 0
|
|
488
|
+
|
|
489
|
+
events_seen = 0
|
|
490
|
+
for event in events:
|
|
491
|
+
if not isinstance(event, dict):
|
|
492
|
+
continue
|
|
493
|
+
events_seen += 1
|
|
494
|
+
attendees = event.get("attendees") or []
|
|
495
|
+
if not isinstance(attendees, list):
|
|
496
|
+
continue
|
|
497
|
+
for attendee in attendees:
|
|
498
|
+
if not isinstance(attendee, dict):
|
|
499
|
+
continue
|
|
500
|
+
email = attendee.get("email")
|
|
501
|
+
if not isinstance(email, str) or not email.strip():
|
|
502
|
+
# Without an email there's nothing to upsert.
|
|
503
|
+
continue
|
|
504
|
+
display = attendee.get("displayName")
|
|
505
|
+
display_name = display if isinstance(display, str) else None
|
|
506
|
+
try:
|
|
507
|
+
store.upsert_pair(
|
|
508
|
+
display_name=display_name,
|
|
509
|
+
email=email,
|
|
510
|
+
source="calendar",
|
|
511
|
+
)
|
|
512
|
+
except ValueError:
|
|
513
|
+
# Bad email shape — skip, keep going. (Don't let one bad
|
|
514
|
+
# row poison the whole refresh.) Log the rejected email at
|
|
515
|
+
# DEBUG so operators can correlate; emails are IDs (not
|
|
516
|
+
# payload bodies) so this respects the CLAUDE.md log rule.
|
|
517
|
+
_logger.debug(
|
|
518
|
+
"calendar refresh: skipping attendee with invalid email: %s",
|
|
519
|
+
email or "<missing>",
|
|
520
|
+
)
|
|
521
|
+
continue
|
|
522
|
+
|
|
523
|
+
_update_refresh_state(conn, source="calendar", records_seen=events_seen)
|
|
524
|
+
return events_seen
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
_CONTACTS_CMD = "gws people otherContacts list --format json --page-all"
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def refresh_contacts(
|
|
531
|
+
conn: psycopg.Connection[Any],
|
|
532
|
+
*,
|
|
533
|
+
runner: GwsRunner,
|
|
534
|
+
) -> int:
|
|
535
|
+
"""Mine name↔email pairs from Google Contacts via the `gws` CLI.
|
|
536
|
+
|
|
537
|
+
Same error-handling contract as :func:`refresh_calendar`: any failure
|
|
538
|
+
logs a warning and returns 0 without updating the high-water mark.
|
|
539
|
+
|
|
540
|
+
Production calls ``gws people otherContacts list --params <JSON>
|
|
541
|
+
--format json --page-all``. ``otherContacts`` is preferred over
|
|
542
|
+
``people connections`` because it auto-collects every email-address
|
|
543
|
+
correspondent the user has interacted with — exactly the directory
|
|
544
|
+
we want to mine for the linker — without requiring the user to add
|
|
545
|
+
them as named contacts. Each NDJSON page is shaped
|
|
546
|
+
``{"otherContacts": [{"names": [{"displayName": ...}],
|
|
547
|
+
"emailAddresses": [{"value": ...}]}, ...]}``.
|
|
548
|
+
"""
|
|
549
|
+
store = DirectoryStore(conn)
|
|
550
|
+
params = json.dumps(
|
|
551
|
+
{
|
|
552
|
+
"readMask": "names,emailAddresses",
|
|
553
|
+
"pageSize": 1000,
|
|
554
|
+
}
|
|
555
|
+
)
|
|
556
|
+
try:
|
|
557
|
+
raw = runner(
|
|
558
|
+
[
|
|
559
|
+
"gws",
|
|
560
|
+
"people",
|
|
561
|
+
"otherContacts",
|
|
562
|
+
"list",
|
|
563
|
+
"--params",
|
|
564
|
+
params,
|
|
565
|
+
"--format",
|
|
566
|
+
"json",
|
|
567
|
+
"--page-all",
|
|
568
|
+
]
|
|
569
|
+
)
|
|
570
|
+
except (OSError, DirectoryRefreshError, RuntimeError) as exc:
|
|
571
|
+
_logger.warning(
|
|
572
|
+
"gws people refresh failed (cmd=%s): %s",
|
|
573
|
+
_CONTACTS_CMD,
|
|
574
|
+
exc,
|
|
575
|
+
)
|
|
576
|
+
return 0
|
|
577
|
+
|
|
578
|
+
contacts = _parse_gws_pages(raw, key="otherContacts")
|
|
579
|
+
if contacts is None:
|
|
580
|
+
_logger.warning(
|
|
581
|
+
"gws people JSON parse failed (cmd=%s): "
|
|
582
|
+
"could not normalize gws stdout into contacts",
|
|
583
|
+
_CONTACTS_CMD,
|
|
584
|
+
)
|
|
585
|
+
return 0
|
|
586
|
+
|
|
587
|
+
contacts_seen = 0
|
|
588
|
+
for contact in contacts:
|
|
589
|
+
if not isinstance(contact, dict):
|
|
590
|
+
continue
|
|
591
|
+
names = contact.get("names") or []
|
|
592
|
+
emails = contact.get("emailAddresses") or []
|
|
593
|
+
if not isinstance(names, list) or not isinstance(emails, list):
|
|
594
|
+
continue
|
|
595
|
+
|
|
596
|
+
# Pick the first display name; Google People returns names ordered
|
|
597
|
+
# by primacy.
|
|
598
|
+
display: str | None = None
|
|
599
|
+
for name_obj in names:
|
|
600
|
+
if isinstance(name_obj, dict):
|
|
601
|
+
candidate = name_obj.get("displayName")
|
|
602
|
+
if isinstance(candidate, str) and candidate.strip():
|
|
603
|
+
display = candidate
|
|
604
|
+
break
|
|
605
|
+
|
|
606
|
+
# A contact with no usable email contributes nothing.
|
|
607
|
+
upserted = False
|
|
608
|
+
for email_obj in emails:
|
|
609
|
+
if not isinstance(email_obj, dict):
|
|
610
|
+
continue
|
|
611
|
+
value = email_obj.get("value")
|
|
612
|
+
if not isinstance(value, str) or not value.strip():
|
|
613
|
+
continue
|
|
614
|
+
try:
|
|
615
|
+
store.upsert_pair(
|
|
616
|
+
display_name=display,
|
|
617
|
+
email=value,
|
|
618
|
+
source="contacts",
|
|
619
|
+
)
|
|
620
|
+
except ValueError:
|
|
621
|
+
# Same belt-and-suspenders catch as refresh_calendar — see
|
|
622
|
+
# the comment there for rationale. DEBUG-log the rejected
|
|
623
|
+
# email value (it's an ID, not a payload body).
|
|
624
|
+
_logger.debug(
|
|
625
|
+
"contacts refresh: skipping email with invalid shape: %s",
|
|
626
|
+
value or "<missing>",
|
|
627
|
+
)
|
|
628
|
+
continue
|
|
629
|
+
upserted = True
|
|
630
|
+
|
|
631
|
+
if upserted:
|
|
632
|
+
contacts_seen += 1
|
|
633
|
+
|
|
634
|
+
_update_refresh_state(conn, source="contacts", records_seen=contacts_seen)
|
|
635
|
+
return contacts_seen
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def rescan_gmail_directory(conn: psycopg.Connection[Any]) -> tuple[int, int]:
|
|
639
|
+
"""Re-walk every Gmail document and upsert (display, email) pairs.
|
|
640
|
+
|
|
641
|
+
Returns ``(docs_seen, pairs_upserted)``. Each (display, email) pair from
|
|
642
|
+
a single document's ``from``/``to`` headers contributes ``+1`` to the
|
|
643
|
+
pair count even when the upsert is a count bump (no row inserted) — the
|
|
644
|
+
metric matches the user-facing "pairs from N docs" line in the summary.
|
|
645
|
+
|
|
646
|
+
The whole rescan runs in one transaction. ``directory_entries`` rows
|
|
647
|
+
upserted earlier in this command (e.g. by a prior partial run) only have
|
|
648
|
+
their ``occurrence_count`` incremented, so re-running this is safe.
|
|
649
|
+
|
|
650
|
+
Shared between ``brain vault relink-derived`` (full corpus rebuild) and
|
|
651
|
+
``brain vault directory refresh`` (directory-only rebuild) — the linker
|
|
652
|
+
pass is intentionally NOT invoked from this helper, leaving rebuild
|
|
653
|
+
orchestration to the caller.
|
|
654
|
+
"""
|
|
655
|
+
rows = conn.execute(
|
|
656
|
+
"""
|
|
657
|
+
SELECT d.id::text, d.metadata
|
|
658
|
+
FROM documents d
|
|
659
|
+
JOIN sources s ON s.id = d.source_id
|
|
660
|
+
WHERE s.kind = 'gmail'
|
|
661
|
+
"""
|
|
662
|
+
).fetchall()
|
|
663
|
+
|
|
664
|
+
store = DirectoryStore(conn)
|
|
665
|
+
pairs = 0
|
|
666
|
+
with conn.transaction():
|
|
667
|
+
for _doc_id, metadata in rows:
|
|
668
|
+
for display_name, email in extract_gmail_addresses(
|
|
669
|
+
dict(metadata or {})
|
|
670
|
+
):
|
|
671
|
+
try:
|
|
672
|
+
store.upsert_pair(
|
|
673
|
+
display_name=display_name,
|
|
674
|
+
email=email,
|
|
675
|
+
source="gmail",
|
|
676
|
+
)
|
|
677
|
+
except ValueError:
|
|
678
|
+
# Defensive — extract_gmail_addresses already filters
|
|
679
|
+
# malformed shapes; a residual bad email would otherwise
|
|
680
|
+
# poison the whole rescan.
|
|
681
|
+
continue
|
|
682
|
+
pairs += 1
|
|
683
|
+
return len(rows), pairs
|