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
brain/vault/graph.py
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
"""Read-only link graph queries over the ``links`` / ``unresolved_links`` /
|
|
2
|
+
``derived_links`` tables.
|
|
3
|
+
|
|
4
|
+
Phase 4 read API: backlinks, outgoing links, orphans, and a
|
|
5
|
+
:class:`GraphData` snapshot suitable for export. Plain SELECTs over
|
|
6
|
+
Phase 2's wiki-link materialization plus Phase 5's metadata-derived edges —
|
|
7
|
+
no schema changes, no writes, no embedder dependency. Callers pass a
|
|
8
|
+
``psycopg.Connection``; this module never opens or closes one itself.
|
|
9
|
+
|
|
10
|
+
Resolution conventions:
|
|
11
|
+
|
|
12
|
+
- ``document_id`` is always a full UUID (string). Callers needing to
|
|
13
|
+
resolve a user-supplied prefix should hand off to
|
|
14
|
+
:func:`brain.queries.resolve_document_prefix` first.
|
|
15
|
+
- ``vault_only=True`` filters by ``documents.kind='vault'`` — the spec's
|
|
16
|
+
default for orphan / graph views since ingested-tier nodes (Krisp,
|
|
17
|
+
Slack, Gmail mirrors) usually have no ``[[refs]]`` and clutter the graph.
|
|
18
|
+
|
|
19
|
+
Derived-edge semantics:
|
|
20
|
+
|
|
21
|
+
- ``derived_links`` rows are stored in canonical ``(LEAST, GREATEST)``
|
|
22
|
+
order and carry an undirected meaning ("these two docs share a
|
|
23
|
+
thread / participant"). Read paths therefore treat a derived edge as
|
|
24
|
+
symmetric: both :func:`backlinks_for` and :func:`outgoing_links_for`
|
|
25
|
+
return the partner regardless of which side of the storage row the
|
|
26
|
+
caller's document sits on.
|
|
27
|
+
- Edges materialized in Python carry ``link_kind='derived'``. The schema
|
|
28
|
+
CHECK in migration 003 still restricts ``links.link_kind`` to
|
|
29
|
+
``'wiki' | 'embed'`` — derived edges live in a sibling table, so the
|
|
30
|
+
enum extension is a Python-only convention.
|
|
31
|
+
- Wiki-link edges keep ``rule=None``, ``weight=None``,
|
|
32
|
+
``evidence=None``; derived edges populate all three.
|
|
33
|
+
"""
|
|
34
|
+
from collections import deque
|
|
35
|
+
from collections.abc import Iterable
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
import psycopg
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class GraphNode:
|
|
44
|
+
"""A document node in the link graph.
|
|
45
|
+
|
|
46
|
+
``kind`` mirrors ``documents.kind`` (``'vault'`` or ``'ingested'``);
|
|
47
|
+
formatters use it to color/style nodes (vault = primary, ingested =
|
|
48
|
+
muted).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
document_id: str
|
|
52
|
+
title: str
|
|
53
|
+
kind: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class GraphEdge:
|
|
58
|
+
"""An edge in the link graph — wiki-link, embed, or metadata-derived.
|
|
59
|
+
|
|
60
|
+
``link_kind`` is one of:
|
|
61
|
+
|
|
62
|
+
- ``'wiki'`` / ``'embed'`` — wiki-link materialization (per the
|
|
63
|
+
``links.link_kind`` CHECK constraint). ``link_text`` carries the
|
|
64
|
+
raw ``[[X]]`` exactly as it appeared in the source body;
|
|
65
|
+
``display_text`` carries the pipe alias when the user wrote
|
|
66
|
+
``[[X|alias]]``. ``rule`` / ``weight`` / ``evidence`` are all
|
|
67
|
+
``None``.
|
|
68
|
+
- ``'derived'`` — Python-level convention (NOT a value in the schema
|
|
69
|
+
CHECK) for an edge sourced from ``derived_links``. ``link_text``
|
|
70
|
+
is empty; ``display_text`` is ``None``. ``rule`` is the rule that
|
|
71
|
+
fired (``'shared_thread'`` / ``'shared_participant'`` /
|
|
72
|
+
``'same_day_participant'``), ``weight`` is the rule's confidence
|
|
73
|
+
(per :mod:`brain.vault.derived_links.rules`), and ``evidence``
|
|
74
|
+
is the JSONB payload as a Python dict.
|
|
75
|
+
|
|
76
|
+
Derived edges are stored as ``(src=LEAST, dst=GREATEST)`` and carry
|
|
77
|
+
an undirected meaning; consumers that care about direction must
|
|
78
|
+
treat the pair symmetrically.
|
|
79
|
+
|
|
80
|
+
Self-loops (src == dst) never appear: sync's resolver excludes them
|
|
81
|
+
for wiki edges, and ``derived_links``' ``CHECK (src <> dst)`` does
|
|
82
|
+
the same for derived.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
src_document_id: str
|
|
86
|
+
dst_document_id: str
|
|
87
|
+
link_kind: str
|
|
88
|
+
link_text: str
|
|
89
|
+
display_text: str | None
|
|
90
|
+
rule: str | None = None
|
|
91
|
+
weight: float | None = None
|
|
92
|
+
evidence: dict[str, Any] | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class GraphData:
|
|
97
|
+
"""Snapshot of the link graph used by every formatter.
|
|
98
|
+
|
|
99
|
+
``nodes`` and ``edges`` are independent: a node may have no edges
|
|
100
|
+
(orphan, isolated when ``include_ingested=False``); an edge always has
|
|
101
|
+
both endpoints in ``nodes`` (we never emit an edge that points at a
|
|
102
|
+
node not in the snapshot).
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
nodes: list[GraphNode]
|
|
106
|
+
edges: list[GraphEdge]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class BacklinkRow:
|
|
111
|
+
"""One row of "documents that link TO me."
|
|
112
|
+
|
|
113
|
+
``src_*`` describes the source of the inbound link; the destination is
|
|
114
|
+
the document the caller passed to :func:`backlinks_for`.
|
|
115
|
+
|
|
116
|
+
For wiki/embed edges, ``link_text`` is the raw ``[[X]]`` and
|
|
117
|
+
``rule`` / ``weight`` / ``evidence`` are ``None``. For derived edges,
|
|
118
|
+
``link_kind='derived'``, ``link_text=''``, and ``rule`` / ``weight`` /
|
|
119
|
+
``evidence`` carry the metadata-rule provenance. Derived edges are
|
|
120
|
+
undirected in semantics — ``backlinks_for(X)`` returns every doc
|
|
121
|
+
paired with X via ``derived_links`` regardless of which side X sits
|
|
122
|
+
on in the canonical storage row.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
src_document_id: str
|
|
126
|
+
src_title: str
|
|
127
|
+
src_kind: str
|
|
128
|
+
link_text: str
|
|
129
|
+
link_kind: str
|
|
130
|
+
rule: str | None = None
|
|
131
|
+
weight: float | None = None
|
|
132
|
+
evidence: dict[str, Any] | None = None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class OutgoingLinkRow:
|
|
137
|
+
"""One row of "documents I link TO."
|
|
138
|
+
|
|
139
|
+
For resolved wiki/embed links (the default), every ``dst_*`` field is
|
|
140
|
+
populated. For unresolved (dangling) ``[[refs]]`` returned with
|
|
141
|
+
``include_unresolved=True``, ``dst_document_id`` / ``dst_title`` /
|
|
142
|
+
``dst_kind`` are ``None`` and ``resolved=False``.
|
|
143
|
+
|
|
144
|
+
Derived edges are also included (with ``include_derived=True``, the
|
|
145
|
+
default) and carry ``link_kind='derived'`` plus populated
|
|
146
|
+
``rule`` / ``weight`` / ``evidence``. Because derived edges are
|
|
147
|
+
undirected, ``outgoing_links_for(X)`` returns the same partner set
|
|
148
|
+
as ``backlinks_for(X)`` for them — every doc paired with X via
|
|
149
|
+
``derived_links``, regardless of canonical storage direction.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
dst_document_id: str | None
|
|
153
|
+
dst_title: str | None
|
|
154
|
+
dst_kind: str | None
|
|
155
|
+
link_text: str
|
|
156
|
+
link_kind: str
|
|
157
|
+
resolved: bool
|
|
158
|
+
rule: str | None = None
|
|
159
|
+
weight: float | None = None
|
|
160
|
+
evidence: dict[str, Any] | None = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def backlinks_for(
|
|
164
|
+
conn: psycopg.Connection[Any],
|
|
165
|
+
document_id: str,
|
|
166
|
+
*,
|
|
167
|
+
include_derived: bool = True,
|
|
168
|
+
) -> list[BacklinkRow]:
|
|
169
|
+
"""Return every document that links TO ``document_id``.
|
|
170
|
+
|
|
171
|
+
Wiki/embed rows come from a JOIN of ``links`` against ``documents``
|
|
172
|
+
(one round-trip, no N+1) and are sorted by source title
|
|
173
|
+
(case-insensitive) then by ``link_text`` to break ties.
|
|
174
|
+
|
|
175
|
+
With ``include_derived=True`` (the default), derived edges are
|
|
176
|
+
appended after the wiki block: every ``derived_links`` row whose src
|
|
177
|
+
or dst equals ``document_id`` contributes one row keyed on the
|
|
178
|
+
*partner* document. This treats derived storage as undirected per
|
|
179
|
+
spec §6 — a row stored ``(A, B)`` shows up in both ``backlinks_for(A)``
|
|
180
|
+
and ``backlinks_for(B)``. Derived rows sort within their block by
|
|
181
|
+
rule then partner title for deterministic output.
|
|
182
|
+
"""
|
|
183
|
+
rows = conn.execute(
|
|
184
|
+
"""
|
|
185
|
+
SELECT d.id::text, d.title, d.kind, l.link_text, l.link_kind
|
|
186
|
+
FROM links l
|
|
187
|
+
JOIN documents d ON d.id = l.src_document_id
|
|
188
|
+
WHERE l.dst_document_id = %s
|
|
189
|
+
ORDER BY LOWER(d.title), l.link_text
|
|
190
|
+
""",
|
|
191
|
+
(document_id,),
|
|
192
|
+
).fetchall()
|
|
193
|
+
out: list[BacklinkRow] = [
|
|
194
|
+
BacklinkRow(
|
|
195
|
+
src_document_id=str(r[0]),
|
|
196
|
+
src_title=str(r[1]),
|
|
197
|
+
src_kind=str(r[2]),
|
|
198
|
+
link_text=str(r[3]),
|
|
199
|
+
link_kind=str(r[4]),
|
|
200
|
+
)
|
|
201
|
+
for r in rows
|
|
202
|
+
]
|
|
203
|
+
if include_derived:
|
|
204
|
+
out.extend(
|
|
205
|
+
BacklinkRow(
|
|
206
|
+
src_document_id=partner.document_id,
|
|
207
|
+
src_title=partner.title,
|
|
208
|
+
src_kind=partner.kind,
|
|
209
|
+
link_text="",
|
|
210
|
+
link_kind="derived",
|
|
211
|
+
rule=row.rule,
|
|
212
|
+
weight=row.weight,
|
|
213
|
+
evidence=row.evidence,
|
|
214
|
+
)
|
|
215
|
+
for row, partner in _derived_partners(conn, document_id)
|
|
216
|
+
)
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def outgoing_links_for(
|
|
221
|
+
conn: psycopg.Connection[Any],
|
|
222
|
+
document_id: str,
|
|
223
|
+
*,
|
|
224
|
+
include_unresolved: bool = False,
|
|
225
|
+
include_derived: bool = True,
|
|
226
|
+
) -> list[OutgoingLinkRow]:
|
|
227
|
+
"""Return every document ``document_id`` links TO.
|
|
228
|
+
|
|
229
|
+
With ``include_unresolved=False`` (the default), only resolved links
|
|
230
|
+
are returned (``links`` table joined to ``documents`` for the dst
|
|
231
|
+
title/kind).
|
|
232
|
+
|
|
233
|
+
With ``include_unresolved=True``, the result also includes dangling
|
|
234
|
+
``[[refs]]`` from ``unresolved_links`` (``dst_document_id`` /
|
|
235
|
+
``dst_title`` / ``dst_kind`` all ``None``). Resolved rows come first,
|
|
236
|
+
then unresolved.
|
|
237
|
+
|
|
238
|
+
With ``include_derived=True`` (the default), derived edges are
|
|
239
|
+
appended after the resolved wiki block (and before the unresolved
|
|
240
|
+
block, when included). Because ``derived_links`` rows are undirected
|
|
241
|
+
in semantics, ``outgoing_links_for(X)`` returns the same partner set
|
|
242
|
+
as ``backlinks_for(X)`` for them — every doc paired with X
|
|
243
|
+
regardless of canonical direction.
|
|
244
|
+
"""
|
|
245
|
+
resolved_rows = conn.execute(
|
|
246
|
+
"""
|
|
247
|
+
SELECT d.id::text, d.title, d.kind, l.link_text, l.link_kind
|
|
248
|
+
FROM links l
|
|
249
|
+
JOIN documents d ON d.id = l.dst_document_id
|
|
250
|
+
WHERE l.src_document_id = %s
|
|
251
|
+
ORDER BY LOWER(d.title), l.link_text
|
|
252
|
+
""",
|
|
253
|
+
(document_id,),
|
|
254
|
+
).fetchall()
|
|
255
|
+
out: list[OutgoingLinkRow] = [
|
|
256
|
+
OutgoingLinkRow(
|
|
257
|
+
dst_document_id=str(r[0]),
|
|
258
|
+
dst_title=str(r[1]),
|
|
259
|
+
dst_kind=str(r[2]),
|
|
260
|
+
link_text=str(r[3]),
|
|
261
|
+
link_kind=str(r[4]),
|
|
262
|
+
resolved=True,
|
|
263
|
+
)
|
|
264
|
+
for r in resolved_rows
|
|
265
|
+
]
|
|
266
|
+
if include_derived:
|
|
267
|
+
out.extend(
|
|
268
|
+
OutgoingLinkRow(
|
|
269
|
+
dst_document_id=partner.document_id,
|
|
270
|
+
dst_title=partner.title,
|
|
271
|
+
dst_kind=partner.kind,
|
|
272
|
+
link_text="",
|
|
273
|
+
link_kind="derived",
|
|
274
|
+
resolved=True,
|
|
275
|
+
rule=row.rule,
|
|
276
|
+
weight=row.weight,
|
|
277
|
+
evidence=row.evidence,
|
|
278
|
+
)
|
|
279
|
+
for row, partner in _derived_partners(conn, document_id)
|
|
280
|
+
)
|
|
281
|
+
if include_unresolved:
|
|
282
|
+
unresolved_rows = conn.execute(
|
|
283
|
+
"""
|
|
284
|
+
SELECT link_text, link_kind
|
|
285
|
+
FROM unresolved_links
|
|
286
|
+
WHERE src_document_id = %s
|
|
287
|
+
ORDER BY link_text
|
|
288
|
+
""",
|
|
289
|
+
(document_id,),
|
|
290
|
+
).fetchall()
|
|
291
|
+
out.extend(
|
|
292
|
+
OutgoingLinkRow(
|
|
293
|
+
dst_document_id=None,
|
|
294
|
+
dst_title=None,
|
|
295
|
+
dst_kind=None,
|
|
296
|
+
link_text=str(r[0]),
|
|
297
|
+
link_kind=str(r[1]),
|
|
298
|
+
resolved=False,
|
|
299
|
+
)
|
|
300
|
+
for r in unresolved_rows
|
|
301
|
+
)
|
|
302
|
+
return out
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def orphans(
|
|
306
|
+
conn: psycopg.Connection[Any],
|
|
307
|
+
*,
|
|
308
|
+
vault_only: bool = True,
|
|
309
|
+
) -> list[GraphNode]:
|
|
310
|
+
"""Return documents with zero incoming AND zero outgoing links.
|
|
311
|
+
|
|
312
|
+
"Outgoing" includes both resolved (``links.src_document_id``) and
|
|
313
|
+
unresolved (``unresolved_links.src_document_id``) — a note that wrote
|
|
314
|
+
``[[Foo]]`` once isn't an orphan even when ``Foo`` doesn't yet exist.
|
|
315
|
+
|
|
316
|
+
A document with at least one ``derived_links`` edge (on either side)
|
|
317
|
+
is also not an orphan: the metadata-aware linker has surfaced it as
|
|
318
|
+
connected to another doc through shared thread / participant
|
|
319
|
+
overlap, and the user's intuition for "orphan" is "nothing
|
|
320
|
+
connecting it" — derived edges count.
|
|
321
|
+
|
|
322
|
+
Defaults to ``vault_only=True`` because ingested-tier orphans are
|
|
323
|
+
usually noise: most Krisp / Slack / Gmail mirrors have no ``[[refs]]``
|
|
324
|
+
yet, and surfacing all of them would drown the user's own notes.
|
|
325
|
+
Pass ``vault_only=False`` (or the CLI's ``--all``) to include them.
|
|
326
|
+
|
|
327
|
+
Sort: title (case-insensitive) for deterministic output, then id to
|
|
328
|
+
break ties when two notes share a title.
|
|
329
|
+
"""
|
|
330
|
+
where = ["d.id NOT IN (SELECT src_document_id FROM links)"]
|
|
331
|
+
where.append("d.id NOT IN (SELECT dst_document_id FROM links)")
|
|
332
|
+
where.append("d.id NOT IN (SELECT src_document_id FROM unresolved_links)")
|
|
333
|
+
where.append("d.id NOT IN (SELECT src_document_id FROM derived_links)")
|
|
334
|
+
where.append("d.id NOT IN (SELECT dst_document_id FROM derived_links)")
|
|
335
|
+
if vault_only:
|
|
336
|
+
where.append("d.kind = 'vault'")
|
|
337
|
+
sql = f"""
|
|
338
|
+
SELECT d.id::text, d.title, d.kind
|
|
339
|
+
FROM documents d
|
|
340
|
+
WHERE {' AND '.join(where)}
|
|
341
|
+
ORDER BY LOWER(d.title), d.id
|
|
342
|
+
"""
|
|
343
|
+
rows = conn.execute(sql).fetchall()
|
|
344
|
+
return [
|
|
345
|
+
GraphNode(document_id=str(r[0]), title=str(r[1]), kind=str(r[2]))
|
|
346
|
+
for r in rows
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def graph_data(
|
|
351
|
+
conn: psycopg.Connection[Any],
|
|
352
|
+
*,
|
|
353
|
+
root: str | None = None,
|
|
354
|
+
depth: int | None = None,
|
|
355
|
+
include_ingested: bool = False,
|
|
356
|
+
include_derived: bool = True,
|
|
357
|
+
) -> GraphData:
|
|
358
|
+
"""Build a :class:`GraphData` snapshot for export.
|
|
359
|
+
|
|
360
|
+
Without ``root``: every document with at least one link in or out, plus
|
|
361
|
+
every vault-tier orphan (so the user sees their isolated thinking
|
|
362
|
+
surfaces). With ``include_ingested=True`` the linked-only ingested
|
|
363
|
+
tier comes along too; ingested-tier orphans stay omitted (noise).
|
|
364
|
+
|
|
365
|
+
With ``root`` set: BFS outward from the root for ``depth`` hops
|
|
366
|
+
(``None`` = unlimited). Only nodes within the BFS frontier are
|
|
367
|
+
returned, and only edges where BOTH endpoints are in the frontier.
|
|
368
|
+
The traversal is undirected on the edge set (you reach a backlink the
|
|
369
|
+
same way you reach an outgoing link) so a focused view contains the
|
|
370
|
+
immediate neighbourhood regardless of direction. Edges in the result
|
|
371
|
+
keep their original direction.
|
|
372
|
+
|
|
373
|
+
With ``include_derived=True`` (the default), ``derived_links`` rows
|
|
374
|
+
are unioned into the edge set as ``link_kind='derived'`` edges,
|
|
375
|
+
carrying their canonical ``(LEAST, GREATEST)`` direction plus the
|
|
376
|
+
rule / weight / evidence triple. BFS treats derived edges as
|
|
377
|
+
undirected (same as wiki edges), and ingested-tier orphan filtering
|
|
378
|
+
correctly counts a doc connected only via derived edges as
|
|
379
|
+
connected. ``include_derived=False`` reproduces the pre-Phase-C
|
|
380
|
+
behavior.
|
|
381
|
+
|
|
382
|
+
The function issues at most three queries (wiki edges, derived
|
|
383
|
+
edges, documents) — no per-node N+1.
|
|
384
|
+
|
|
385
|
+
Cycle safety: BFS uses a visited set, so ``A → B → A`` terminates at
|
|
386
|
+
depth 2 instead of looping. Self-loops never appear.
|
|
387
|
+
"""
|
|
388
|
+
# Single fetch of every link, plus a filter on the document set —
|
|
389
|
+
# cheaper at personal-corpus scale than per-node SELECTs even when
|
|
390
|
+
# rooted, and trivially cached.
|
|
391
|
+
edge_rows = conn.execute(
|
|
392
|
+
"""
|
|
393
|
+
SELECT src_document_id::text, dst_document_id::text,
|
|
394
|
+
link_kind, link_text, display_text
|
|
395
|
+
FROM links
|
|
396
|
+
ORDER BY src_document_id, dst_document_id, link_text, link_kind
|
|
397
|
+
"""
|
|
398
|
+
).fetchall()
|
|
399
|
+
all_edges: list[GraphEdge] = [
|
|
400
|
+
GraphEdge(
|
|
401
|
+
src_document_id=str(r[0]),
|
|
402
|
+
dst_document_id=str(r[1]),
|
|
403
|
+
link_kind=str(r[2]),
|
|
404
|
+
link_text=str(r[3]),
|
|
405
|
+
display_text=(str(r[4]) if r[4] is not None else None),
|
|
406
|
+
)
|
|
407
|
+
for r in edge_rows
|
|
408
|
+
]
|
|
409
|
+
|
|
410
|
+
if include_derived:
|
|
411
|
+
derived_rows = conn.execute(
|
|
412
|
+
"""
|
|
413
|
+
SELECT src_document_id::text, dst_document_id::text,
|
|
414
|
+
rule, weight, evidence
|
|
415
|
+
FROM derived_links
|
|
416
|
+
ORDER BY src_document_id, dst_document_id, rule
|
|
417
|
+
"""
|
|
418
|
+
).fetchall()
|
|
419
|
+
all_edges.extend(
|
|
420
|
+
GraphEdge(
|
|
421
|
+
src_document_id=str(r[0]),
|
|
422
|
+
dst_document_id=str(r[1]),
|
|
423
|
+
link_kind="derived",
|
|
424
|
+
link_text="",
|
|
425
|
+
display_text=None,
|
|
426
|
+
rule=str(r[2]),
|
|
427
|
+
weight=float(r[3]),
|
|
428
|
+
evidence=_coerce_evidence(r[4]),
|
|
429
|
+
)
|
|
430
|
+
for r in derived_rows
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# Pull every document so we can filter / look up titles in Python.
|
|
434
|
+
# Personal-corpus scale (low thousands) — one fetch is cheaper than
|
|
435
|
+
# repeated round-trips and keeps the SQL trivially auditable.
|
|
436
|
+
doc_rows = conn.execute(
|
|
437
|
+
"SELECT id::text, title, kind FROM documents ORDER BY LOWER(title), id"
|
|
438
|
+
).fetchall()
|
|
439
|
+
all_docs: dict[str, GraphNode] = {
|
|
440
|
+
str(r[0]): GraphNode(
|
|
441
|
+
document_id=str(r[0]), title=str(r[1]), kind=str(r[2])
|
|
442
|
+
)
|
|
443
|
+
for r in doc_rows
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if root is not None:
|
|
447
|
+
keep = _bfs_frontier(root, all_edges, depth=depth)
|
|
448
|
+
# Filter edges to those entirely within the frontier.
|
|
449
|
+
kept_edges = [
|
|
450
|
+
e
|
|
451
|
+
for e in all_edges
|
|
452
|
+
if e.src_document_id in keep and e.dst_document_id in keep
|
|
453
|
+
]
|
|
454
|
+
kept_nodes = [
|
|
455
|
+
all_docs[doc_id]
|
|
456
|
+
for doc_id in sorted(
|
|
457
|
+
keep, key=lambda d: (all_docs[d].title.lower(), d)
|
|
458
|
+
)
|
|
459
|
+
if doc_id in all_docs
|
|
460
|
+
]
|
|
461
|
+
return GraphData(nodes=kept_nodes, edges=kept_edges)
|
|
462
|
+
|
|
463
|
+
# Whole-graph mode.
|
|
464
|
+
if include_ingested:
|
|
465
|
+
node_filter: set[str] | None = None
|
|
466
|
+
else:
|
|
467
|
+
# Vault-tier nodes always included; ingested-tier nodes only when
|
|
468
|
+
# they sit on at least one edge (so a vault note linking to an
|
|
469
|
+
# ingested artifact still shows the artifact). Ingested-tier
|
|
470
|
+
# orphans are dropped — they're noise per spec.
|
|
471
|
+
connected = _connected_node_set(all_edges)
|
|
472
|
+
node_filter = {
|
|
473
|
+
doc_id
|
|
474
|
+
for doc_id, node in all_docs.items()
|
|
475
|
+
if node.kind == "vault" or doc_id in connected
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if node_filter is None:
|
|
479
|
+
kept_nodes = list(all_docs.values())
|
|
480
|
+
kept_edges = list(all_edges)
|
|
481
|
+
else:
|
|
482
|
+
kept_nodes = [
|
|
483
|
+
all_docs[doc_id]
|
|
484
|
+
for doc_id in sorted(
|
|
485
|
+
node_filter, key=lambda d: (all_docs[d].title.lower(), d)
|
|
486
|
+
)
|
|
487
|
+
]
|
|
488
|
+
kept_edges = [
|
|
489
|
+
e
|
|
490
|
+
for e in all_edges
|
|
491
|
+
if e.src_document_id in node_filter
|
|
492
|
+
and e.dst_document_id in node_filter
|
|
493
|
+
]
|
|
494
|
+
return GraphData(nodes=kept_nodes, edges=kept_edges)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
@dataclass(frozen=True)
|
|
498
|
+
class _DerivedRow:
|
|
499
|
+
"""Internal projection of a ``derived_links`` row used by partner queries."""
|
|
500
|
+
|
|
501
|
+
rule: str
|
|
502
|
+
weight: float
|
|
503
|
+
evidence: dict[str, Any] | None
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _derived_partners(
|
|
507
|
+
conn: psycopg.Connection[Any], document_id: str
|
|
508
|
+
) -> list[tuple[_DerivedRow, GraphNode]]:
|
|
509
|
+
"""Return ``(row, partner_node)`` pairs for every derived edge touching ``document_id``.
|
|
510
|
+
|
|
511
|
+
``derived_links`` rows are stored undirected as ``(LEAST, GREATEST)``,
|
|
512
|
+
so a single SELECT with ``OR`` over both columns finds every edge the
|
|
513
|
+
document participates in. We resolve the *partner* document_id (the
|
|
514
|
+
other endpoint) plus its title/kind in one round-trip via a CASE +
|
|
515
|
+
JOIN, mirroring :func:`backlinks_for`'s anti-N+1 style.
|
|
516
|
+
|
|
517
|
+
Sort order: rule, then partner title (case-insensitive), then partner
|
|
518
|
+
id. Stable across calls so callers can pin output bytes in tests.
|
|
519
|
+
"""
|
|
520
|
+
rows = conn.execute(
|
|
521
|
+
"""
|
|
522
|
+
SELECT
|
|
523
|
+
dl.rule,
|
|
524
|
+
dl.weight,
|
|
525
|
+
dl.evidence,
|
|
526
|
+
partner.id::text,
|
|
527
|
+
partner.title,
|
|
528
|
+
partner.kind
|
|
529
|
+
FROM derived_links dl
|
|
530
|
+
JOIN documents partner
|
|
531
|
+
ON partner.id = CASE
|
|
532
|
+
WHEN dl.src_document_id = %(doc)s THEN dl.dst_document_id
|
|
533
|
+
ELSE dl.src_document_id
|
|
534
|
+
END
|
|
535
|
+
WHERE dl.src_document_id = %(doc)s OR dl.dst_document_id = %(doc)s
|
|
536
|
+
ORDER BY dl.rule, LOWER(partner.title), partner.id::text
|
|
537
|
+
""",
|
|
538
|
+
{"doc": document_id},
|
|
539
|
+
).fetchall()
|
|
540
|
+
return [
|
|
541
|
+
(
|
|
542
|
+
_DerivedRow(
|
|
543
|
+
rule=str(r[0]),
|
|
544
|
+
weight=float(r[1]),
|
|
545
|
+
evidence=_coerce_evidence(r[2]),
|
|
546
|
+
),
|
|
547
|
+
GraphNode(
|
|
548
|
+
document_id=str(r[3]),
|
|
549
|
+
title=str(r[4]),
|
|
550
|
+
kind=str(r[5]),
|
|
551
|
+
),
|
|
552
|
+
)
|
|
553
|
+
for r in rows
|
|
554
|
+
]
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _coerce_evidence(raw: Any) -> dict[str, Any] | None:
|
|
558
|
+
"""Normalize a JSONB column read into a Python ``dict``.
|
|
559
|
+
|
|
560
|
+
psycopg's default JSONB adapter already returns ``dict``; this
|
|
561
|
+
helper is a thin defensive copy so callers can't mutate the
|
|
562
|
+
underlying row's payload. ``derived_links.evidence`` is
|
|
563
|
+
``NOT NULL DEFAULT '{}'::jsonb`` per migration 005, so an empty
|
|
564
|
+
``{}`` is the worst case — a payload with no keys. Anything else
|
|
565
|
+
falls through to ``None`` and the caller treats the row as
|
|
566
|
+
payload-less.
|
|
567
|
+
"""
|
|
568
|
+
if isinstance(raw, dict):
|
|
569
|
+
return dict(raw)
|
|
570
|
+
return None
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _connected_node_set(edges: Iterable[GraphEdge]) -> set[str]:
|
|
574
|
+
"""Return every document_id that appears as src or dst in ``edges``."""
|
|
575
|
+
out: set[str] = set()
|
|
576
|
+
for e in edges:
|
|
577
|
+
out.add(e.src_document_id)
|
|
578
|
+
out.add(e.dst_document_id)
|
|
579
|
+
return out
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _bfs_frontier(
|
|
583
|
+
root: str, edges: Iterable[GraphEdge], *, depth: int | None
|
|
584
|
+
) -> set[str]:
|
|
585
|
+
"""Undirected BFS from ``root`` over ``edges``, capped at ``depth`` hops.
|
|
586
|
+
|
|
587
|
+
Returns the set of document_ids reachable from ``root`` within
|
|
588
|
+
``depth`` hops (inclusive). ``depth=None`` means unlimited (all
|
|
589
|
+
reachable nodes). ``depth=0`` returns just ``{root}``.
|
|
590
|
+
|
|
591
|
+
The traversal is undirected (treats every edge as bidirectional)
|
|
592
|
+
because a "graph view rooted at X" intuitively pulls in both backlinks
|
|
593
|
+
and outgoing links — the user expects to see X's neighbourhood in
|
|
594
|
+
every direction. Edge orientation is preserved later when building
|
|
595
|
+
the final :class:`GraphData`. Derived edges already store undirected
|
|
596
|
+
semantics; mixing them into ``edges`` here is correct.
|
|
597
|
+
"""
|
|
598
|
+
# Build an undirected adjacency map once. Cheaper than scanning the
|
|
599
|
+
# edge list per hop (O(|edges|) per hop vs. O(|nodes|) per hop after
|
|
600
|
+
# this preprocessing).
|
|
601
|
+
adjacency: dict[str, set[str]] = {}
|
|
602
|
+
for e in edges:
|
|
603
|
+
adjacency.setdefault(e.src_document_id, set()).add(e.dst_document_id)
|
|
604
|
+
adjacency.setdefault(e.dst_document_id, set()).add(e.src_document_id)
|
|
605
|
+
|
|
606
|
+
visited: set[str] = {root}
|
|
607
|
+
if depth == 0:
|
|
608
|
+
return visited
|
|
609
|
+
|
|
610
|
+
queue: deque[tuple[str, int]] = deque([(root, 0)])
|
|
611
|
+
while queue:
|
|
612
|
+
node, dist = queue.popleft()
|
|
613
|
+
if depth is not None and dist >= depth:
|
|
614
|
+
continue
|
|
615
|
+
for neighbour in adjacency.get(node, ()):
|
|
616
|
+
if neighbour in visited:
|
|
617
|
+
continue
|
|
618
|
+
visited.add(neighbour)
|
|
619
|
+
queue.append((neighbour, dist + 1))
|
|
620
|
+
return visited
|