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,362 @@
|
|
|
1
|
+
"""Linker pass — rebuild derived_links rows for a set of touched documents."""
|
|
2
|
+
import datetime
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from email.utils import parsedate_to_datetime
|
|
6
|
+
from typing import Any, Literal, cast
|
|
7
|
+
|
|
8
|
+
import psycopg
|
|
9
|
+
|
|
10
|
+
from brain.vault.derived_links.directory import DirectoryStore
|
|
11
|
+
from brain.vault.derived_links.participants import extract_gmail_addresses
|
|
12
|
+
from brain.vault.derived_links.rules import (
|
|
13
|
+
DocSnapshot,
|
|
14
|
+
Evidence,
|
|
15
|
+
rule_same_day_participant,
|
|
16
|
+
rule_shared_participant,
|
|
17
|
+
rule_shared_thread,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Source kinds the linker considers. Manual / vault docs never carry the
|
|
23
|
+
# metadata shapes the rules read, so they're excluded from both the touched
|
|
24
|
+
# snapshot pass and the candidate pool.
|
|
25
|
+
_LINKABLE_SOURCE_KINDS: frozenset[str] = frozenset({"gmail", "krisp"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def rebuild_derived_for(
|
|
29
|
+
conn: psycopg.Connection[Any],
|
|
30
|
+
doc_ids: set[str],
|
|
31
|
+
*,
|
|
32
|
+
directory: DirectoryStore,
|
|
33
|
+
owner_participants: frozenset[str] = frozenset(),
|
|
34
|
+
) -> tuple[int, set[str]]:
|
|
35
|
+
"""Rebuild ``derived_links`` rows whose src or dst is in ``doc_ids``.
|
|
36
|
+
|
|
37
|
+
Steps (all in one transaction):
|
|
38
|
+
1. SELECT touched docs + their participant keys + dates.
|
|
39
|
+
2. SELECT every other Gmail/Krisp doc (candidate pool).
|
|
40
|
+
3. Compute candidate pairs (de-duplicated by canonical ordering); run
|
|
41
|
+
R1/R2/R3 against each.
|
|
42
|
+
4. R3 supersedes R2 for the same pair.
|
|
43
|
+
5. DELETE FROM derived_links WHERE src or dst IN doc_ids (capturing
|
|
44
|
+
the row endpoints so callers see partners that LOST an edge in
|
|
45
|
+
this pass too).
|
|
46
|
+
6. UPSERT the new edge set with ``(LEAST, GREATEST)`` ordering. The
|
|
47
|
+
INSERT uses ``ON CONFLICT (src, dst, rule) DO UPDATE`` so a
|
|
48
|
+
concurrent rebuild from another connection (``brain vault
|
|
49
|
+
sync --watch`` worker vs foreground ``brain vault sync``) cannot
|
|
50
|
+
race the INSERT into a UniqueViolation — the row simply gets its
|
|
51
|
+
``evidence`` + ``weight`` refreshed to the values this rebuild
|
|
52
|
+
computed.
|
|
53
|
+
|
|
54
|
+
Returns ``(inserted_count, affected_ids)``:
|
|
55
|
+
|
|
56
|
+
- ``inserted_count`` — number of ``derived_links`` rows inserted in
|
|
57
|
+
step 6.
|
|
58
|
+
- ``affected_ids`` — superset of ``doc_ids`` containing every endpoint
|
|
59
|
+
that had an edge added (step 6) OR removed (step 5). This is the
|
|
60
|
+
touched-set that downstream callers (Phase D's fence renderer)
|
|
61
|
+
iterate over to decide which ``_ingested/`` files need their fence
|
|
62
|
+
regenerated. Includes the input ``doc_ids`` even when no edges were
|
|
63
|
+
added or removed, so callers can rely on the input being a subset
|
|
64
|
+
of the output.
|
|
65
|
+
|
|
66
|
+
``owner_participants`` is the set of corpus-owner identifiers (emails
|
|
67
|
+
or display names, lowercased + trimmed at config-load time per
|
|
68
|
+
:class:`brain.config.Config.owner_participants`) that should be
|
|
69
|
+
stripped from each :class:`DocSnapshot.participant_keys` BEFORE rule
|
|
70
|
+
evaluation. Without this filter the owner is a participant on
|
|
71
|
+
essentially every doc, which floods R2 / R3 with noise edges keyed
|
|
72
|
+
on a single shared identity. The default (empty frozenset) is a
|
|
73
|
+
fast-path no-op and preserves the historical behaviour for tests
|
|
74
|
+
and any caller that hasn't opted in.
|
|
75
|
+
|
|
76
|
+
The set short-circuits to ``(0, set())`` when ``doc_ids`` is empty —
|
|
77
|
+
no DB round-trip, no transaction.
|
|
78
|
+
"""
|
|
79
|
+
if not doc_ids:
|
|
80
|
+
return 0, set()
|
|
81
|
+
|
|
82
|
+
# 1+2. Snapshot every linkable doc once. The corpus is small (~500 rows
|
|
83
|
+
# at full scale per the spec), so a single SELECT + Python-side hash
|
|
84
|
+
# join beats per-touched-doc round-trips. Joining to ``sources``
|
|
85
|
+
# keeps manual / vault rows out — they don't carry the metadata
|
|
86
|
+
# shapes the rule functions read.
|
|
87
|
+
rows = conn.execute(
|
|
88
|
+
"""
|
|
89
|
+
SELECT d.id::text, s.kind, d.metadata
|
|
90
|
+
FROM documents d
|
|
91
|
+
JOIN sources s ON s.id = d.source_id
|
|
92
|
+
WHERE s.kind = ANY(%s)
|
|
93
|
+
""",
|
|
94
|
+
(sorted(_LINKABLE_SOURCE_KINDS),),
|
|
95
|
+
).fetchall()
|
|
96
|
+
|
|
97
|
+
snapshots: dict[str, DocSnapshot] = {}
|
|
98
|
+
for row_id, source_kind, metadata in rows:
|
|
99
|
+
snap = _build_snapshot(
|
|
100
|
+
document_id=str(row_id),
|
|
101
|
+
source_kind=str(source_kind),
|
|
102
|
+
metadata=dict(metadata or {}),
|
|
103
|
+
directory=directory,
|
|
104
|
+
owner_participants=owner_participants,
|
|
105
|
+
)
|
|
106
|
+
snapshots[snap.document_id] = snap
|
|
107
|
+
|
|
108
|
+
# Touched docs that aren't linkable (manual / vault / missing) contribute
|
|
109
|
+
# no pairs. Any pre-existing edges with them are still removed by the
|
|
110
|
+
# DELETE below.
|
|
111
|
+
touched_in_corpus = {d for d in doc_ids if d in snapshots}
|
|
112
|
+
|
|
113
|
+
# 3+4. Walk the touched set, pair each against the rest of the corpus,
|
|
114
|
+
# de-dupe via canonical ordering, then apply rules.
|
|
115
|
+
seen_pairs: set[tuple[str, str]] = set()
|
|
116
|
+
pair_evidence: list[tuple[str, str, Evidence]] = []
|
|
117
|
+
|
|
118
|
+
for touched_id in touched_in_corpus:
|
|
119
|
+
a = snapshots[touched_id]
|
|
120
|
+
for other_id, b in snapshots.items():
|
|
121
|
+
if other_id == touched_id:
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
canonical = _canonical_pair(touched_id, other_id)
|
|
125
|
+
if canonical in seen_pairs:
|
|
126
|
+
continue
|
|
127
|
+
seen_pairs.add(canonical)
|
|
128
|
+
|
|
129
|
+
for evidence in _evaluate_pair(a, b):
|
|
130
|
+
pair_evidence.append((canonical[0], canonical[1], evidence))
|
|
131
|
+
|
|
132
|
+
# 5+6. DELETE then INSERT in one transaction. The DELETE scope is the
|
|
133
|
+
# original ``doc_ids`` set (not just ``touched_in_corpus``) so a
|
|
134
|
+
# caller that passes a now-deleted / kind-changed doc still has its
|
|
135
|
+
# stale edges cleared.
|
|
136
|
+
#
|
|
137
|
+
# The DELETE returns the endpoints of every removed row so the
|
|
138
|
+
# affected-set captures partners that LOST an edge in this pass.
|
|
139
|
+
# Without that, a fence renderer wouldn't know to regenerate the
|
|
140
|
+
# partner's "Related" section after a deletion.
|
|
141
|
+
doc_ids_list = list(doc_ids)
|
|
142
|
+
affected_ids: set[str] = set(doc_ids)
|
|
143
|
+
with conn.transaction():
|
|
144
|
+
deleted_rows = conn.execute(
|
|
145
|
+
"DELETE FROM derived_links "
|
|
146
|
+
"WHERE src_document_id = ANY(%s) OR dst_document_id = ANY(%s) "
|
|
147
|
+
"RETURNING src_document_id::text, dst_document_id::text",
|
|
148
|
+
(doc_ids_list, doc_ids_list),
|
|
149
|
+
).fetchall()
|
|
150
|
+
for src, dst in deleted_rows:
|
|
151
|
+
affected_ids.add(str(src))
|
|
152
|
+
affected_ids.add(str(dst))
|
|
153
|
+
for src, dst, evidence in pair_evidence:
|
|
154
|
+
# ``ON CONFLICT DO UPDATE`` makes the INSERT race-safe against a
|
|
155
|
+
# concurrent rebuild (e.g. ``brain vault sync --watch`` worker
|
|
156
|
+
# firing on a file event while a foreground ``brain vault sync``
|
|
157
|
+
# commits a fresh ``derived_links`` row in the same window).
|
|
158
|
+
# Without this, our DELETE-then-INSERT could DELETE a row, observe
|
|
159
|
+
# a concurrent INSERT of the same canonical pair from a sibling
|
|
160
|
+
# transaction, and crash on UniqueViolation. The semantics are
|
|
161
|
+
# preserved: rebuild's intent is "make these rows reflect the
|
|
162
|
+
# current snapshot", and an UPSERT does exactly that — refreshes
|
|
163
|
+
# ``evidence`` + ``weight`` to the freshly-computed values.
|
|
164
|
+
conn.execute(
|
|
165
|
+
"""
|
|
166
|
+
INSERT INTO derived_links
|
|
167
|
+
(src_document_id, dst_document_id, rule, evidence, weight)
|
|
168
|
+
VALUES (%s, %s, %s, %s::jsonb, %s)
|
|
169
|
+
ON CONFLICT (src_document_id, dst_document_id, rule)
|
|
170
|
+
DO UPDATE SET evidence = EXCLUDED.evidence,
|
|
171
|
+
weight = EXCLUDED.weight
|
|
172
|
+
""",
|
|
173
|
+
(
|
|
174
|
+
src,
|
|
175
|
+
dst,
|
|
176
|
+
evidence.rule,
|
|
177
|
+
json.dumps(evidence.payload),
|
|
178
|
+
evidence.weight,
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
affected_ids.add(src)
|
|
182
|
+
affected_ids.add(dst)
|
|
183
|
+
|
|
184
|
+
_logger.info(
|
|
185
|
+
"rebuilt %d derived edges across %d touched docs "
|
|
186
|
+
"(%d in corpus, %d affected ids)",
|
|
187
|
+
len(pair_evidence),
|
|
188
|
+
len(doc_ids),
|
|
189
|
+
len(touched_in_corpus),
|
|
190
|
+
len(affected_ids),
|
|
191
|
+
)
|
|
192
|
+
return len(pair_evidence), affected_ids
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _canonical_pair(a: str, b: str) -> tuple[str, str]:
|
|
196
|
+
"""Return ``(LEAST, GREATEST)`` so the same unordered pair always orders identically.
|
|
197
|
+
|
|
198
|
+
Mirrors the SQL ``LEAST(src, dst) / GREATEST(src, dst)`` canonicalization
|
|
199
|
+
that backstops ``derived_links``' ``UNIQUE (src, dst, rule)`` constraint.
|
|
200
|
+
"""
|
|
201
|
+
return (a, b) if a < b else (b, a)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _evaluate_pair(a: DocSnapshot, b: DocSnapshot) -> list[Evidence]:
|
|
205
|
+
"""Run R1, R2, R3 against ``(a, b)`` and apply R3-supersedes-R2.
|
|
206
|
+
|
|
207
|
+
R1 and R3/R2 are independent rules and may co-exist on the same pair
|
|
208
|
+
(different ``derived_links.rule`` rows). Within the participant family,
|
|
209
|
+
R3 (same-day) is strictly stronger than R2 (no date constraint), so R2
|
|
210
|
+
is suppressed when R3 fires.
|
|
211
|
+
"""
|
|
212
|
+
evidences: list[Evidence] = []
|
|
213
|
+
|
|
214
|
+
r1 = rule_shared_thread(a, b)
|
|
215
|
+
if r1 is not None:
|
|
216
|
+
evidences.append(r1)
|
|
217
|
+
|
|
218
|
+
r3 = rule_same_day_participant(a, b)
|
|
219
|
+
if r3 is not None:
|
|
220
|
+
evidences.append(r3)
|
|
221
|
+
else:
|
|
222
|
+
r2 = rule_shared_participant(a, b)
|
|
223
|
+
if r2 is not None:
|
|
224
|
+
evidences.append(r2)
|
|
225
|
+
|
|
226
|
+
return evidences
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _build_snapshot(
|
|
230
|
+
*,
|
|
231
|
+
document_id: str,
|
|
232
|
+
source_kind: str,
|
|
233
|
+
metadata: dict[str, Any],
|
|
234
|
+
directory: DirectoryStore,
|
|
235
|
+
owner_participants: frozenset[str] = frozenset(),
|
|
236
|
+
) -> DocSnapshot:
|
|
237
|
+
"""Project a DB row into a :class:`DocSnapshot` for rule evaluation.
|
|
238
|
+
|
|
239
|
+
Gmail keys are derived from ``from``/``to`` headers via
|
|
240
|
+
:func:`extract_gmail_addresses`, with each ``(display, email)`` pair
|
|
241
|
+
contributing the email plus either the directory-resolved email for
|
|
242
|
+
``display`` or the normalized display name itself.
|
|
243
|
+
|
|
244
|
+
Krisp keys come from ``metadata['_participant_keys']`` (populated at
|
|
245
|
+
ingest time by :func:`brain.ingest._apply_pre_insert_metadata`). Name-only
|
|
246
|
+
keys are bridged to emails via :meth:`DirectoryStore.resolve_name_to_email`
|
|
247
|
+
so cross-source linking works without baking the directory into the
|
|
248
|
+
pre-insert step.
|
|
249
|
+
|
|
250
|
+
``owner_participants`` (lowercased at config load) is subtracted from the
|
|
251
|
+
final key set — emails AND display-name keys both run through the same
|
|
252
|
+
``key.lower() in owner_participants`` filter — so the corpus owner can't
|
|
253
|
+
create participant-overlap edges between every doc they're on. The
|
|
254
|
+
default (empty frozenset) skips the dict-comp entirely (zero overhead
|
|
255
|
+
for callers that haven't opted in).
|
|
256
|
+
"""
|
|
257
|
+
if source_kind == "gmail":
|
|
258
|
+
keys = _gmail_participant_keys(metadata, directory)
|
|
259
|
+
elif source_kind == "krisp":
|
|
260
|
+
keys = _krisp_participant_keys(metadata, directory)
|
|
261
|
+
else: # pragma: no cover - SELECT filter keeps us here
|
|
262
|
+
keys = set()
|
|
263
|
+
|
|
264
|
+
if owner_participants:
|
|
265
|
+
keys = {k for k in keys if k.lower() not in owner_participants}
|
|
266
|
+
|
|
267
|
+
return DocSnapshot(
|
|
268
|
+
document_id=document_id,
|
|
269
|
+
source_kind=cast(Literal["gmail", "krisp", "manual"], source_kind),
|
|
270
|
+
metadata=metadata,
|
|
271
|
+
participant_keys=frozenset(keys),
|
|
272
|
+
date=_parse_date(metadata.get("date"), source_kind=source_kind),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _gmail_participant_keys(
|
|
277
|
+
metadata: dict[str, Any], directory: DirectoryStore
|
|
278
|
+
) -> set[str]:
|
|
279
|
+
"""Build participant keys for a Gmail snapshot.
|
|
280
|
+
|
|
281
|
+
Each ``(display, email)`` pair contributes the email itself; the display
|
|
282
|
+
name (if present) is run through the directory — if it resolves to an
|
|
283
|
+
email, that email is added (catches the ``"person-x"`` → ``person-a@…`` bridge);
|
|
284
|
+
otherwise the normalized display name is added so a Krisp doc that only
|
|
285
|
+
knows the name can still match.
|
|
286
|
+
"""
|
|
287
|
+
keys: set[str] = set()
|
|
288
|
+
for display, email in extract_gmail_addresses(metadata):
|
|
289
|
+
keys.add(email)
|
|
290
|
+
if display:
|
|
291
|
+
resolved = directory.resolve_name_to_email(display)
|
|
292
|
+
keys.add(resolved if resolved else display)
|
|
293
|
+
return keys
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _krisp_participant_keys(
|
|
297
|
+
metadata: dict[str, Any], directory: DirectoryStore
|
|
298
|
+
) -> set[str]:
|
|
299
|
+
"""Build participant keys for a Krisp snapshot.
|
|
300
|
+
|
|
301
|
+
Reads ``metadata['_participant_keys']`` (sorted list, ingest-time output)
|
|
302
|
+
and bridges name-only keys to emails via the directory so a Krisp call
|
|
303
|
+
labeled ``**Pat Morgan | 0:01**`` can match a Gmail with
|
|
304
|
+
``from: "Pat Morgan <redacted@example.com>"``.
|
|
305
|
+
"""
|
|
306
|
+
raw = metadata.get("_participant_keys")
|
|
307
|
+
if not isinstance(raw, list):
|
|
308
|
+
return set()
|
|
309
|
+
|
|
310
|
+
keys: set[str] = set()
|
|
311
|
+
for entry in raw:
|
|
312
|
+
if not isinstance(entry, str):
|
|
313
|
+
continue
|
|
314
|
+
token = entry.strip()
|
|
315
|
+
if not token:
|
|
316
|
+
continue
|
|
317
|
+
if "@" in token:
|
|
318
|
+
keys.add(token)
|
|
319
|
+
continue
|
|
320
|
+
resolved = directory.resolve_name_to_email(token)
|
|
321
|
+
keys.add(resolved if resolved else token)
|
|
322
|
+
return keys
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _parse_date(
|
|
326
|
+
raw: Any, *, source_kind: str
|
|
327
|
+
) -> datetime.date | None:
|
|
328
|
+
"""Parse ``metadata['date']`` per source convention.
|
|
329
|
+
|
|
330
|
+
- Krisp stores ISO date strings (``2026-04-15`` or ``2026-04-15T12:00``)
|
|
331
|
+
passed through ``brain ingest-stdin --date``.
|
|
332
|
+
- Gmail stores RFC 5322 strings (``Wed, 15 Apr 2026 12:00:00 -0700``).
|
|
333
|
+
|
|
334
|
+
Returns ``None`` for missing, non-string, or unparseable values — R3
|
|
335
|
+
quietly degrades when the date is missing rather than crashing the pass.
|
|
336
|
+
"""
|
|
337
|
+
if not isinstance(raw, str):
|
|
338
|
+
return None
|
|
339
|
+
text = raw.strip()
|
|
340
|
+
if not text:
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
if source_kind == "krisp":
|
|
344
|
+
# ``date.fromisoformat`` is strict YYYY-MM-DD on 3.11+. Slicing the
|
|
345
|
+
# first 10 characters tolerates the ``YYYY-MM-DDTHH:MM:SS`` variant
|
|
346
|
+
# without pulling in ``datetime.fromisoformat``'s wider but still
|
|
347
|
+
# not-RFC-5322 grammar.
|
|
348
|
+
try:
|
|
349
|
+
return datetime.date.fromisoformat(text[:10])
|
|
350
|
+
except ValueError:
|
|
351
|
+
_logger.debug("krisp date parse failed: %r", text)
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
# Gmail (and any future RFC-5322 source).
|
|
355
|
+
try:
|
|
356
|
+
parsed = parsedate_to_datetime(text)
|
|
357
|
+
except (TypeError, ValueError):
|
|
358
|
+
_logger.debug("gmail date parse failed: %r", text)
|
|
359
|
+
return None
|
|
360
|
+
if parsed is None:
|
|
361
|
+
return None
|
|
362
|
+
return parsed.date()
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Pure rule functions for the metadata-aware linker — R1, R2, R3."""
|
|
2
|
+
import datetime
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
# Confidence weights for the three derived-link rules. Stored on every
|
|
7
|
+
# `derived_links.weight` insert so formatters can tier-style edges
|
|
8
|
+
# (spec §Q3). Constrained by migration 005's `CHECK (weight >= 0 AND weight <= 1)`.
|
|
9
|
+
WEIGHT_SHARED_THREAD = 1.0 # R1 — Gmail thread match (highest confidence)
|
|
10
|
+
WEIGHT_SAME_DAY_PARTICIPANT = 0.7 # R3 — participant + ±1-day match
|
|
11
|
+
WEIGHT_SHARED_PARTICIPANT = 0.4 # R2 — participant only (weakest)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class DocSnapshot:
|
|
16
|
+
"""A read-only projection of a document used by rule evaluation.
|
|
17
|
+
|
|
18
|
+
Built once per linker pass from `documents` + `metadata->'_participant_keys'`.
|
|
19
|
+
Rules consume snapshots; they never query the DB themselves. ``source_kind``
|
|
20
|
+
matches ``sources.kind`` in the DB schema, or ``None`` for vault-tier docs
|
|
21
|
+
without a source.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
document_id: str
|
|
25
|
+
source_kind: Literal["gmail", "krisp", "manual"] | None
|
|
26
|
+
metadata: dict[str, Any]
|
|
27
|
+
participant_keys: frozenset[str]
|
|
28
|
+
date: datetime.date | None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Evidence:
|
|
33
|
+
"""Outcome of a successful rule evaluation for a document pair.
|
|
34
|
+
|
|
35
|
+
The pass-runner stores `(rule, weight, payload)` into `derived_links`
|
|
36
|
+
columns + the `evidence` JSONB column. Payload shape varies per rule —
|
|
37
|
+
e.g. {"thread_id": "..."} for R1, {"participant": "...", "date": "..."}
|
|
38
|
+
for R3.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
rule: Literal["shared_thread", "shared_participant", "same_day_participant"]
|
|
42
|
+
weight: float # 1.0 / 0.4 / 0.7
|
|
43
|
+
payload: dict[str, Any]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def rule_shared_thread(a: DocSnapshot, b: DocSnapshot) -> Evidence | None:
|
|
47
|
+
"""R1 — Gmail↔Gmail edge when both share `metadata.thread_id`.
|
|
48
|
+
|
|
49
|
+
Returns None for non-Gmail pairs, missing thread_ids, or thread_id
|
|
50
|
+
mismatch. Weight ``WEIGHT_SHARED_THREAD``.
|
|
51
|
+
"""
|
|
52
|
+
if a.document_id == b.document_id:
|
|
53
|
+
return None
|
|
54
|
+
if a.source_kind != "gmail" or b.source_kind != "gmail":
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
thread_a = a.metadata.get("thread_id")
|
|
58
|
+
thread_b = b.metadata.get("thread_id")
|
|
59
|
+
if not (isinstance(thread_a, str) and thread_a.strip()):
|
|
60
|
+
return None
|
|
61
|
+
if not (isinstance(thread_b, str) and thread_b.strip()):
|
|
62
|
+
return None
|
|
63
|
+
if thread_a != thread_b:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
return Evidence(
|
|
67
|
+
rule="shared_thread",
|
|
68
|
+
weight=WEIGHT_SHARED_THREAD,
|
|
69
|
+
payload={"thread_id": thread_a},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def rule_shared_participant(a: DocSnapshot, b: DocSnapshot) -> Evidence | None:
|
|
74
|
+
"""R2 — edge when both docs' participant_keys intersect.
|
|
75
|
+
|
|
76
|
+
Applies across Krisp↔Gmail, Krisp↔Krisp, Gmail↔Gmail. Returns None for
|
|
77
|
+
self-pairs, empty intersections. Weight ``WEIGHT_SHARED_PARTICIPANT``.
|
|
78
|
+
The pass-runner is responsible for suppressing R2 when R3 fires for
|
|
79
|
+
the same pair.
|
|
80
|
+
"""
|
|
81
|
+
if a.document_id == b.document_id:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
shared = a.participant_keys & b.participant_keys
|
|
85
|
+
if not shared:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
representative = sorted(shared)[0]
|
|
89
|
+
return Evidence(
|
|
90
|
+
rule="shared_participant",
|
|
91
|
+
weight=WEIGHT_SHARED_PARTICIPANT,
|
|
92
|
+
payload={"participant": representative, "shared_count": len(shared)},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def rule_same_day_participant(a: DocSnapshot, b: DocSnapshot) -> Evidence | None:
|
|
97
|
+
"""R3 — Krisp↔Gmail edge when participants intersect AND dates within ±1 day.
|
|
98
|
+
|
|
99
|
+
Strictly stronger than R2 for Krisp↔Gmail pairs. Returns None for
|
|
100
|
+
non-Krisp/Gmail pairs, missing dates, mismatched participant sets, or
|
|
101
|
+
dates more than 1 day apart. Timezone-naive comparison. Weight
|
|
102
|
+
``WEIGHT_SAME_DAY_PARTICIPANT``.
|
|
103
|
+
"""
|
|
104
|
+
if a.document_id == b.document_id:
|
|
105
|
+
return None
|
|
106
|
+
if {a.source_kind, b.source_kind} != {"krisp", "gmail"}:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
a_date = a.date
|
|
110
|
+
b_date = b.date
|
|
111
|
+
if a_date is None or b_date is None:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
day_delta = abs((a_date - b_date).days)
|
|
115
|
+
if day_delta > 1:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
shared = a.participant_keys & b.participant_keys
|
|
119
|
+
if not shared:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
representative = sorted(shared)[0]
|
|
123
|
+
if a.source_kind == "krisp":
|
|
124
|
+
krisp_date_iso, gmail_date_iso = a_date.isoformat(), b_date.isoformat()
|
|
125
|
+
else:
|
|
126
|
+
krisp_date_iso, gmail_date_iso = b_date.isoformat(), a_date.isoformat()
|
|
127
|
+
|
|
128
|
+
return Evidence(
|
|
129
|
+
rule="same_day_participant",
|
|
130
|
+
weight=WEIGHT_SAME_DAY_PARTICIPANT,
|
|
131
|
+
payload={
|
|
132
|
+
"participant": representative,
|
|
133
|
+
"krisp_date": krisp_date_iso,
|
|
134
|
+
"gmail_date": gmail_date_iso,
|
|
135
|
+
"day_delta": day_delta,
|
|
136
|
+
},
|
|
137
|
+
)
|