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/connect.py
ADDED
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
"""Scoring core for `brain connect` — proactive auto-link suggestions (Plan 07).
|
|
2
|
+
|
|
3
|
+
This module owns the *logic*: it scores candidate (source_doc → target_doc)
|
|
4
|
+
wikilink pairs by an RRF blend of an entity-graph affinity leg
|
|
5
|
+
(normalized entity overlap over ``graph_entity_mentions``) and an embedding
|
|
6
|
+
affinity leg (cosine over ``chunks.embedding``), gates by a confidence floor,
|
|
7
|
+
dedups against already-linked pairs, and upserts the survivors into
|
|
8
|
+
``link_suggestions``. It also exposes the accept/reject status mutations and the
|
|
9
|
+
Typer-free ``## See Also`` vault-writeback primitives shared by the CLI and the
|
|
10
|
+
MCP server (so neither layer duplicates the SQL or the file logic).
|
|
11
|
+
|
|
12
|
+
It deliberately carries no Typer / MCP imports: the CLI (``cli_connect.py``)
|
|
13
|
+
and the MCP server map the plain :mod:`brain.errors` exceptions raised here to
|
|
14
|
+
their own frameworks.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass, replace
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import psycopg
|
|
26
|
+
|
|
27
|
+
from .config import Config
|
|
28
|
+
from .errors import ConnectError
|
|
29
|
+
from .vault._atomic import atomic_write_text
|
|
30
|
+
from .vault.paths import safe_wikilink_alias, strip_md_extension
|
|
31
|
+
from .wiki.build_related import _avg_embedding, _eligible_source_docs
|
|
32
|
+
|
|
33
|
+
_logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# Suggestion-id prefixes (like document-id prefixes) are hex digits + hyphens
|
|
36
|
+
# only; reject anything else before it reaches SQL so a user-supplied ``_`` /
|
|
37
|
+
# ``%`` cannot act as a LIKE wildcard. Mirrors ``queries._UUID_PREFIX_RE``.
|
|
38
|
+
_UUID_PREFIX_RE = re.compile(r"[0-9a-f-]+")
|
|
39
|
+
_MIN_PREFIX_LEN = 6
|
|
40
|
+
|
|
41
|
+
# Markdown heading the ``accept --write`` path appends suggested wikilinks under.
|
|
42
|
+
_SEE_ALSO_HEADING = "## See Also"
|
|
43
|
+
|
|
44
|
+
# --------------------------------------------------------------------------- #
|
|
45
|
+
# ``embedding_affinity`` KNN tuning (Task 4.1 — HNSW-friendly rewrite).
|
|
46
|
+
# --------------------------------------------------------------------------- #
|
|
47
|
+
|
|
48
|
+
# Starting KNN batch size = ``candidate_limit`` × this multiplier. On the live
|
|
49
|
+
# corpus nearly every doc sits above the cosine floor, so the first batch yields
|
|
50
|
+
# far more than ``candidate_limit`` distinct docs and the adaptive re-query
|
|
51
|
+
# below never fires; the multiplier is headroom for a rare cold / sparse source
|
|
52
|
+
# whose nearest chunks cluster onto a handful of docs.
|
|
53
|
+
_KNN_CANDIDATE_MULTIPLIER = 8
|
|
54
|
+
|
|
55
|
+
# Max adaptive doublings before falling back to the exhaustive scan. Bounds the
|
|
56
|
+
# worst case; correctness holds either way (the fallback is exact).
|
|
57
|
+
_KNN_MAX_ITERATIONS = 4
|
|
58
|
+
|
|
59
|
+
# pgvector 0.8.2 GUC. A *filtered* HNSW scan under a post-filter ``LIMIT``
|
|
60
|
+
# under-returns by default: the index hands the planner only ``hnsw.ef_search``
|
|
61
|
+
# rows, most of which the JOIN + eligibility filters then drop (measured on
|
|
62
|
+
# prod: a ``LIMIT 400`` yielded only 27 rows). ``strict_order`` makes the scan
|
|
63
|
+
# ITERATE until the ``LIMIT`` is satisfied AND keeps rows in exact ascending-
|
|
64
|
+
# distance order, which the per-doc-max exactness argument in
|
|
65
|
+
# :func:`_embedding_affinity_knn` relies on. ``SET LOCAL`` scopes it to the one
|
|
66
|
+
# transaction — never a session/global ``SET``. Verified against pgvector 0.8.2:
|
|
67
|
+
# the enum is ``{off, relaxed_order, strict_order}``; ``relaxed_order`` would be
|
|
68
|
+
# faster but breaks the exact-order precondition, so we require ``strict_order``.
|
|
69
|
+
_SET_ITERATIVE_SCAN = "SET LOCAL hnsw.iterative_scan = strict_order"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class SuggestionRow:
|
|
74
|
+
"""One row of the ``link_suggestions`` review queue, joined to doc titles."""
|
|
75
|
+
|
|
76
|
+
id: str
|
|
77
|
+
source_doc_id: str
|
|
78
|
+
target_doc_id: str
|
|
79
|
+
source_title: str
|
|
80
|
+
target_title: str
|
|
81
|
+
score: float
|
|
82
|
+
graph_score: float | None
|
|
83
|
+
embed_score: float | None
|
|
84
|
+
status: str
|
|
85
|
+
suggested_at: datetime | None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class RefreshResult:
|
|
90
|
+
"""Outcome counts for a ``brain connect refresh`` pass."""
|
|
91
|
+
|
|
92
|
+
source_docs: int = 0
|
|
93
|
+
candidates: int = 0
|
|
94
|
+
written: int = 0
|
|
95
|
+
dry_run: bool = False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class ActionResult:
|
|
100
|
+
"""Result of an accept/reject status mutation + writeback metadata.
|
|
101
|
+
|
|
102
|
+
``source_vault_path`` / ``target_vault_path`` / ``target_title`` carry the
|
|
103
|
+
metadata the caller needs to build + insert the ``## See Also`` wikilink
|
|
104
|
+
for ``accept --write`` without a second round-trip. ``wikilink_written`` is
|
|
105
|
+
populated by the caller after it performs (or skips) the file write.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
suggestion_id: str
|
|
109
|
+
status: str
|
|
110
|
+
source_doc_id: str
|
|
111
|
+
target_doc_id: str
|
|
112
|
+
source_vault_path: str | None
|
|
113
|
+
target_vault_path: str | None
|
|
114
|
+
target_title: str
|
|
115
|
+
wikilink_written: bool = False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------------------- #
|
|
119
|
+
# Pure scoring helpers (no I/O).
|
|
120
|
+
# --------------------------------------------------------------------------- #
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def normalized_overlap(shared: int, src_count: int, tgt_count: int) -> float:
|
|
124
|
+
"""Normalized entity overlap ``shared / min(src_count, tgt_count)`` ∈ [0, 1].
|
|
125
|
+
|
|
126
|
+
This is the graph-affinity signal: the fraction of the *smaller* doc's
|
|
127
|
+
entities that the two docs share. A full subset (one doc's entities ⊂ the
|
|
128
|
+
other's) scores ``1.0``; disjoint sets score ``0.0``. Returns ``0.0`` when
|
|
129
|
+
either side has no entities (the denominator would be zero).
|
|
130
|
+
"""
|
|
131
|
+
denom = min(src_count, tgt_count)
|
|
132
|
+
if denom <= 0:
|
|
133
|
+
return 0.0
|
|
134
|
+
return shared / denom
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def score_doc_pair(
|
|
138
|
+
graph_signal: float | None, embed_signal: float | None
|
|
139
|
+
) -> float:
|
|
140
|
+
"""Blend the two affinity legs into one confidence score in [0, 1].
|
|
141
|
+
|
|
142
|
+
The legs are combined as ``(graph + embed) / 2`` with an *absent* leg
|
|
143
|
+
counting as ``0.0`` (fixed denominator of 2). This rewards corroboration: a
|
|
144
|
+
pair confirmed by BOTH the entity-graph leg and the embedding leg outscores
|
|
145
|
+
an otherwise-equal pair seen in only one leg — the whole point of the
|
|
146
|
+
feature. The result is directly comparable to ``cfg.connect_min_score`` and
|
|
147
|
+
is what the review table's ``Score`` column shows (the mean of the displayed
|
|
148
|
+
``Graph`` / ``Embed`` legs).
|
|
149
|
+
|
|
150
|
+
Design note: the plan's prose mentioned ``rrf_contribution`` of leg *ranks*,
|
|
151
|
+
but raw RRF tops out at ~``2/(RRF_K+1)`` ≈ 0.033 — far below any usable
|
|
152
|
+
``connect_min_score`` floor (nothing would ever clear the gate) and
|
|
153
|
+
inconsistent with the plan's display table, where ``Score`` is exactly the
|
|
154
|
+
mean of the raw ``Graph`` / ``Embed`` signals (0.72 = (0.81 + 0.63) / 2).
|
|
155
|
+
This linear blend of the normalized leg signals is the interpretation those
|
|
156
|
+
concrete artifacts pin down.
|
|
157
|
+
"""
|
|
158
|
+
graph = graph_signal if graph_signal is not None else 0.0
|
|
159
|
+
embed = embed_signal if embed_signal is not None else 0.0
|
|
160
|
+
return (graph + embed) / 2.0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# --------------------------------------------------------------------------- #
|
|
164
|
+
# Affinity legs (DB reads).
|
|
165
|
+
# --------------------------------------------------------------------------- #
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def graph_affinity(
|
|
169
|
+
conn: psycopg.Connection[Any],
|
|
170
|
+
*,
|
|
171
|
+
source_doc_id: str,
|
|
172
|
+
tenant_id: str,
|
|
173
|
+
candidate_limit: int,
|
|
174
|
+
) -> dict[str, float]:
|
|
175
|
+
"""Return ``{target_doc_id: graph_score}`` for one source doc.
|
|
176
|
+
|
|
177
|
+
Single set-based query (no per-candidate fanout): joins the source doc's
|
|
178
|
+
entity mentions against every other non-draft, vault-backed doc's mentions,
|
|
179
|
+
counts shared entities, and scores via :func:`normalized_overlap`. Pairs
|
|
180
|
+
with no shared entities are absent (they receive ``graph_score = 0.0`` /
|
|
181
|
+
no graph leg in the blend). Scoped to ``tenant_id`` (the relational
|
|
182
|
+
source-of-truth is multi-tenant; migration 012). Capped at
|
|
183
|
+
``candidate_limit``. Returns an empty dict when the graph is unbuilt /
|
|
184
|
+
AGE-less — the feature degrades to embedding-only.
|
|
185
|
+
"""
|
|
186
|
+
if candidate_limit < 1:
|
|
187
|
+
raise ConnectError(f"candidate_limit must be >= 1 (got {candidate_limit})")
|
|
188
|
+
rows = conn.execute(
|
|
189
|
+
"""
|
|
190
|
+
WITH src_entities AS (
|
|
191
|
+
SELECT gem.entity_id,
|
|
192
|
+
COUNT(*) OVER () AS src_count
|
|
193
|
+
FROM graph_entity_mentions gem
|
|
194
|
+
WHERE gem.document_id = %(source)s::uuid
|
|
195
|
+
AND gem.tenant_id = %(tenant)s
|
|
196
|
+
),
|
|
197
|
+
tgt_entities AS (
|
|
198
|
+
SELECT gem.document_id AS tgt_id,
|
|
199
|
+
gem.entity_id,
|
|
200
|
+
COUNT(*) OVER (PARTITION BY gem.document_id) AS tgt_count
|
|
201
|
+
FROM graph_entity_mentions gem
|
|
202
|
+
JOIN documents d ON d.id = gem.document_id
|
|
203
|
+
WHERE gem.tenant_id = %(tenant)s
|
|
204
|
+
AND d.draft = FALSE
|
|
205
|
+
AND d.vault_path IS NOT NULL
|
|
206
|
+
AND d.id <> %(source)s::uuid
|
|
207
|
+
),
|
|
208
|
+
shared AS (
|
|
209
|
+
SELECT te.tgt_id,
|
|
210
|
+
COUNT(*) AS shared_count,
|
|
211
|
+
MAX(te.tgt_count) AS tgt_count,
|
|
212
|
+
MAX(se.src_count) AS src_count
|
|
213
|
+
FROM src_entities se
|
|
214
|
+
JOIN tgt_entities te USING (entity_id)
|
|
215
|
+
GROUP BY te.tgt_id
|
|
216
|
+
)
|
|
217
|
+
SELECT tgt_id::text, shared_count, src_count, tgt_count
|
|
218
|
+
FROM shared
|
|
219
|
+
WHERE shared_count > 0
|
|
220
|
+
ORDER BY (shared_count::float / LEAST(src_count, tgt_count)) DESC, tgt_id
|
|
221
|
+
LIMIT %(limit)s
|
|
222
|
+
""",
|
|
223
|
+
{
|
|
224
|
+
"source": source_doc_id,
|
|
225
|
+
"tenant": tenant_id,
|
|
226
|
+
"limit": candidate_limit,
|
|
227
|
+
},
|
|
228
|
+
).fetchall()
|
|
229
|
+
scored: list[tuple[str, float]] = [
|
|
230
|
+
(
|
|
231
|
+
str(r[0]),
|
|
232
|
+
normalized_overlap(int(r[1]), int(r[2]), int(r[3])),
|
|
233
|
+
)
|
|
234
|
+
for r in rows
|
|
235
|
+
]
|
|
236
|
+
# Re-sort in Python so the dict insertion order (= rank order) matches the
|
|
237
|
+
# Python-computed scores exactly, independent of any FP drift between the
|
|
238
|
+
# SQL ORDER BY expression and ``normalized_overlap``.
|
|
239
|
+
scored.sort(key=lambda item: (-item[1], item[0]))
|
|
240
|
+
return {tgt_id: score for tgt_id, score in scored}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def embedding_affinity(
|
|
244
|
+
conn: psycopg.Connection[Any],
|
|
245
|
+
*,
|
|
246
|
+
source_doc_id: str,
|
|
247
|
+
candidate_limit: int,
|
|
248
|
+
vector_sim_floor: float,
|
|
249
|
+
knn_multiplier: int = _KNN_CANDIDATE_MULTIPLIER,
|
|
250
|
+
) -> dict[str, float]:
|
|
251
|
+
"""Return ``{target_doc_id: best_cosine}`` for one source doc, rank-ordered.
|
|
252
|
+
|
|
253
|
+
The source doc's average chunk embedding is the query vector (reusing
|
|
254
|
+
:func:`brain.wiki.build_related._avg_embedding`); each candidate doc's score
|
|
255
|
+
is its best per-chunk cosine similarity, floored at ``vector_sim_floor``
|
|
256
|
+
(the same floor runtime ``brain search`` uses). Dict insertion order is the
|
|
257
|
+
cosine-descending rank order (ties broken by target-doc id). Empty when the
|
|
258
|
+
source doc has no embedded chunks (cold corpus) — the feature degrades to
|
|
259
|
+
graph-only.
|
|
260
|
+
|
|
261
|
+
Task 4.1 rewrite: the primary path (:func:`_embedding_affinity_knn`) rides
|
|
262
|
+
the ``chunks_embedding_idx`` HNSW index via a top-K nearest-neighbour scan
|
|
263
|
+
with the eligibility predicates IN the SQL, instead of the pre-4.1
|
|
264
|
+
``MAX(...) GROUP BY`` + range-predicate query that seq-scanned every chunk
|
|
265
|
+
(measured ~1.2s/doc on prod → ~40ms). When the adaptive re-query cannot
|
|
266
|
+
converge within :data:`_KNN_MAX_ITERATIONS` (a cold / sparse source whose
|
|
267
|
+
nearest chunks pile onto a handful of docs) it falls back to the exact
|
|
268
|
+
:func:`_embedding_affinity_exhaustive` — correctness beats speed. The
|
|
269
|
+
output contract is identical across both paths.
|
|
270
|
+
|
|
271
|
+
``knn_multiplier`` sets the first KNN batch (``candidate_limit`` ×
|
|
272
|
+
multiplier); it exists so tests can force truncation with a small value and
|
|
273
|
+
prove the adaptive re-query converges to the exhaustive result. Production
|
|
274
|
+
callers leave it at the default.
|
|
275
|
+
"""
|
|
276
|
+
if candidate_limit < 1:
|
|
277
|
+
raise ConnectError(f"candidate_limit must be >= 1 (got {candidate_limit})")
|
|
278
|
+
if knn_multiplier < 1:
|
|
279
|
+
raise ConnectError(f"knn_multiplier must be >= 1 (got {knn_multiplier})")
|
|
280
|
+
src_embedding = _avg_embedding(conn, source_doc_id)
|
|
281
|
+
if src_embedding is None:
|
|
282
|
+
return {}
|
|
283
|
+
knn = _embedding_affinity_knn(
|
|
284
|
+
conn,
|
|
285
|
+
src_embedding=src_embedding,
|
|
286
|
+
source_doc_id=source_doc_id,
|
|
287
|
+
candidate_limit=candidate_limit,
|
|
288
|
+
vector_sim_floor=vector_sim_floor,
|
|
289
|
+
knn_multiplier=knn_multiplier,
|
|
290
|
+
)
|
|
291
|
+
if knn is not None:
|
|
292
|
+
return knn
|
|
293
|
+
return _embedding_affinity_exhaustive(
|
|
294
|
+
conn,
|
|
295
|
+
src_embedding=src_embedding,
|
|
296
|
+
source_doc_id=source_doc_id,
|
|
297
|
+
candidate_limit=candidate_limit,
|
|
298
|
+
vector_sim_floor=vector_sim_floor,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# KNN leg: the eligibility predicates live IN the query so ``LIMIT k`` counts
|
|
303
|
+
# only eligible chunks and the HNSW index (``chunks_embedding_idx``) drives the
|
|
304
|
+
# ordering. Paired with :data:`_SET_ITERATIVE_SCAN` so the filtered scan does
|
|
305
|
+
# not under-return.
|
|
306
|
+
_KNN_SQL = """
|
|
307
|
+
SELECT c.document_id::text AS doc_id,
|
|
308
|
+
1 - (c.embedding <=> %(vec)s::vector) AS cosine
|
|
309
|
+
FROM chunks c
|
|
310
|
+
JOIN documents d ON d.id = c.document_id
|
|
311
|
+
WHERE d.draft = FALSE
|
|
312
|
+
AND d.vault_path IS NOT NULL
|
|
313
|
+
AND d.id <> %(source)s::uuid
|
|
314
|
+
AND c.embedding IS NOT NULL
|
|
315
|
+
ORDER BY c.embedding <=> %(vec)s::vector
|
|
316
|
+
LIMIT %(k)s
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _embedding_affinity_knn(
|
|
321
|
+
conn: psycopg.Connection[Any],
|
|
322
|
+
*,
|
|
323
|
+
src_embedding: Any,
|
|
324
|
+
source_doc_id: str,
|
|
325
|
+
candidate_limit: int,
|
|
326
|
+
vector_sim_floor: float,
|
|
327
|
+
knn_multiplier: int,
|
|
328
|
+
) -> dict[str, float] | None:
|
|
329
|
+
"""Ride ``chunks_embedding_idx``: fetch K nearest eligible chunks → floor →
|
|
330
|
+
per-doc max → sort → truncate. Returns the ``{doc: best_cosine}`` mapping, or
|
|
331
|
+
``None`` to signal "did not converge — use the exhaustive fallback".
|
|
332
|
+
|
|
333
|
+
Exactness argument (why the returned docs match the exhaustive scan): with
|
|
334
|
+
``strict_order`` the ``LIMIT k`` rows are the true k nearest eligible chunks
|
|
335
|
+
in exact ascending-distance (descending-cosine) order. So:
|
|
336
|
+
|
|
337
|
+
* Every doc that APPEARS has its globally-best chunk among the k — that
|
|
338
|
+
chunk's cosine is ≥ the k-th (smallest returned) cosine, and any other
|
|
339
|
+
chunk of the doc is ≤ its best, so nothing better sits beyond the cut.
|
|
340
|
+
Its per-doc max is therefore exact.
|
|
341
|
+
* Every doc NOT among the k has all chunks below the k-th cosine, hence a
|
|
342
|
+
true best below every captured doc's best.
|
|
343
|
+
|
|
344
|
+
Consequently, once the k nearest chunks contain ≥ ``candidate_limit``
|
|
345
|
+
distinct floor-passing docs, the top ``candidate_limit`` by best cosine are
|
|
346
|
+
fully and exactly determined (no unseen doc can outrank them). We also stop
|
|
347
|
+
early when the batch is provably complete: it under-filled (all eligible
|
|
348
|
+
chunks seen) or its smallest returned cosine already fell below the floor
|
|
349
|
+
(nothing further can pass). Otherwise we double k and retry, capped at
|
|
350
|
+
:data:`_KNN_MAX_ITERATIONS` before conceding to the exhaustive fallback.
|
|
351
|
+
|
|
352
|
+
``SET LOCAL`` is scoped by :meth:`psycopg.Connection.transaction`; under the
|
|
353
|
+
autocommit connection the CLI uses this opens a real ``BEGIN``/``COMMIT`` so
|
|
354
|
+
the GUC never leaks past the scan. (Assumes the eligible chunk count stays
|
|
355
|
+
within ``hnsw.max_scan_tuples`` so an under-fill means true exhaustion —
|
|
356
|
+
true for the current corpus; the exhaustive fallback backstops the rest.)
|
|
357
|
+
"""
|
|
358
|
+
k = candidate_limit * knn_multiplier
|
|
359
|
+
with conn.transaction():
|
|
360
|
+
conn.execute(_SET_ITERATIVE_SCAN)
|
|
361
|
+
for _ in range(_KNN_MAX_ITERATIONS):
|
|
362
|
+
rows = conn.execute(
|
|
363
|
+
_KNN_SQL,
|
|
364
|
+
{"vec": src_embedding, "source": source_doc_id, "k": k},
|
|
365
|
+
).fetchall()
|
|
366
|
+
floor_rows = [
|
|
367
|
+
(str(r[0]), float(r[1]))
|
|
368
|
+
for r in rows
|
|
369
|
+
if float(r[1]) >= vector_sim_floor
|
|
370
|
+
]
|
|
371
|
+
distinct_docs = {doc_id for doc_id, _ in floor_rows}
|
|
372
|
+
# Provably complete when the batch under-filled (every eligible
|
|
373
|
+
# chunk seen) or its smallest returned cosine is already below the
|
|
374
|
+
# floor (rows are in exact descending-cosine order, so nothing
|
|
375
|
+
# further can pass).
|
|
376
|
+
seen_all = len(rows) < k or (
|
|
377
|
+
len(rows) > 0 and float(rows[-1][1]) < vector_sim_floor
|
|
378
|
+
)
|
|
379
|
+
if seen_all or len(distinct_docs) >= candidate_limit:
|
|
380
|
+
return _group_per_doc_max(floor_rows, candidate_limit)
|
|
381
|
+
k *= 2
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _group_per_doc_max(
|
|
386
|
+
floor_rows: list[tuple[str, float]], candidate_limit: int
|
|
387
|
+
) -> dict[str, float]:
|
|
388
|
+
"""Collapse floor-passing ``(doc_id, cosine)`` rows to the per-doc max,
|
|
389
|
+
ordered cosine-descending then doc-id, truncated to ``candidate_limit``.
|
|
390
|
+
|
|
391
|
+
Mirrors the exhaustive query's ``MAX(...) ... ORDER BY cosine DESC, doc_id
|
|
392
|
+
LIMIT`` in Python. Values are byte-identical to the SQL aggregate: both take
|
|
393
|
+
the max of the same float8 cosines each backend computed.
|
|
394
|
+
"""
|
|
395
|
+
by_doc: dict[str, float] = {}
|
|
396
|
+
for doc_id, cosine in floor_rows:
|
|
397
|
+
prev = by_doc.get(doc_id)
|
|
398
|
+
if prev is None or cosine > prev:
|
|
399
|
+
by_doc[doc_id] = cosine
|
|
400
|
+
ordered = sorted(by_doc.items(), key=lambda item: (-item[1], item[0]))
|
|
401
|
+
return {doc_id: cosine for doc_id, cosine in ordered[:candidate_limit]}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _embedding_affinity_exhaustive(
|
|
405
|
+
conn: psycopg.Connection[Any],
|
|
406
|
+
*,
|
|
407
|
+
src_embedding: Any,
|
|
408
|
+
source_doc_id: str,
|
|
409
|
+
candidate_limit: int,
|
|
410
|
+
vector_sim_floor: float,
|
|
411
|
+
) -> dict[str, float]:
|
|
412
|
+
"""Pre-4.1 exhaustive ``MAX(...) GROUP BY`` scan — exact but seq-scans every
|
|
413
|
+
chunk (the range predicate on the cosine defeats the HNSW index).
|
|
414
|
+
|
|
415
|
+
Retained verbatim as the correctness fallback for the rare case where the
|
|
416
|
+
adaptive KNN re-query in :func:`_embedding_affinity_knn` cannot converge
|
|
417
|
+
(a cold / sparse corpus where k would have to grow past the cap). Callers
|
|
418
|
+
reach it only after that path returns ``None``.
|
|
419
|
+
"""
|
|
420
|
+
rows = conn.execute(
|
|
421
|
+
"""
|
|
422
|
+
SELECT d.id::text AS doc_id,
|
|
423
|
+
MAX(1 - (c.embedding <=> %(vec)s::vector)) AS cosine
|
|
424
|
+
FROM chunks c
|
|
425
|
+
JOIN documents d ON d.id = c.document_id
|
|
426
|
+
WHERE d.draft = FALSE
|
|
427
|
+
AND d.vault_path IS NOT NULL
|
|
428
|
+
AND d.id <> %(source)s::uuid
|
|
429
|
+
AND c.embedding IS NOT NULL
|
|
430
|
+
AND 1 - (c.embedding <=> %(vec)s::vector) >= %(floor)s
|
|
431
|
+
GROUP BY d.id
|
|
432
|
+
ORDER BY cosine DESC, doc_id
|
|
433
|
+
LIMIT %(limit)s
|
|
434
|
+
""",
|
|
435
|
+
{
|
|
436
|
+
"vec": src_embedding,
|
|
437
|
+
"source": source_doc_id,
|
|
438
|
+
"floor": vector_sim_floor,
|
|
439
|
+
"limit": candidate_limit,
|
|
440
|
+
},
|
|
441
|
+
).fetchall()
|
|
442
|
+
return {str(r[0]): float(r[1]) for r in rows}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _existing_link_targets(
|
|
446
|
+
conn: psycopg.Connection[Any], source_doc_id: str
|
|
447
|
+
) -> set[str]:
|
|
448
|
+
"""Return target ids already linked from ``source_doc_id``.
|
|
449
|
+
|
|
450
|
+
Reads both ``links`` (extracted wikilinks) and ``derived_links``
|
|
451
|
+
(fence-emitted edges); both use ``src_document_id`` / ``dst_document_id``
|
|
452
|
+
(verified: migrations 003 + 005). A pair already present in either table is
|
|
453
|
+
dropped from the candidate set so ``connect`` never re-suggests an existing
|
|
454
|
+
edge.
|
|
455
|
+
|
|
456
|
+
Both ``links`` and ``derived_links`` are treated as UNDIRECTED here, because
|
|
457
|
+
a suggestion pair is undirected for review purposes (migration 022): if A
|
|
458
|
+
and B are already connected in EITHER orientation, the pair must not be
|
|
459
|
+
re-suggested in either orientation. ``links`` rows are individually directed
|
|
460
|
+
(a wikilink from A's body to B), so we union both the ``src = source`` and
|
|
461
|
+
``dst = source`` directions. ``derived_links`` are stored as a single
|
|
462
|
+
CANONICAL ``(LEAST, GREATEST)`` pair (already undirected — see
|
|
463
|
+
``derived_links.pass_runner._canonical_pair``).
|
|
464
|
+
"""
|
|
465
|
+
rows = conn.execute(
|
|
466
|
+
"""
|
|
467
|
+
SELECT dst_document_id::text FROM links WHERE src_document_id = %(src)s::uuid
|
|
468
|
+
UNION
|
|
469
|
+
SELECT src_document_id::text FROM links WHERE dst_document_id = %(src)s::uuid
|
|
470
|
+
UNION
|
|
471
|
+
SELECT dst_document_id::text FROM derived_links WHERE src_document_id = %(src)s::uuid
|
|
472
|
+
UNION
|
|
473
|
+
SELECT src_document_id::text FROM derived_links WHERE dst_document_id = %(src)s::uuid
|
|
474
|
+
""",
|
|
475
|
+
{"src": source_doc_id},
|
|
476
|
+
).fetchall()
|
|
477
|
+
return {str(r[0]) for r in rows}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _retire_linked_pending(
|
|
481
|
+
conn: psycopg.Connection[Any], source_ids: list[str]
|
|
482
|
+
) -> int:
|
|
483
|
+
"""Delete ``pending`` suggestions whose pair is now in ``links`` / ``derived_links``.
|
|
484
|
+
|
|
485
|
+
Closes the window where a suggestion is queued, then the user draws the link
|
|
486
|
+
manually (or a derived edge appears) — the stale pending row must leave the
|
|
487
|
+
queue on the next refresh. Only ``pending`` rows are removed (accepted /
|
|
488
|
+
rejected are frozen). Returns the row count deleted.
|
|
489
|
+
|
|
490
|
+
Scoping is by EITHER endpoint (``source_doc_id`` OR ``target_doc_id`` in
|
|
491
|
+
``source_ids``), not just the stored source: suggestions are undirected
|
|
492
|
+
(migration 022), so the stored orientation is not stable. A partial
|
|
493
|
+
``refresh --doc B`` must still retire a pending row stored as ``A -> B`` once
|
|
494
|
+
the pair is linked, even though ``B`` is the row's target, not its source.
|
|
495
|
+
|
|
496
|
+
Both ``links`` and ``derived_links`` are matched UNDIRECTED: a link drawn in
|
|
497
|
+
EITHER orientation retires the pending row regardless of how the suggestion
|
|
498
|
+
stored its source/target.
|
|
499
|
+
"""
|
|
500
|
+
if not source_ids:
|
|
501
|
+
return 0
|
|
502
|
+
cur = conn.execute(
|
|
503
|
+
"""
|
|
504
|
+
DELETE FROM link_suggestions ls
|
|
505
|
+
WHERE ls.status = 'pending'
|
|
506
|
+
AND (ls.source_doc_id = ANY(%(srcs)s::uuid[])
|
|
507
|
+
OR ls.target_doc_id = ANY(%(srcs)s::uuid[]))
|
|
508
|
+
AND (
|
|
509
|
+
EXISTS (
|
|
510
|
+
-- links are matched undirected — match either orientation.
|
|
511
|
+
SELECT 1 FROM links l
|
|
512
|
+
WHERE (l.src_document_id = ls.source_doc_id
|
|
513
|
+
AND l.dst_document_id = ls.target_doc_id)
|
|
514
|
+
OR (l.src_document_id = ls.target_doc_id
|
|
515
|
+
AND l.dst_document_id = ls.source_doc_id)
|
|
516
|
+
)
|
|
517
|
+
OR EXISTS (
|
|
518
|
+
-- derived_links are canonical/undirected — match either way.
|
|
519
|
+
SELECT 1 FROM derived_links d
|
|
520
|
+
WHERE (d.src_document_id = ls.source_doc_id
|
|
521
|
+
AND d.dst_document_id = ls.target_doc_id)
|
|
522
|
+
OR (d.src_document_id = ls.target_doc_id
|
|
523
|
+
AND d.dst_document_id = ls.source_doc_id)
|
|
524
|
+
)
|
|
525
|
+
)
|
|
526
|
+
""",
|
|
527
|
+
{"srcs": source_ids},
|
|
528
|
+
)
|
|
529
|
+
return cur.rowcount
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# --------------------------------------------------------------------------- #
|
|
533
|
+
# Refresh pipeline (DB read + write).
|
|
534
|
+
# --------------------------------------------------------------------------- #
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def refresh_suggestions(
|
|
538
|
+
conn: psycopg.Connection[Any],
|
|
539
|
+
cfg: Config,
|
|
540
|
+
*,
|
|
541
|
+
doc_prefix: str | None = None,
|
|
542
|
+
dry_run: bool = False,
|
|
543
|
+
) -> RefreshResult:
|
|
544
|
+
"""Recompute candidate suggestions and upsert the survivors.
|
|
545
|
+
|
|
546
|
+
For each eligible source doc (non-draft, vault-backed, ≥1 embedded chunk —
|
|
547
|
+
the same predicate as ``build_related``), blend the graph + embedding legs
|
|
548
|
+
via RRF, drop pairs already linked or scoring below ``cfg.connect_min_score``,
|
|
549
|
+
keep the top ``cfg.connect_max_per_doc``, and upsert into
|
|
550
|
+
``link_suggestions``. Suggestions are UNDIRECTED (migration 022): exactly one
|
|
551
|
+
row persists per unordered pair, storing the best-scoring orientation (see
|
|
552
|
+
:func:`_upsert_suggestion`). Accepted/rejected rows are frozen and their
|
|
553
|
+
mirror is never re-suggested.
|
|
554
|
+
|
|
555
|
+
``doc_prefix`` limits the refresh to a single source doc (resolved via the
|
|
556
|
+
document-id prefix). ``dry_run`` computes everything but writes nothing.
|
|
557
|
+
The caller owns transaction scope (commit/autocommit).
|
|
558
|
+
"""
|
|
559
|
+
if cfg.connect_candidate_limit < 1:
|
|
560
|
+
raise ConnectError(
|
|
561
|
+
f"connect_candidate_limit must be >= 1 (got {cfg.connect_candidate_limit})"
|
|
562
|
+
)
|
|
563
|
+
if cfg.connect_max_per_doc < 1:
|
|
564
|
+
raise ConnectError(
|
|
565
|
+
f"connect_max_per_doc must be >= 1 (got {cfg.connect_max_per_doc})"
|
|
566
|
+
)
|
|
567
|
+
if not (0.0 < cfg.connect_min_score <= 1.0):
|
|
568
|
+
raise ConnectError(
|
|
569
|
+
f"connect_min_score must be in (0.0, 1.0] (got {cfg.connect_min_score})"
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
sources = _eligible_source_docs(conn)
|
|
573
|
+
if doc_prefix is not None:
|
|
574
|
+
# Resolve the prefix lazily here (kept out of the import surface) so a
|
|
575
|
+
# bad prefix surfaces the same IdPrefix* errors the rest of the CLI uses.
|
|
576
|
+
from .queries import resolve_document_prefix
|
|
577
|
+
|
|
578
|
+
target_id = resolve_document_prefix(conn, doc_prefix)
|
|
579
|
+
sources = [s for s in sources if s.id == target_id]
|
|
580
|
+
|
|
581
|
+
if not dry_run:
|
|
582
|
+
# Retire any PENDING suggestion whose pair has since been linked (a
|
|
583
|
+
# manual wikilink or derived edge added after it was queued) so the
|
|
584
|
+
# review queue never re-surfaces an edge that already exists (Codex
|
|
585
|
+
# R2 #1). Scoped to the sources being refreshed; accepted/rejected
|
|
586
|
+
# rows are frozen and untouched.
|
|
587
|
+
_retire_linked_pending(conn, [s.id for s in sources])
|
|
588
|
+
|
|
589
|
+
candidates_total = 0
|
|
590
|
+
written_total = 0
|
|
591
|
+
for source in sources:
|
|
592
|
+
scored = _score_source(conn, cfg, source_doc_id=source.id)
|
|
593
|
+
candidates_total += len(scored)
|
|
594
|
+
kept = [pair for pair in scored if pair[1] >= cfg.connect_min_score]
|
|
595
|
+
kept = kept[: cfg.connect_max_per_doc]
|
|
596
|
+
written_total += len(kept)
|
|
597
|
+
if dry_run:
|
|
598
|
+
continue
|
|
599
|
+
for target_id, score, graph_score, embed_score in kept:
|
|
600
|
+
_upsert_suggestion(
|
|
601
|
+
conn,
|
|
602
|
+
source_doc_id=source.id,
|
|
603
|
+
target_doc_id=target_id,
|
|
604
|
+
score=score,
|
|
605
|
+
graph_score=graph_score,
|
|
606
|
+
embed_score=embed_score,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
_logger.info(
|
|
610
|
+
"connect refresh: sources=%d candidates=%d written=%d dry_run=%s",
|
|
611
|
+
len(sources),
|
|
612
|
+
candidates_total,
|
|
613
|
+
written_total,
|
|
614
|
+
dry_run,
|
|
615
|
+
)
|
|
616
|
+
return RefreshResult(
|
|
617
|
+
source_docs=len(sources),
|
|
618
|
+
candidates=candidates_total,
|
|
619
|
+
written=written_total,
|
|
620
|
+
dry_run=dry_run,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _score_source(
|
|
625
|
+
conn: psycopg.Connection[Any],
|
|
626
|
+
cfg: Config,
|
|
627
|
+
*,
|
|
628
|
+
source_doc_id: str,
|
|
629
|
+
) -> list[tuple[str, float, float | None, float | None]]:
|
|
630
|
+
"""Blend both legs for one source doc → ranked ``(tgt, score, g, e)`` list.
|
|
631
|
+
|
|
632
|
+
The returned list is sorted by RRF score descending (ties broken by target
|
|
633
|
+
id) and has the already-linked pairs removed. ``g`` / ``e`` are the raw
|
|
634
|
+
graph / embedding leg signals (or ``None`` when the pair is absent from
|
|
635
|
+
that leg) — stored on the row for display, distinct from the blended
|
|
636
|
+
``score``.
|
|
637
|
+
"""
|
|
638
|
+
graph = graph_affinity(
|
|
639
|
+
conn,
|
|
640
|
+
source_doc_id=source_doc_id,
|
|
641
|
+
tenant_id=cfg.graph_tenant_id,
|
|
642
|
+
candidate_limit=cfg.connect_candidate_limit,
|
|
643
|
+
)
|
|
644
|
+
embed = embedding_affinity(
|
|
645
|
+
conn,
|
|
646
|
+
source_doc_id=source_doc_id,
|
|
647
|
+
candidate_limit=cfg.connect_candidate_limit,
|
|
648
|
+
vector_sim_floor=cfg.vector_sim_floor,
|
|
649
|
+
)
|
|
650
|
+
already_linked = _existing_link_targets(conn, source_doc_id)
|
|
651
|
+
|
|
652
|
+
blended: list[tuple[str, float, float | None, float | None]] = []
|
|
653
|
+
for target_id in set(graph) | set(embed):
|
|
654
|
+
if target_id in already_linked:
|
|
655
|
+
continue
|
|
656
|
+
graph_score = graph.get(target_id)
|
|
657
|
+
embed_score = embed.get(target_id)
|
|
658
|
+
score = score_doc_pair(graph_score, embed_score)
|
|
659
|
+
blended.append((target_id, score, graph_score, embed_score))
|
|
660
|
+
blended.sort(key=lambda item: (-item[1], item[0]))
|
|
661
|
+
return blended
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _upsert_suggestion(
|
|
665
|
+
conn: psycopg.Connection[Any],
|
|
666
|
+
*,
|
|
667
|
+
source_doc_id: str,
|
|
668
|
+
target_doc_id: str,
|
|
669
|
+
score: float,
|
|
670
|
+
graph_score: float | None,
|
|
671
|
+
embed_score: float | None,
|
|
672
|
+
) -> None:
|
|
673
|
+
"""Upsert one UNDIRECTED suggestion, keeping the best-scoring orientation.
|
|
674
|
+
|
|
675
|
+
A suggested pair is undirected for review purposes (migration 022): exactly
|
|
676
|
+
one row may exist per unordered pair, regardless of which doc is stored as
|
|
677
|
+
source vs target. The conflict is inferred against the functional unique
|
|
678
|
+
index ``uq_link_suggestions_unordered_pair`` on
|
|
679
|
+
``(LEAST(source, target), GREATEST(source, target))``.
|
|
680
|
+
|
|
681
|
+
On conflict the row is rewritten — INCLUDING its orientation
|
|
682
|
+
(``source_doc_id`` / ``target_doc_id``) — to the incoming candidate, but
|
|
683
|
+
ONLY when the existing row is ``pending`` AND the new ``score`` is strictly
|
|
684
|
+
greater. This keeps the better-scoring orientation (so the ``accept --write``
|
|
685
|
+
writeback targets the more sensible "source" doc) and never thrashes on
|
|
686
|
+
ties. Because the conflict consumes the insert, an accepted/rejected pair
|
|
687
|
+
can NOT have its mirror re-inserted: refresh never resuggests the reverse of
|
|
688
|
+
a decided pair (the WHERE leaves the frozen row untouched and no new row is
|
|
689
|
+
created).
|
|
690
|
+
"""
|
|
691
|
+
conn.execute(
|
|
692
|
+
"""
|
|
693
|
+
INSERT INTO link_suggestions
|
|
694
|
+
(source_doc_id, target_doc_id, score, graph_score, embed_score)
|
|
695
|
+
VALUES (%(src)s::uuid, %(dst)s::uuid, %(score)s, %(graph)s, %(embed)s)
|
|
696
|
+
ON CONFLICT (LEAST(source_doc_id, target_doc_id),
|
|
697
|
+
GREATEST(source_doc_id, target_doc_id)) DO UPDATE
|
|
698
|
+
SET source_doc_id = EXCLUDED.source_doc_id,
|
|
699
|
+
target_doc_id = EXCLUDED.target_doc_id,
|
|
700
|
+
score = EXCLUDED.score,
|
|
701
|
+
graph_score = EXCLUDED.graph_score,
|
|
702
|
+
embed_score = EXCLUDED.embed_score,
|
|
703
|
+
suggested_at = now()
|
|
704
|
+
WHERE link_suggestions.status = 'pending'
|
|
705
|
+
AND EXCLUDED.score > link_suggestions.score
|
|
706
|
+
""",
|
|
707
|
+
{
|
|
708
|
+
"src": source_doc_id,
|
|
709
|
+
"dst": target_doc_id,
|
|
710
|
+
"score": score,
|
|
711
|
+
"graph": graph_score,
|
|
712
|
+
"embed": embed_score,
|
|
713
|
+
},
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
# --------------------------------------------------------------------------- #
|
|
718
|
+
# Queue reads.
|
|
719
|
+
# --------------------------------------------------------------------------- #
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def iter_suggestions(
|
|
723
|
+
conn: psycopg.Connection[Any],
|
|
724
|
+
*,
|
|
725
|
+
status: str | None = "pending",
|
|
726
|
+
limit: int = 20,
|
|
727
|
+
) -> list[SuggestionRow]:
|
|
728
|
+
"""Return suggestions joined to their source/target titles.
|
|
729
|
+
|
|
730
|
+
``status=None`` returns every row (the ``--all`` view); otherwise filters to
|
|
731
|
+
one status. Ordered by score descending. ``limit`` caps the result.
|
|
732
|
+
"""
|
|
733
|
+
rows = conn.execute(
|
|
734
|
+
"""
|
|
735
|
+
SELECT ls.id::text, ls.source_doc_id::text, ls.target_doc_id::text,
|
|
736
|
+
sd.title, td.title, ls.score, ls.graph_score, ls.embed_score,
|
|
737
|
+
ls.status, ls.suggested_at
|
|
738
|
+
FROM link_suggestions ls
|
|
739
|
+
JOIN documents sd ON sd.id = ls.source_doc_id
|
|
740
|
+
JOIN documents td ON td.id = ls.target_doc_id
|
|
741
|
+
WHERE (%(status)s::text IS NULL OR ls.status = %(status)s)
|
|
742
|
+
ORDER BY ls.score DESC, ls.id
|
|
743
|
+
LIMIT %(limit)s
|
|
744
|
+
""",
|
|
745
|
+
{"status": status, "limit": limit},
|
|
746
|
+
).fetchall()
|
|
747
|
+
return [
|
|
748
|
+
SuggestionRow(
|
|
749
|
+
id=str(r[0]),
|
|
750
|
+
source_doc_id=str(r[1]),
|
|
751
|
+
target_doc_id=str(r[2]),
|
|
752
|
+
source_title=str(r[3] or ""),
|
|
753
|
+
target_title=str(r[4] or ""),
|
|
754
|
+
score=float(r[5]),
|
|
755
|
+
graph_score=None if r[6] is None else float(r[6]),
|
|
756
|
+
embed_score=None if r[7] is None else float(r[7]),
|
|
757
|
+
status=str(r[8]),
|
|
758
|
+
suggested_at=r[9],
|
|
759
|
+
)
|
|
760
|
+
for r in rows
|
|
761
|
+
]
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def suggestion_counts(conn: psycopg.Connection[Any]) -> dict[str, int]:
|
|
765
|
+
"""Return ``{status: count}`` for the three statuses (zero-filled)."""
|
|
766
|
+
rows = conn.execute(
|
|
767
|
+
"SELECT status, COUNT(*) FROM link_suggestions GROUP BY status"
|
|
768
|
+
).fetchall()
|
|
769
|
+
counts = {"pending": 0, "accepted": 0, "rejected": 0}
|
|
770
|
+
for status, count in rows:
|
|
771
|
+
counts[str(status)] = int(count)
|
|
772
|
+
return counts
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def resolve_suggestion_prefix(conn: psycopg.Connection[Any], prefix: str) -> str:
|
|
776
|
+
"""Resolve a suggestion-id prefix (min 6 hex chars) to a full id.
|
|
777
|
+
|
|
778
|
+
Mirrors :func:`brain.queries.resolve_document_prefix` but against
|
|
779
|
+
``link_suggestions``. Raises :class:`ConnectError` on a too-short /
|
|
780
|
+
non-hex / not-found / ambiguous prefix.
|
|
781
|
+
"""
|
|
782
|
+
if len(prefix) < _MIN_PREFIX_LEN:
|
|
783
|
+
raise ConnectError("suggestion id prefix must be at least 6 characters")
|
|
784
|
+
if not _UUID_PREFIX_RE.fullmatch(prefix):
|
|
785
|
+
raise ConnectError(
|
|
786
|
+
"suggestion id prefix must contain only hex digits and hyphens"
|
|
787
|
+
)
|
|
788
|
+
rows = conn.execute(
|
|
789
|
+
"SELECT id::text FROM link_suggestions WHERE id::text LIKE %s",
|
|
790
|
+
(prefix + "%",),
|
|
791
|
+
).fetchall()
|
|
792
|
+
if not rows:
|
|
793
|
+
raise ConnectError(f"suggestion not found: {prefix}")
|
|
794
|
+
if len(rows) > 1:
|
|
795
|
+
raise ConnectError(f"suggestion id prefix ambiguous: {prefix}")
|
|
796
|
+
return str(rows[0][0])
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
# --------------------------------------------------------------------------- #
|
|
800
|
+
# Accept / reject (status mutation + writeback metadata).
|
|
801
|
+
# --------------------------------------------------------------------------- #
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def load_action_context(
|
|
805
|
+
conn: psycopg.Connection[Any], suggestion_id: str
|
|
806
|
+
) -> ActionResult:
|
|
807
|
+
"""Return a suggestion's current state + writeback metadata WITHOUT mutating.
|
|
808
|
+
|
|
809
|
+
Lets an ``accept --write`` caller fetch the source/target vault paths and
|
|
810
|
+
title, perform the vault write FIRST, and only then flip the status — so a
|
|
811
|
+
write failure never leaves the row frozen ``accepted`` with no wikilink
|
|
812
|
+
(Codex R1 #1). ``status`` is the row's current status. Raises
|
|
813
|
+
:class:`ConnectError` for a missing suggestion id.
|
|
814
|
+
"""
|
|
815
|
+
row = conn.execute(
|
|
816
|
+
"""
|
|
817
|
+
SELECT ls.status, ls.source_doc_id::text, ls.target_doc_id::text,
|
|
818
|
+
sd.vault_path, td.vault_path, td.title
|
|
819
|
+
FROM link_suggestions ls
|
|
820
|
+
JOIN documents sd ON sd.id = ls.source_doc_id
|
|
821
|
+
JOIN documents td ON td.id = ls.target_doc_id
|
|
822
|
+
WHERE ls.id = %s::uuid
|
|
823
|
+
""",
|
|
824
|
+
(suggestion_id,),
|
|
825
|
+
).fetchone()
|
|
826
|
+
if row is None:
|
|
827
|
+
raise ConnectError(f"suggestion not found: {suggestion_id}")
|
|
828
|
+
return ActionResult(
|
|
829
|
+
suggestion_id=suggestion_id,
|
|
830
|
+
status=str(row[0]),
|
|
831
|
+
source_doc_id=str(row[1]),
|
|
832
|
+
target_doc_id=str(row[2]),
|
|
833
|
+
source_vault_path=row[3],
|
|
834
|
+
target_vault_path=row[4],
|
|
835
|
+
target_title=str(row[5] or ""),
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def set_suggestion_status(
|
|
840
|
+
conn: psycopg.Connection[Any], suggestion_id: str, status: str
|
|
841
|
+
) -> ActionResult:
|
|
842
|
+
"""Flip a suggestion to ``accepted`` / ``rejected`` and stamp ``actioned_at``.
|
|
843
|
+
|
|
844
|
+
Returns the writeback metadata (source/target vault paths + target title)
|
|
845
|
+
so an ``accept --write`` caller can build + insert the wikilink without a
|
|
846
|
+
second query. Raises :class:`ConnectError` for an unknown status or a
|
|
847
|
+
missing suggestion id.
|
|
848
|
+
"""
|
|
849
|
+
if status not in ("accepted", "rejected"):
|
|
850
|
+
raise ConnectError(
|
|
851
|
+
f"status must be 'accepted' or 'rejected' (got {status!r})"
|
|
852
|
+
)
|
|
853
|
+
ctx = load_action_context(conn, suggestion_id)
|
|
854
|
+
conn.execute(
|
|
855
|
+
"UPDATE link_suggestions SET status = %s, actioned_at = now() WHERE id = %s::uuid",
|
|
856
|
+
(status, suggestion_id),
|
|
857
|
+
)
|
|
858
|
+
return replace(ctx, status=status)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
# --------------------------------------------------------------------------- #
|
|
862
|
+
# Vault writeback primitives (Typer-free; shared by CLI + MCP).
|
|
863
|
+
# --------------------------------------------------------------------------- #
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def build_see_also_wikilink(target_vault_path: str, target_title: str) -> str:
|
|
867
|
+
"""Build the path-form alias wikilink ``[[<path-no-md>|<title>]]``.
|
|
868
|
+
|
|
869
|
+
The alias (path) form — not a bare ``[[Title]]`` — guards against
|
|
870
|
+
misresolution when two docs share a title. The target path is stripped of
|
|
871
|
+
its ``.md`` suffix (matching ``_resolve_by_vault_path``); the title has
|
|
872
|
+
brackets sanitized (Quartz alias rule) and any ``|`` replaced with ``-``
|
|
873
|
+
(the wikilink delimiter).
|
|
874
|
+
"""
|
|
875
|
+
link_path = strip_md_extension(target_vault_path)
|
|
876
|
+
safe_title = safe_wikilink_alias(target_title).replace("|", "-")
|
|
877
|
+
return f"[[{link_path}|{safe_title}]]"
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def append_see_also_link(vault_file: Path, wikilink: str) -> bool:
|
|
881
|
+
"""Insert ``- <wikilink>`` into the file's ``## See Also`` section.
|
|
882
|
+
|
|
883
|
+
Idempotent: returns ``False`` (no write) when ``wikilink`` is already in the
|
|
884
|
+
file. Otherwise:
|
|
885
|
+
|
|
886
|
+
- If a ``## See Also`` heading exists (anywhere — not only as the trailing
|
|
887
|
+
section, Codex R1 #2), the bullet is inserted at the END of that section
|
|
888
|
+
(just before the next markdown heading, or EOF), so it never lands under
|
|
889
|
+
an unrelated later section.
|
|
890
|
+
- Otherwise a new ``## See Also`` section is appended at the end of the file.
|
|
891
|
+
|
|
892
|
+
Writes via :func:`atomic_write_text` and returns ``True``. Never logs the
|
|
893
|
+
file body (privacy rule: ids / paths only).
|
|
894
|
+
"""
|
|
895
|
+
text = vault_file.read_text(encoding="utf-8") if vault_file.exists() else ""
|
|
896
|
+
if wikilink in text:
|
|
897
|
+
return False
|
|
898
|
+
bullet = f"- {wikilink}"
|
|
899
|
+
lines = text.splitlines()
|
|
900
|
+
heading_idx = next(
|
|
901
|
+
(i for i, line in enumerate(lines) if line.strip() == _SEE_ALSO_HEADING),
|
|
902
|
+
None,
|
|
903
|
+
)
|
|
904
|
+
if heading_idx is None:
|
|
905
|
+
body = text.rstrip("\n")
|
|
906
|
+
new_text = (
|
|
907
|
+
f"{body}\n\n{_SEE_ALSO_HEADING}\n\n{bullet}\n"
|
|
908
|
+
if body
|
|
909
|
+
else f"{_SEE_ALSO_HEADING}\n\n{bullet}\n"
|
|
910
|
+
)
|
|
911
|
+
atomic_write_text(vault_file, new_text)
|
|
912
|
+
return True
|
|
913
|
+
# Find the end of the See Also section: the next markdown heading after it,
|
|
914
|
+
# or EOF. Insert the bullet just after the section's last non-blank line.
|
|
915
|
+
section_end = len(lines)
|
|
916
|
+
for j in range(heading_idx + 1, len(lines)):
|
|
917
|
+
if lines[j].startswith("#"):
|
|
918
|
+
section_end = j
|
|
919
|
+
break
|
|
920
|
+
insert_at = section_end
|
|
921
|
+
while insert_at - 1 > heading_idx and lines[insert_at - 1].strip() == "":
|
|
922
|
+
insert_at -= 1
|
|
923
|
+
lines.insert(insert_at, bullet)
|
|
924
|
+
atomic_write_text(vault_file, "\n".join(lines) + "\n")
|
|
925
|
+
return True
|