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/queries.py
ADDED
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
"""Shared read-side SQL helpers used by both the CLI and the MCP server.
|
|
2
|
+
|
|
3
|
+
This module exists to avoid duplicating identical SELECTs and prefix-resolution
|
|
4
|
+
logic across two callers. The helpers raise plain :mod:`brain.errors`
|
|
5
|
+
exceptions so each caller can map them to its own framework's error type.
|
|
6
|
+
"""
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import psycopg
|
|
15
|
+
import yaml
|
|
16
|
+
from psycopg import sql
|
|
17
|
+
|
|
18
|
+
from .embedding_targets import (
|
|
19
|
+
embedding_index_name,
|
|
20
|
+
validate_embedding_target,
|
|
21
|
+
)
|
|
22
|
+
from .errors import (
|
|
23
|
+
IdPrefixAmbiguous,
|
|
24
|
+
IdPrefixNotFound,
|
|
25
|
+
IdPrefixNotHex,
|
|
26
|
+
IdPrefixTooShort,
|
|
27
|
+
PersonAmbiguous,
|
|
28
|
+
PersonNotFound,
|
|
29
|
+
)
|
|
30
|
+
from .ingest import Embedder
|
|
31
|
+
|
|
32
|
+
# libyaml's C loader parses the flat scalar frontmatter mappings ~11x faster
|
|
33
|
+
# than the pure-Python SafeLoader while returning identical values; fall back to
|
|
34
|
+
# the pure-Python loader when the C extension is unavailable so mirror-drift
|
|
35
|
+
# behavior is unchanged on those builds.
|
|
36
|
+
try:
|
|
37
|
+
from yaml import CSafeLoader as _FrontmatterLoader
|
|
38
|
+
except ImportError: # pragma: no cover - libyaml ships in supported envs
|
|
39
|
+
from yaml import SafeLoader as _FrontmatterLoader # type: ignore[assignment]
|
|
40
|
+
|
|
41
|
+
# pgvector 0.8.x caps HNSW (and IVFFlat) at 2000 dims for ``vector`` and 4000
|
|
42
|
+
# for ``halfvec``. Backends with native dims at or below this limit get an
|
|
43
|
+
# HNSW cosine index at finalize time; higher-dim backends (Qwen3 at 4096)
|
|
44
|
+
# use sequential scan, acceptable at personal-corpus scale.
|
|
45
|
+
_PGVECTOR_HNSW_DIM_CAP = 2000
|
|
46
|
+
|
|
47
|
+
# UUID prefixes consist solely of hex digits and hyphens; anything else is
|
|
48
|
+
# rejected before reaching SQL so user-supplied `_` / `%` cannot act as LIKE
|
|
49
|
+
# wildcards.
|
|
50
|
+
_UUID_PREFIX_RE = re.compile(r"[0-9a-f-]+")
|
|
51
|
+
_MIN_PREFIX_LEN = 6
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class DocumentRow:
|
|
56
|
+
"""A document row joined with its source kind, used by show + list views."""
|
|
57
|
+
|
|
58
|
+
id: str
|
|
59
|
+
title: str
|
|
60
|
+
content_type: str
|
|
61
|
+
tags: list[str]
|
|
62
|
+
source_kind: str | None
|
|
63
|
+
ingested_at: datetime | None
|
|
64
|
+
# Show-only fields. Populated by :func:`fetch_document`; ``None`` after
|
|
65
|
+
# :func:`list_documents` (which returns the lighter projection).
|
|
66
|
+
content: str | None = None
|
|
67
|
+
source_path: str | None = None
|
|
68
|
+
# Wave Q1-D — per-document auto-summary. Populated by
|
|
69
|
+
# :func:`fetch_document` (so ``brain show`` + MCP ``brain_show`` can
|
|
70
|
+
# surface it); :func:`list_documents` leaves them at ``None`` to keep
|
|
71
|
+
# the listing projection cheap.
|
|
72
|
+
summary: str | None = None
|
|
73
|
+
summary_model: str | None = None
|
|
74
|
+
summary_at: datetime | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_document_prefix(conn: psycopg.Connection[Any], prefix: str) -> str:
|
|
78
|
+
"""Resolve a UUID prefix (min 6 chars) to a full document id.
|
|
79
|
+
|
|
80
|
+
Raises :class:`IdPrefixTooShort`, :class:`IdPrefixNotHex`,
|
|
81
|
+
:class:`IdPrefixNotFound`, or :class:`IdPrefixAmbiguous` so the caller can
|
|
82
|
+
map to its framework's user-facing error type.
|
|
83
|
+
"""
|
|
84
|
+
if len(prefix) < _MIN_PREFIX_LEN:
|
|
85
|
+
raise IdPrefixTooShort("id prefix must be at least 6 characters")
|
|
86
|
+
if not _UUID_PREFIX_RE.fullmatch(prefix):
|
|
87
|
+
raise IdPrefixNotHex(
|
|
88
|
+
"id prefix must contain only hex digits and hyphens"
|
|
89
|
+
)
|
|
90
|
+
rows = conn.execute(
|
|
91
|
+
"SELECT id::text FROM documents WHERE id::text LIKE %s",
|
|
92
|
+
(prefix + "%",),
|
|
93
|
+
).fetchall()
|
|
94
|
+
if not rows:
|
|
95
|
+
raise IdPrefixNotFound(prefix)
|
|
96
|
+
if len(rows) > 1:
|
|
97
|
+
raise IdPrefixAmbiguous(prefix)
|
|
98
|
+
return str(rows[0][0])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class PersonMatch:
|
|
103
|
+
"""Resolved person → participant-key list for SQL array-overlap filtering.
|
|
104
|
+
|
|
105
|
+
``keys`` is the lower-cased list of every identifier (display name +
|
|
106
|
+
emails + their ``"Display <email>"`` combination forms) that should
|
|
107
|
+
overlap with a doc's ``documents.participants`` array to count as a
|
|
108
|
+
match. Q1-C uses this to power ``--person`` on ``brain search`` /
|
|
109
|
+
``brain explain`` / MCP ``brain_search``.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
display_name: str
|
|
113
|
+
keys: list[str]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _canonicalize_display_name(name: str) -> str:
|
|
117
|
+
"""Reduce a display name to a comparison-only canonical form.
|
|
118
|
+
|
|
119
|
+
Treats common identity-equivalent variants as the same person:
|
|
120
|
+
``"person-x person-j"`` and ``"person-x.person-j"`` and ``"person-j"`` and
|
|
121
|
+
``"person-x person-j"`` all canonicalize to ``"person-j person-j"``. Used
|
|
122
|
+
inside :func:`resolve_person_to_keys` to dedupe step-3 substring hits
|
|
123
|
+
that point at one logical person stored under several formattings —
|
|
124
|
+
common when directory entries come from both Gmail header names and
|
|
125
|
+
Krisp speaker labels for the same individual.
|
|
126
|
+
|
|
127
|
+
The canonical form is NOT used for storage or display — only for
|
|
128
|
+
grouping aggregate_people records inside the resolver. Mirror writes
|
|
129
|
+
keep the original casing.
|
|
130
|
+
"""
|
|
131
|
+
folded = name.strip().casefold()
|
|
132
|
+
# Treat dots / underscores / hyphens as word separators so
|
|
133
|
+
# "person-x.person-j" canonicalizes to "person-j person-j".
|
|
134
|
+
for sep in (".", "_", "-"):
|
|
135
|
+
folded = folded.replace(sep, " ")
|
|
136
|
+
# Collapse runs of whitespace (incl. NBSP) to a single space.
|
|
137
|
+
return " ".join(folded.split())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _expand_person_keys(display_name: str, emails: list[str]) -> list[str]:
|
|
141
|
+
"""Build the SQL-overlap key list for one person.
|
|
142
|
+
|
|
143
|
+
``documents.participants`` is a free-form ``TEXT[]`` written by ingest
|
|
144
|
+
extractors. Gmail emits a mix of bare emails (``alice@x.com``) and
|
|
145
|
+
``"Display <email>"`` combination strings; Krisp emits display names
|
|
146
|
+
only. To match all three forms with a single GIN-friendly ``&&``
|
|
147
|
+
overlap predicate, we expand each person's identity into every form
|
|
148
|
+
they might have been recorded under and let the array operator do
|
|
149
|
+
the union.
|
|
150
|
+
|
|
151
|
+
Returns the deduplicated list, sorted for determinism so tests +
|
|
152
|
+
explain payloads stay byte-stable across runs.
|
|
153
|
+
"""
|
|
154
|
+
keys: set[str] = set()
|
|
155
|
+
name = (display_name or "").strip().lower()
|
|
156
|
+
if name:
|
|
157
|
+
keys.add(name)
|
|
158
|
+
lowered_emails: list[str] = []
|
|
159
|
+
for raw in emails:
|
|
160
|
+
if not raw:
|
|
161
|
+
continue
|
|
162
|
+
normalized = raw.strip().lower()
|
|
163
|
+
if not normalized:
|
|
164
|
+
continue
|
|
165
|
+
keys.add(normalized)
|
|
166
|
+
lowered_emails.append(normalized)
|
|
167
|
+
# ``Display <email>`` combination form — Gmail emits this for any
|
|
168
|
+
# header where the sender / recipient had a display-name component.
|
|
169
|
+
if name:
|
|
170
|
+
for email in lowered_emails:
|
|
171
|
+
keys.add(f"{name} <{email}>")
|
|
172
|
+
return sorted(keys)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _expand_keys_with_directory_variants(
|
|
176
|
+
conn: psycopg.Connection[Any], *, display_name: str, emails: list[str]
|
|
177
|
+
) -> list[str]:
|
|
178
|
+
"""Expand person keys across every RAW directory display-name variant.
|
|
179
|
+
|
|
180
|
+
:func:`brain.wiki.build_people.aggregate_people` collapses separator /
|
|
181
|
+
ordering variants of one person (``jane.doe`` vs ``Jane Doe``) into a
|
|
182
|
+
single canonical record, so the resolver only ever sees the canonical
|
|
183
|
+
display name. Docs, however, store ``participants`` under whichever RAW
|
|
184
|
+
variant their source emitted — Gmail dot-form headers vs Krisp space-form
|
|
185
|
+
speaker labels. A variant whose combo key is never emitted makes its docs
|
|
186
|
+
silently invisible to ``--person`` (Wave A.2, Codex Q1 HIGH). Re-fetch the
|
|
187
|
+
raw variants (linked by shared email or identical canonical key) and union
|
|
188
|
+
the expansion over all of them.
|
|
189
|
+
"""
|
|
190
|
+
from .wiki._person_name import normalize_person_name
|
|
191
|
+
|
|
192
|
+
keys: set[str] = set(_expand_person_keys(display_name, emails))
|
|
193
|
+
base = normalize_person_name(display_name)
|
|
194
|
+
canonical_key = base.canonical_key if base is not None else None
|
|
195
|
+
email_set = {e.strip().lower() for e in emails if e and e.strip()}
|
|
196
|
+
rows = conn.execute(
|
|
197
|
+
"SELECT DISTINCT display_name, email FROM directory_entries "
|
|
198
|
+
"WHERE display_name <> ''"
|
|
199
|
+
).fetchall()
|
|
200
|
+
for raw_name, raw_email in rows:
|
|
201
|
+
linked = (raw_email or "").strip().lower() in email_set
|
|
202
|
+
if not linked and canonical_key is not None:
|
|
203
|
+
normalized = normalize_person_name(raw_name)
|
|
204
|
+
linked = normalized is not None and normalized.canonical_key == canonical_key
|
|
205
|
+
if linked:
|
|
206
|
+
keys.update(_expand_person_keys(raw_name, emails))
|
|
207
|
+
return sorted(keys)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def resolve_person_to_keys(
|
|
211
|
+
conn: psycopg.Connection[Any], name_or_email: str
|
|
212
|
+
) -> PersonMatch:
|
|
213
|
+
"""Resolve a ``--person`` argument to a participant-key set.
|
|
214
|
+
|
|
215
|
+
Resolution order (mirrors ``brain people <name>``):
|
|
216
|
+
|
|
217
|
+
1. Exact email match (case-insensitive) against any
|
|
218
|
+
``directory_entries.email``.
|
|
219
|
+
2. Exact display-name match against ``directory_entries.display_name``
|
|
220
|
+
(case-folded).
|
|
221
|
+
3. Case-insensitive substring on ``display_name``; alpha-first
|
|
222
|
+
tiebreak when multiple records match.
|
|
223
|
+
|
|
224
|
+
Per plan §3.b D16, the resolver calls
|
|
225
|
+
:func:`brain.wiki.build_people.aggregate_people` with
|
|
226
|
+
``min_docs=0`` and ``owner_keys=frozenset()`` so query-time
|
|
227
|
+
``--person`` filters see every known person — including the corpus
|
|
228
|
+
owner and curated-but-low-doc-count entries that the People Hub UI
|
|
229
|
+
threshold would filter out. The UI's display rules should not gate
|
|
230
|
+
a query filter.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
PersonAmbiguous: Multiple persons matched at step (2) or (3).
|
|
234
|
+
The ``candidates`` attribute carries the top-5 display names
|
|
235
|
+
for caller-side disambiguation messages.
|
|
236
|
+
PersonNotFound: No match at any step.
|
|
237
|
+
"""
|
|
238
|
+
# Late import: avoid a top-level dependency on the wiki package so
|
|
239
|
+
# ``brain.queries`` stays import-cheap for non-search code paths.
|
|
240
|
+
from .wiki.build_people import aggregate_people, humanize_display_name
|
|
241
|
+
|
|
242
|
+
needle = name_or_email.strip().casefold()
|
|
243
|
+
if not needle:
|
|
244
|
+
raise PersonNotFound(name_or_email)
|
|
245
|
+
|
|
246
|
+
records = aggregate_people(
|
|
247
|
+
conn, owner_keys=frozenset(), min_docs=0
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Step 1 — exact email match.
|
|
251
|
+
for rec in records:
|
|
252
|
+
for email in rec.all_emails:
|
|
253
|
+
if email.casefold() == needle:
|
|
254
|
+
return PersonMatch(
|
|
255
|
+
display_name=humanize_display_name(rec.display_name),
|
|
256
|
+
keys=_expand_keys_with_directory_variants(
|
|
257
|
+
conn, display_name=rec.display_name, emails=rec.all_emails
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def _merge_or_ambiguous(hits: list[Any]) -> PersonMatch | None:
|
|
262
|
+
"""Group hits by canonical display name. Single canonical group →
|
|
263
|
+
merge all hit records' keys into one PersonMatch. Multiple canonical
|
|
264
|
+
groups → raise PersonAmbiguous with the deduplicated candidate set.
|
|
265
|
+
Returns None when ``hits`` is empty so callers fall through.
|
|
266
|
+
|
|
267
|
+
This is what distinguishes "person-x person-j" + "person-x.person-j" (same
|
|
268
|
+
canonical, one logical person, MERGE) from "John Smith" + "John
|
|
269
|
+
Smith Jr" (different canonicals, two people, AMBIGUOUS).
|
|
270
|
+
"""
|
|
271
|
+
if not hits:
|
|
272
|
+
return None
|
|
273
|
+
grouped: dict[str, list[Any]] = {}
|
|
274
|
+
for rec in hits:
|
|
275
|
+
grouped.setdefault(_canonicalize_display_name(rec.display_name), []).append(rec)
|
|
276
|
+
if len(grouped) == 1:
|
|
277
|
+
members = next(iter(grouped.values()))
|
|
278
|
+
merged_emails: list[str] = []
|
|
279
|
+
seen: set[str] = set()
|
|
280
|
+
for rec in members:
|
|
281
|
+
for email in rec.all_emails:
|
|
282
|
+
if email and email.lower() not in seen:
|
|
283
|
+
seen.add(email.lower())
|
|
284
|
+
merged_emails.append(email)
|
|
285
|
+
# Prefer the humanized display name from the alphabetically-first
|
|
286
|
+
# record so output stays deterministic across runs.
|
|
287
|
+
canonical_rec = sorted(members, key=lambda r: r.display_name)[0]
|
|
288
|
+
return PersonMatch(
|
|
289
|
+
display_name=humanize_display_name(canonical_rec.display_name),
|
|
290
|
+
keys=_expand_keys_with_directory_variants(
|
|
291
|
+
conn,
|
|
292
|
+
display_name=canonical_rec.display_name,
|
|
293
|
+
emails=merged_emails,
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
raise PersonAmbiguous(
|
|
297
|
+
name_or_email,
|
|
298
|
+
sorted(humanize_display_name(members[0].display_name) for members in grouped.values()),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Step 2 — canonical-name exact match. Compares the canonical form of
|
|
302
|
+
# each record against the canonical form of the needle so equivalents
|
|
303
|
+
# like ``"person-x person-j"`` / ``"person-x.person-j"`` / ``"person-j"``
|
|
304
|
+
# all match the query ``"person-j person-j"`` at this strict-identity tier.
|
|
305
|
+
canonical_needle = _canonicalize_display_name(name_or_email)
|
|
306
|
+
exact_name_hits = [
|
|
307
|
+
rec
|
|
308
|
+
for rec in records
|
|
309
|
+
if _canonicalize_display_name(rec.display_name) == canonical_needle
|
|
310
|
+
]
|
|
311
|
+
if (match := _merge_or_ambiguous(exact_name_hits)) is not None:
|
|
312
|
+
return match
|
|
313
|
+
|
|
314
|
+
# Step 3 — canonical-name substring match. Same canonicalization rules
|
|
315
|
+
# so a query like ``"person-j"`` catches both ``"person-x person-j"`` and
|
|
316
|
+
# ``"person-x.person-j"`` (both canonicalize to ``"person-j person-j"``, both
|
|
317
|
+
# contain ``"person-j"``). The merge pass then collapses them into one
|
|
318
|
+
# PersonMatch.
|
|
319
|
+
substring_hits = [
|
|
320
|
+
rec
|
|
321
|
+
for rec in records
|
|
322
|
+
if canonical_needle in _canonicalize_display_name(rec.display_name)
|
|
323
|
+
]
|
|
324
|
+
if (match := _merge_or_ambiguous(substring_hits)) is not None:
|
|
325
|
+
return match
|
|
326
|
+
|
|
327
|
+
raise PersonNotFound(name_or_email)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def fetch_document(conn: psycopg.Connection[Any], document_id: str) -> DocumentRow | None:
|
|
331
|
+
"""Return the full document row for ``document_id`` (or ``None`` if missing).
|
|
332
|
+
|
|
333
|
+
Includes the document body, ``source_path``, and the Q1-D summary
|
|
334
|
+
triple; pair with :func:`resolve_document_prefix` when the caller has
|
|
335
|
+
a prefix instead.
|
|
336
|
+
"""
|
|
337
|
+
row = conn.execute(
|
|
338
|
+
"""
|
|
339
|
+
SELECT d.id::text, d.title, d.content, d.content_type, d.tags,
|
|
340
|
+
d.source_path, d.ingested_at, s.kind,
|
|
341
|
+
d.summary, d.summary_model, d.summary_at
|
|
342
|
+
FROM documents d
|
|
343
|
+
LEFT JOIN sources s ON s.id = d.source_id
|
|
344
|
+
WHERE d.id = %s
|
|
345
|
+
""",
|
|
346
|
+
(document_id,),
|
|
347
|
+
).fetchone()
|
|
348
|
+
if row is None:
|
|
349
|
+
return None
|
|
350
|
+
return DocumentRow(
|
|
351
|
+
id=row[0],
|
|
352
|
+
title=row[1],
|
|
353
|
+
content=row[2],
|
|
354
|
+
content_type=row[3],
|
|
355
|
+
tags=list(row[4] or []),
|
|
356
|
+
source_path=row[5],
|
|
357
|
+
ingested_at=row[6],
|
|
358
|
+
source_kind=row[7],
|
|
359
|
+
summary=row[8],
|
|
360
|
+
summary_model=row[9],
|
|
361
|
+
summary_at=row[10],
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def fetch_document_summary(
|
|
366
|
+
conn: psycopg.Connection[Any], document_id: str
|
|
367
|
+
) -> tuple[str, str | None] | None:
|
|
368
|
+
"""Return ``(title, summary)`` for ``document_id`` (or ``None`` if missing).
|
|
369
|
+
|
|
370
|
+
The summary-only projection used by the Plan 04 audio bundle path. It
|
|
371
|
+
deliberately selects ``title`` + ``summary`` ONLY — never ``content`` — so a
|
|
372
|
+
full document body can never be loaded into the LLM script prompt (the audio
|
|
373
|
+
privacy contract). :func:`fetch_document` always selects ``d.content`` and so
|
|
374
|
+
is never called from the audio bundle path.
|
|
375
|
+
|
|
376
|
+
``summary`` is ``None`` for a not-yet-enriched document; the caller falls
|
|
377
|
+
back to the title alone in that case.
|
|
378
|
+
"""
|
|
379
|
+
row = conn.execute(
|
|
380
|
+
"SELECT title, summary FROM documents WHERE id = %s",
|
|
381
|
+
(document_id,),
|
|
382
|
+
).fetchone()
|
|
383
|
+
if row is None:
|
|
384
|
+
return None
|
|
385
|
+
return (row[0], row[1])
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@dataclass(frozen=True)
|
|
389
|
+
class UnenrichedDocument:
|
|
390
|
+
"""One row from :func:`iter_unenriched_documents` — the backfill driver.
|
|
391
|
+
|
|
392
|
+
Carries just enough state for :class:`brain.enrichment.OllamaEnricher`
|
|
393
|
+
to summarize the doc and for the caller to write the result back. The
|
|
394
|
+
backfill loop reads this in batches via keyset pagination so memory
|
|
395
|
+
stays bounded on a large corpus.
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
id: str
|
|
399
|
+
title: str
|
|
400
|
+
content: str
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def iter_unenriched_documents(
|
|
404
|
+
conn: psycopg.Connection[Any],
|
|
405
|
+
*,
|
|
406
|
+
batch_size: int = 32,
|
|
407
|
+
current_model: str | None = None,
|
|
408
|
+
) -> Iterator[list[UnenrichedDocument]]:
|
|
409
|
+
"""Yield batches of documents that need enrichment.
|
|
410
|
+
|
|
411
|
+
Uses keyset pagination over ``documents.id`` (same shape as
|
|
412
|
+
:func:`iter_chunks_missing_embedding`) so the in-memory footprint stays
|
|
413
|
+
bounded regardless of corpus size.
|
|
414
|
+
|
|
415
|
+
Eligibility:
|
|
416
|
+
|
|
417
|
+
- ``current_model is None`` (default) → rows with ``summary IS NULL``
|
|
418
|
+
only. Backed by the partial index ``idx_documents_summary_null``.
|
|
419
|
+
- ``current_model is not None`` → rows with ``summary IS NULL`` OR
|
|
420
|
+
``summary_model IS DISTINCT FROM current_model``. Lets the backfill
|
|
421
|
+
loop re-enrich after a ``BRAIN_ENRICH_MODEL`` upgrade: an existing
|
|
422
|
+
summary generated by the OLD model is considered stale relative to
|
|
423
|
+
the NEW model, so the new model's improvements propagate to the
|
|
424
|
+
whole corpus. The ``IS DISTINCT FROM`` (vs ``<>``) is critical —
|
|
425
|
+
it matches NULL-summary rows whose ``summary_model`` is also NULL
|
|
426
|
+
without an explicit NULL check.
|
|
427
|
+
|
|
428
|
+
The iterator does NOT do any LLM work itself — the backfill loop in
|
|
429
|
+
:func:`brain.cli.enrich` is what calls the enricher and writes
|
|
430
|
+
``summary`` / ``summary_model`` / ``summary_at`` back. That keeps this
|
|
431
|
+
helper pure / test-friendly.
|
|
432
|
+
"""
|
|
433
|
+
if current_model is None:
|
|
434
|
+
base_where = "summary IS NULL"
|
|
435
|
+
params_base: tuple[Any, ...] = ()
|
|
436
|
+
else:
|
|
437
|
+
base_where = (
|
|
438
|
+
"(summary IS NULL OR summary_model IS DISTINCT FROM %s)"
|
|
439
|
+
)
|
|
440
|
+
params_base = (current_model,)
|
|
441
|
+
|
|
442
|
+
last_id: str | None = None
|
|
443
|
+
while True:
|
|
444
|
+
if last_id is None:
|
|
445
|
+
rows = conn.execute(
|
|
446
|
+
f"SELECT id::text, title, content FROM documents "
|
|
447
|
+
f"WHERE {base_where} "
|
|
448
|
+
f"ORDER BY id LIMIT %s",
|
|
449
|
+
(*params_base, batch_size),
|
|
450
|
+
).fetchall()
|
|
451
|
+
else:
|
|
452
|
+
rows = conn.execute(
|
|
453
|
+
f"SELECT id::text, title, content FROM documents "
|
|
454
|
+
f"WHERE {base_where} AND id > %s::uuid "
|
|
455
|
+
f"ORDER BY id LIMIT %s",
|
|
456
|
+
(*params_base, last_id, batch_size),
|
|
457
|
+
).fetchall()
|
|
458
|
+
if not rows:
|
|
459
|
+
return
|
|
460
|
+
last_id = str(rows[-1][0])
|
|
461
|
+
yield [
|
|
462
|
+
UnenrichedDocument(id=str(r[0]), title=str(r[1]), content=str(r[2]))
|
|
463
|
+
for r in rows
|
|
464
|
+
]
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def count_unenriched_documents(
|
|
468
|
+
conn: psycopg.Connection[Any], *, current_model: str | None = None
|
|
469
|
+
) -> int:
|
|
470
|
+
"""Return the number of documents that need enrichment.
|
|
471
|
+
|
|
472
|
+
Mirrors the eligibility rules of :func:`iter_unenriched_documents`:
|
|
473
|
+
``current_model is None`` counts ``summary IS NULL`` rows only;
|
|
474
|
+
``current_model is not None`` also counts rows whose ``summary_model``
|
|
475
|
+
differs from the supplied model (model-upgrade staleness).
|
|
476
|
+
"""
|
|
477
|
+
if current_model is None:
|
|
478
|
+
row = conn.execute(
|
|
479
|
+
"SELECT count(*) FROM documents WHERE summary IS NULL"
|
|
480
|
+
).fetchone()
|
|
481
|
+
else:
|
|
482
|
+
row = conn.execute(
|
|
483
|
+
"SELECT count(*) FROM documents "
|
|
484
|
+
"WHERE summary IS NULL "
|
|
485
|
+
"OR summary_model IS DISTINCT FROM %s",
|
|
486
|
+
(current_model,),
|
|
487
|
+
).fetchone()
|
|
488
|
+
assert row is not None
|
|
489
|
+
return int(row[0])
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def list_existing_tags(
|
|
493
|
+
conn: psycopg.Connection[Any], *, min_doc_count: int = 1
|
|
494
|
+
) -> list[str]:
|
|
495
|
+
"""Return every tag currently on documents, normalized + alpha-sorted.
|
|
496
|
+
|
|
497
|
+
Used by ``brain tag --auto`` to feed the LLM the existing tag vocabulary
|
|
498
|
+
so it preferentially proposes from it. Drafts are NOT excluded — they
|
|
499
|
+
can still contribute to vocabulary. Stable alpha sort makes test
|
|
500
|
+
fixtures byte-stable.
|
|
501
|
+
|
|
502
|
+
``min_doc_count`` is the inclusive lower bound on how many docs must
|
|
503
|
+
already carry a tag for it to count. Q1-D ships with the default
|
|
504
|
+
``min_doc_count=1`` (every tag counts); a future caller can require an
|
|
505
|
+
established tag (``min_doc_count > 1``) without a schema change.
|
|
506
|
+
"""
|
|
507
|
+
rows = conn.execute(
|
|
508
|
+
"""
|
|
509
|
+
SELECT t, COUNT(*) AS n
|
|
510
|
+
FROM documents, unnest(tags) AS t
|
|
511
|
+
GROUP BY t
|
|
512
|
+
HAVING COUNT(*) >= %s
|
|
513
|
+
ORDER BY t
|
|
514
|
+
""",
|
|
515
|
+
(min_doc_count,),
|
|
516
|
+
).fetchall()
|
|
517
|
+
return [str(r[0]) for r in rows]
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def list_documents(
|
|
521
|
+
conn: psycopg.Connection[Any],
|
|
522
|
+
*,
|
|
523
|
+
source: str | None = None,
|
|
524
|
+
tag: str | None = None,
|
|
525
|
+
limit: int = 20,
|
|
526
|
+
) -> list[DocumentRow]:
|
|
527
|
+
"""Return up to ``limit`` documents (most-recently-ingested first).
|
|
528
|
+
|
|
529
|
+
Optional ``source`` and ``tag`` filters mirror ``brain list``. The returned
|
|
530
|
+
rows omit the document body (``content``) and ``source_path`` to keep the
|
|
531
|
+
projection cheap.
|
|
532
|
+
"""
|
|
533
|
+
where = ["TRUE"]
|
|
534
|
+
params: list[Any] = []
|
|
535
|
+
if source:
|
|
536
|
+
where.append("s.kind = %s")
|
|
537
|
+
params.append(source)
|
|
538
|
+
if tag:
|
|
539
|
+
where.append("%s = ANY(d.tags)")
|
|
540
|
+
params.append(tag)
|
|
541
|
+
sql = f"""
|
|
542
|
+
SELECT d.id::text, d.title, d.content_type, d.tags, s.kind, d.ingested_at
|
|
543
|
+
FROM documents d
|
|
544
|
+
LEFT JOIN sources s ON s.id = d.source_id
|
|
545
|
+
WHERE {" AND ".join(where)}
|
|
546
|
+
ORDER BY d.ingested_at DESC
|
|
547
|
+
LIMIT %s
|
|
548
|
+
"""
|
|
549
|
+
params.append(limit)
|
|
550
|
+
rows = conn.execute(sql, params).fetchall()
|
|
551
|
+
return [
|
|
552
|
+
DocumentRow(
|
|
553
|
+
id=r[0],
|
|
554
|
+
title=r[1],
|
|
555
|
+
content_type=r[2],
|
|
556
|
+
tags=list(r[3] or []),
|
|
557
|
+
source_kind=r[4],
|
|
558
|
+
ingested_at=r[5],
|
|
559
|
+
)
|
|
560
|
+
for r in rows
|
|
561
|
+
]
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@dataclass
|
|
565
|
+
class StatusCounts:
|
|
566
|
+
"""Aggregate counts for ``brain status`` / ``brain_status``.
|
|
567
|
+
|
|
568
|
+
Each caller (CLI vs MCP) formats this into its own response shape.
|
|
569
|
+
"""
|
|
570
|
+
|
|
571
|
+
documents: int
|
|
572
|
+
chunks: int
|
|
573
|
+
sources: int
|
|
574
|
+
last_ingest: datetime | None
|
|
575
|
+
by_kind: list[tuple[str, int]]
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def summary_counts(conn: psycopg.Connection[Any]) -> StatusCounts:
|
|
579
|
+
"""Aggregate counts for ``status`` / ``brain_status``. Single round-trip via CTE.
|
|
580
|
+
|
|
581
|
+
Replaces five sequential SELECTs with one CTE query so ``brain status``
|
|
582
|
+
and the MCP ``brain_status`` tool each pay one network round-trip instead
|
|
583
|
+
of five. The return type (:class:`StatusCounts`) is unchanged.
|
|
584
|
+
"""
|
|
585
|
+
row = conn.execute(
|
|
586
|
+
"""
|
|
587
|
+
WITH
|
|
588
|
+
doc_stats AS (
|
|
589
|
+
SELECT count(*) AS doc_count,
|
|
590
|
+
max(ingested_at) AS last_ingest
|
|
591
|
+
FROM documents
|
|
592
|
+
),
|
|
593
|
+
chunk_count AS (
|
|
594
|
+
SELECT count(*) AS n FROM chunks
|
|
595
|
+
),
|
|
596
|
+
source_count AS (
|
|
597
|
+
SELECT count(*) AS n FROM sources
|
|
598
|
+
),
|
|
599
|
+
by_kind AS (
|
|
600
|
+
SELECT coalesce(s.kind, 'manual') AS kind,
|
|
601
|
+
count(*) AS n
|
|
602
|
+
FROM documents d
|
|
603
|
+
LEFT JOIN sources s ON s.id = d.source_id
|
|
604
|
+
GROUP BY 1
|
|
605
|
+
)
|
|
606
|
+
SELECT
|
|
607
|
+
(SELECT doc_count FROM doc_stats),
|
|
608
|
+
(SELECT last_ingest FROM doc_stats),
|
|
609
|
+
(SELECT n FROM chunk_count),
|
|
610
|
+
(SELECT n FROM source_count),
|
|
611
|
+
(SELECT json_agg(json_build_array(kind, n) ORDER BY n DESC)
|
|
612
|
+
FROM by_kind)
|
|
613
|
+
"""
|
|
614
|
+
).fetchone()
|
|
615
|
+
assert row is not None # constant-expression SELECT always yields one row
|
|
616
|
+
doc_count, last_ingest, chunk_count, source_count, by_kind_json = row
|
|
617
|
+
by_kind: list[tuple[str, int]] = (
|
|
618
|
+
[(str(item[0]), int(item[1])) for item in by_kind_json]
|
|
619
|
+
if by_kind_json is not None
|
|
620
|
+
else []
|
|
621
|
+
)
|
|
622
|
+
return StatusCounts(
|
|
623
|
+
documents=int(doc_count),
|
|
624
|
+
chunks=int(chunk_count),
|
|
625
|
+
sources=int(source_count),
|
|
626
|
+
last_ingest=last_ingest,
|
|
627
|
+
by_kind=by_kind,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@dataclass
|
|
632
|
+
class NullEmbeddingChunk:
|
|
633
|
+
"""A chunk whose embedding is NULL and needs backfill."""
|
|
634
|
+
|
|
635
|
+
id: str
|
|
636
|
+
content: str
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def iter_chunks_missing_embedding(
|
|
640
|
+
conn: psycopg.Connection[Any],
|
|
641
|
+
*,
|
|
642
|
+
batch_size: int = 32,
|
|
643
|
+
include_embedded: bool = False,
|
|
644
|
+
) -> Iterator[list[NullEmbeddingChunk]]:
|
|
645
|
+
"""Yield batches of chunks whose embedding is NULL.
|
|
646
|
+
|
|
647
|
+
Uses keyset pagination over ``chunks.id`` (UUID, ordered) so the
|
|
648
|
+
in-memory footprint stays bounded even on a brain with hundreds of
|
|
649
|
+
thousands of chunks — only one batch's content is materialized at a
|
|
650
|
+
time. Keyset (rather than a server-side ``DECLARE CURSOR``) sidesteps
|
|
651
|
+
the autocommit/transaction subtleties that would arise from issuing
|
|
652
|
+
UPDATEs on the same connection while a named cursor is open.
|
|
653
|
+
|
|
654
|
+
The iterator advances on ``id`` rather than re-running ``LIMIT N``
|
|
655
|
+
against the NULL-set so that callers which inspect rows *without*
|
|
656
|
+
backfilling them (e.g. tests) still see every NULL row exactly once.
|
|
657
|
+
|
|
658
|
+
When ``include_embedded=True``, the ``embedding IS NULL`` filter is
|
|
659
|
+
dropped — the iterator yields every chunk in the table. Used by
|
|
660
|
+
``brain reembed --all`` to re-embed an entire corpus after switching
|
|
661
|
+
embedder backends.
|
|
662
|
+
"""
|
|
663
|
+
null_clause = "" if include_embedded else "WHERE embedding IS NULL"
|
|
664
|
+
null_clause_and = "" if include_embedded else "WHERE embedding IS NULL AND"
|
|
665
|
+
last_id: str | None = None
|
|
666
|
+
while True:
|
|
667
|
+
if last_id is None:
|
|
668
|
+
rows = conn.execute(
|
|
669
|
+
f"SELECT id::text, content FROM chunks "
|
|
670
|
+
f"{null_clause} "
|
|
671
|
+
f"ORDER BY id LIMIT %s",
|
|
672
|
+
(batch_size,),
|
|
673
|
+
).fetchall()
|
|
674
|
+
else:
|
|
675
|
+
cursor_clause = (
|
|
676
|
+
"WHERE id > %s::uuid" if include_embedded else f"{null_clause_and} id > %s::uuid"
|
|
677
|
+
)
|
|
678
|
+
rows = conn.execute(
|
|
679
|
+
f"SELECT id::text, content FROM chunks "
|
|
680
|
+
f"{cursor_clause} "
|
|
681
|
+
f"ORDER BY id LIMIT %s",
|
|
682
|
+
(last_id, batch_size),
|
|
683
|
+
).fetchall()
|
|
684
|
+
if not rows:
|
|
685
|
+
return
|
|
686
|
+
last_id = str(rows[-1][0])
|
|
687
|
+
yield [NullEmbeddingChunk(id=str(r[0]), content=str(r[1])) for r in rows]
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def count_chunks_missing_embedding(
|
|
691
|
+
conn: psycopg.Connection[Any], *, include_embedded: bool = False
|
|
692
|
+
) -> int:
|
|
693
|
+
"""Return the number of chunks whose embedding is NULL.
|
|
694
|
+
|
|
695
|
+
When ``include_embedded=True``, returns the total chunk count instead.
|
|
696
|
+
"""
|
|
697
|
+
sql = (
|
|
698
|
+
"SELECT count(*) FROM chunks"
|
|
699
|
+
if include_embedded
|
|
700
|
+
else "SELECT count(*) FROM chunks WHERE embedding IS NULL"
|
|
701
|
+
)
|
|
702
|
+
row = conn.execute(sql).fetchone()
|
|
703
|
+
assert row is not None # count(*) always yields one row
|
|
704
|
+
return int(row[0])
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def iter_all_document_ids(
|
|
708
|
+
conn: psycopg.Connection[Any], *, batch_size: int = 256
|
|
709
|
+
) -> Iterator[list[str]]:
|
|
710
|
+
"""Yield batches of every document id in ascending ``id`` order.
|
|
711
|
+
|
|
712
|
+
Drives ``brain graphrag build --backfill`` — the batch equivalent of the
|
|
713
|
+
per-document graph reconcile hook. Uses keyset pagination over
|
|
714
|
+
``documents.id`` (UUID, ordered) so the in-memory footprint stays bounded on
|
|
715
|
+
a large corpus, mirroring :func:`iter_chunks_missing_embedding` /
|
|
716
|
+
:func:`iter_unenriched_documents`.
|
|
717
|
+
|
|
718
|
+
The deterministic ascending-id order is what makes a build resumable: a
|
|
719
|
+
re-run after an interruption revisits the ids in the same order, and the
|
|
720
|
+
reconcile watermark skips the already-indexed prefix cheaply.
|
|
721
|
+
"""
|
|
722
|
+
last_id: str | None = None
|
|
723
|
+
while True:
|
|
724
|
+
if last_id is None:
|
|
725
|
+
rows = conn.execute(
|
|
726
|
+
"SELECT id::text FROM documents ORDER BY id LIMIT %s",
|
|
727
|
+
(batch_size,),
|
|
728
|
+
).fetchall()
|
|
729
|
+
else:
|
|
730
|
+
rows = conn.execute(
|
|
731
|
+
"SELECT id::text FROM documents WHERE id > %s::uuid "
|
|
732
|
+
"ORDER BY id LIMIT %s",
|
|
733
|
+
(last_id, batch_size),
|
|
734
|
+
).fetchall()
|
|
735
|
+
if not rows:
|
|
736
|
+
return
|
|
737
|
+
last_id = str(rows[-1][0])
|
|
738
|
+
yield [str(r[0]) for r in rows]
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def count_documents(conn: psycopg.Connection[Any]) -> int:
|
|
742
|
+
"""Return the total number of documents (drives build progress reporting)."""
|
|
743
|
+
row = conn.execute("SELECT count(*) FROM documents").fetchone()
|
|
744
|
+
assert row is not None # count(*) always yields one row
|
|
745
|
+
return int(row[0])
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def count_documents_with_tag(conn: psycopg.Connection[Any], tag: str) -> int:
|
|
749
|
+
"""Return the number of documents carrying ``tag`` in their ``tags`` array.
|
|
750
|
+
|
|
751
|
+
Parameterized membership test (``%s = ANY(tags)``) over ``documents.tags``.
|
|
752
|
+
Backs the ``brain doctor`` inbox-size WARN and the ``brain capture`` inbox
|
|
753
|
+
count (Plan 09 quick-capture). ``tag`` is matched verbatim — callers
|
|
754
|
+
normalize it (e.g. via :func:`brain.tags.normalize_tags`) before counting
|
|
755
|
+
when the input might not already be canonical.
|
|
756
|
+
"""
|
|
757
|
+
row = conn.execute(
|
|
758
|
+
"SELECT count(*) FROM documents WHERE %s = ANY(tags)", (tag,)
|
|
759
|
+
).fetchone()
|
|
760
|
+
assert row is not None # count(*) always yields one row
|
|
761
|
+
return int(row[0])
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _count_null_embedding(
|
|
765
|
+
conn: psycopg.Connection[Any], table: str, column: str
|
|
766
|
+
) -> int:
|
|
767
|
+
"""Count rows in ``<table>`` whose vector ``<column>`` is NULL.
|
|
768
|
+
|
|
769
|
+
Identifiers are quoted via :class:`psycopg.sql.Identifier`; callers
|
|
770
|
+
validate ``(table, column)`` against the allowlist before reaching here.
|
|
771
|
+
"""
|
|
772
|
+
count_sql = sql.SQL(
|
|
773
|
+
"SELECT count(*) FROM {table} WHERE {column} IS NULL"
|
|
774
|
+
).format(table=sql.Identifier(table), column=sql.Identifier(column))
|
|
775
|
+
row = conn.execute(count_sql).fetchone()
|
|
776
|
+
assert row is not None # count(*) always yields one row
|
|
777
|
+
return int(row[0])
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def finalize_embedding_index(
|
|
781
|
+
conn: psycopg.Connection[Any],
|
|
782
|
+
embedder: Embedder,
|
|
783
|
+
table: str = "chunks",
|
|
784
|
+
column: str = "embedding",
|
|
785
|
+
*,
|
|
786
|
+
create_hnsw: bool = True,
|
|
787
|
+
) -> None:
|
|
788
|
+
"""Finalize a pgvector embedding column once its backfill is complete.
|
|
789
|
+
|
|
790
|
+
Generalized over ``(table, column)`` (default ``chunks.embedding``) so the
|
|
791
|
+
GraphRAG tables can reuse it; ``(table, column)`` is checked against the
|
|
792
|
+
hard-coded allowlist in :mod:`brain.embedding_targets` and every identifier
|
|
793
|
+
is quoted via :class:`psycopg.sql.Identifier` — never string-formatted.
|
|
794
|
+
|
|
795
|
+
Two regimes, selected by ``create_hnsw``:
|
|
796
|
+
|
|
797
|
+
- ``create_hnsw=True`` (default — the ``chunks`` semantics, **unchanged**):
|
|
798
|
+
apply ``NOT NULL`` on the column, and for embedders with
|
|
799
|
+
``dim <= 2000`` (arctic, voyage) additionally create an HNSW cosine
|
|
800
|
+
index. pgvector 0.8.x caps HNSW/IVFFlat at 2000 dims for ``vector``, so
|
|
801
|
+
higher-dim embedders (Qwen3 at 4096) get ``NOT NULL`` but skip the index
|
|
802
|
+
— sequential cosine scan is acceptable at personal-corpus scale.
|
|
803
|
+
- ``create_hnsw=False`` (the GraphRAG semantics, e.g.
|
|
804
|
+
``graph_entities.embedding``): the column stays **NULLABLE** with **no**
|
|
805
|
+
HNSW index. Small row counts make sequential scan fine (spec §5) and
|
|
806
|
+
global ranking guards on ``IS NOT NULL`` rather than a column
|
|
807
|
+
constraint, so there is nothing to finalize yet — the call is a
|
|
808
|
+
validated no-op.
|
|
809
|
+
|
|
810
|
+
Idempotent — ``ALTER COLUMN ... SET NOT NULL`` is a no-op if the column
|
|
811
|
+
is already non-nullable, and ``CREATE INDEX IF NOT EXISTS`` is a no-op
|
|
812
|
+
if the index already exists.
|
|
813
|
+
|
|
814
|
+
Raises :class:`ValueError` (only on the ``create_hnsw=True`` path) if any
|
|
815
|
+
row still has a NULL embedding — that's a caller bug (the CLI should only
|
|
816
|
+
call this after asserting the NULL count is zero).
|
|
817
|
+
"""
|
|
818
|
+
validate_embedding_target(table, column)
|
|
819
|
+
if not create_hnsw:
|
|
820
|
+
# GraphRAG deferred mode: column stays NULLABLE, no HNSW index, no
|
|
821
|
+
# NULL-completeness requirement. Nothing to finalize.
|
|
822
|
+
return
|
|
823
|
+
|
|
824
|
+
remaining = _count_null_embedding(conn, table, column)
|
|
825
|
+
if remaining > 0:
|
|
826
|
+
raise ValueError(
|
|
827
|
+
f"cannot finalize: {remaining} {table}.{column} value(s) "
|
|
828
|
+
f"still NULL"
|
|
829
|
+
)
|
|
830
|
+
index_name = embedding_index_name(table, column)
|
|
831
|
+
with conn.transaction():
|
|
832
|
+
conn.execute(
|
|
833
|
+
sql.SQL("ALTER TABLE {table} ALTER COLUMN {column} SET NOT NULL").format(
|
|
834
|
+
table=sql.Identifier(table), column=sql.Identifier(column)
|
|
835
|
+
)
|
|
836
|
+
)
|
|
837
|
+
if embedder.dim <= _PGVECTOR_HNSW_DIM_CAP:
|
|
838
|
+
conn.execute(
|
|
839
|
+
sql.SQL(
|
|
840
|
+
"CREATE INDEX IF NOT EXISTS {index} ON {table} "
|
|
841
|
+
"USING hnsw ({column} vector_cosine_ops)"
|
|
842
|
+
).format(
|
|
843
|
+
index=sql.Identifier(index_name),
|
|
844
|
+
table=sql.Identifier(table),
|
|
845
|
+
column=sql.Identifier(column),
|
|
846
|
+
)
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def sync_chunk_search_metadata(
|
|
851
|
+
conn: psycopg.Connection[Any], document_id: str
|
|
852
|
+
) -> int:
|
|
853
|
+
"""Refresh ``chunks.title_text`` and ``chunks.tags_text`` from documents.
|
|
854
|
+
|
|
855
|
+
Migration 009 denormalizes ``documents.title`` and ``documents.tags`` onto
|
|
856
|
+
every chunk so the weighted FTS tsvector (title at weight A, tags at
|
|
857
|
+
weight B) stays a single GIN-indexable expression. Anything that mutates
|
|
858
|
+
those two source columns without re-inserting chunks must call this so
|
|
859
|
+
the chunk-side denormalization stays consistent — every title/tag UPDATE
|
|
860
|
+
site in the codebase pairs with a call to this helper.
|
|
861
|
+
|
|
862
|
+
The ``IS DISTINCT FROM`` guards on both columns make repeated calls a
|
|
863
|
+
no-op (returns 0). Scoped to one document so the mutation is bounded
|
|
864
|
+
even on a brain with hundreds of thousands of chunks. Returns the
|
|
865
|
+
number of chunk rows actually updated — useful for tests and
|
|
866
|
+
observability.
|
|
867
|
+
"""
|
|
868
|
+
cur = conn.execute(
|
|
869
|
+
"UPDATE chunks "
|
|
870
|
+
"SET title_text = d.title, "
|
|
871
|
+
" tags_text = array_to_string(d.tags, ' ') "
|
|
872
|
+
"FROM documents d "
|
|
873
|
+
"WHERE chunks.document_id = d.id "
|
|
874
|
+
" AND chunks.document_id = %s "
|
|
875
|
+
" AND (chunks.title_text IS DISTINCT FROM d.title "
|
|
876
|
+
" OR chunks.tags_text IS DISTINCT FROM array_to_string(d.tags, ' '))",
|
|
877
|
+
(document_id,),
|
|
878
|
+
)
|
|
879
|
+
return cur.rowcount or 0
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
@dataclass
|
|
883
|
+
class EmbeddingColumnState:
|
|
884
|
+
"""Snapshot of the ``chunks.embedding`` column for ``brain doctor``.
|
|
885
|
+
|
|
886
|
+
``has_index`` reports whether the HNSW cosine index exists. For low-dim
|
|
887
|
+
backends (arctic, voyage) finalize creates it; for Qwen3 (4096 dims) it
|
|
888
|
+
stays absent because pgvector's HNSW cap is 2000.
|
|
889
|
+
"""
|
|
890
|
+
|
|
891
|
+
column_type: str
|
|
892
|
+
not_null: bool
|
|
893
|
+
has_index: bool
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def embedding_column_state(conn: psycopg.Connection[Any]) -> EmbeddingColumnState:
|
|
897
|
+
"""Return the current state of the ``chunks.embedding`` column.
|
|
898
|
+
|
|
899
|
+
Used by ``brain doctor`` to surface the post-migration / pre-finalize
|
|
900
|
+
state to the user without gating exit code on it.
|
|
901
|
+
"""
|
|
902
|
+
col_row = conn.execute(
|
|
903
|
+
"SELECT format_type(atttypid, atttypmod), attnotnull "
|
|
904
|
+
"FROM pg_attribute "
|
|
905
|
+
"WHERE attrelid = 'chunks'::regclass AND attname = 'embedding'"
|
|
906
|
+
).fetchone()
|
|
907
|
+
assert col_row is not None # chunks.embedding always exists post-migration
|
|
908
|
+
idx_row = conn.execute(
|
|
909
|
+
"SELECT 1 FROM pg_indexes WHERE indexname = 'chunks_embedding_idx'"
|
|
910
|
+
).fetchone()
|
|
911
|
+
return EmbeddingColumnState(
|
|
912
|
+
column_type=str(col_row[0]),
|
|
913
|
+
not_null=bool(col_row[1]),
|
|
914
|
+
has_index=idx_row is not None,
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
# ---------------------------------------------------------------------------
|
|
919
|
+
# Mirror drift helpers — used by ``brain doctor`` and
|
|
920
|
+
# ``brain vault prune-orphans`` to surface DB-vs-disk drift in the
|
|
921
|
+
# ``_ingested/`` mirror tier.
|
|
922
|
+
# ---------------------------------------------------------------------------
|
|
923
|
+
|
|
924
|
+
# Top-level vault subdirectory that holds mirror files for stdin/file-ingested
|
|
925
|
+
# documents. Mirrors :mod:`brain.vault.sync._INGESTED_DIR_NAME` — duplicating
|
|
926
|
+
# the constant here keeps ``queries`` from importing the heavier
|
|
927
|
+
# ``vault.sync`` module just to read a string.
|
|
928
|
+
_INGESTED_DIR_NAME = "_ingested"
|
|
929
|
+
|
|
930
|
+
# Frontmatter fence delimiter. Mirrors :data:`brain.vault.frontmatter._FENCE`;
|
|
931
|
+
# duplicated here so the mirror-drift scan can slice a file's frontmatter block
|
|
932
|
+
# without importing the heavier ``vault.frontmatter`` module.
|
|
933
|
+
_FRONTMATTER_FENCE = "---"
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _read_mirror_frontmatter_id(path: Path) -> str | None:
|
|
937
|
+
"""Return the frontmatter ``id`` of a mirror file, or ``None`` to skip it.
|
|
938
|
+
|
|
939
|
+
Semantically identical to ``parse_frontmatter(path.read_text())[0].get("id")``
|
|
940
|
+
for the drift scan's needs — same fence rules, same "no fence / no closing
|
|
941
|
+
fence / malformed YAML / non-mapping / no string id ⇒ skip" behavior — but
|
|
942
|
+
reads only the frontmatter block (never the possibly-megabyte body) and
|
|
943
|
+
loads that block with libyaml when available. Returns the ``id`` string only
|
|
944
|
+
when the file opens with a ``---`` fence, has a closing ``---`` fence, whose
|
|
945
|
+
enclosed YAML parses to a mapping carrying a non-empty string ``id``.
|
|
946
|
+
|
|
947
|
+
``OSError`` (and, matching the previous ``read_text``-based code, a
|
|
948
|
+
``UnicodeDecodeError`` on a non-UTF-8 file) propagates to the caller rather
|
|
949
|
+
than being swallowed; every real mirror file is UTF-8, so this never fires.
|
|
950
|
+
"""
|
|
951
|
+
try:
|
|
952
|
+
with path.open(encoding="utf-8") as handle:
|
|
953
|
+
first = handle.readline()
|
|
954
|
+
if first.rstrip("\r\n") != _FRONTMATTER_FENCE:
|
|
955
|
+
return None
|
|
956
|
+
fm_lines: list[str] = []
|
|
957
|
+
for line in handle:
|
|
958
|
+
if line.rstrip("\r\n") == _FRONTMATTER_FENCE:
|
|
959
|
+
break
|
|
960
|
+
fm_lines.append(line)
|
|
961
|
+
else:
|
|
962
|
+
# No closing fence: the whole file is body, no frontmatter.
|
|
963
|
+
return None
|
|
964
|
+
except OSError:
|
|
965
|
+
return None
|
|
966
|
+
yaml_text = "".join(fm_lines)
|
|
967
|
+
if not yaml_text.strip():
|
|
968
|
+
return None
|
|
969
|
+
try:
|
|
970
|
+
parsed = yaml.load(yaml_text, Loader=_FrontmatterLoader)
|
|
971
|
+
except yaml.YAMLError:
|
|
972
|
+
return None
|
|
973
|
+
if not isinstance(parsed, dict):
|
|
974
|
+
return None
|
|
975
|
+
doc_id = parsed.get("id")
|
|
976
|
+
if not isinstance(doc_id, str) or not doc_id:
|
|
977
|
+
return None
|
|
978
|
+
return doc_id
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _iter_ingested_mirror_ids(vault_path: Path) -> Iterator[tuple[Path, str]]:
|
|
982
|
+
"""Yield ``(path, id)`` for every ``.md`` mirror under ``<vault>/_ingested/``.
|
|
983
|
+
|
|
984
|
+
Walks the mirror tree once in :py:meth:`Path.rglob` sort order (deterministic
|
|
985
|
+
output) and yields only files that carry a resolvable string frontmatter
|
|
986
|
+
``id`` (see :func:`_read_mirror_frontmatter_id`). Files without parseable
|
|
987
|
+
frontmatter or without a string ``id`` are silently skipped — they can't be
|
|
988
|
+
classified by id. Shared by :func:`iter_orphan_mirror_files`,
|
|
989
|
+
:func:`iter_stale_mirror_files`, and :func:`mirror_drift_summary` so the
|
|
990
|
+
expensive read+parse happens exactly once per call site.
|
|
991
|
+
"""
|
|
992
|
+
ingested_dir = vault_path / _INGESTED_DIR_NAME
|
|
993
|
+
if not ingested_dir.is_dir():
|
|
994
|
+
return
|
|
995
|
+
for path in sorted(ingested_dir.rglob("*.md")):
|
|
996
|
+
if not path.is_file():
|
|
997
|
+
continue
|
|
998
|
+
doc_id = _read_mirror_frontmatter_id(path)
|
|
999
|
+
if doc_id is not None:
|
|
1000
|
+
yield path, doc_id
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
@dataclass(frozen=True)
|
|
1004
|
+
class MirrorDriftSummary:
|
|
1005
|
+
"""Counts of DB↔disk drift in the ``_ingested/`` mirror tier.
|
|
1006
|
+
|
|
1007
|
+
All four counters are independent; a healthy vault has zeros across the
|
|
1008
|
+
board. Used by ``brain doctor`` to surface drift the user can act on:
|
|
1009
|
+
|
|
1010
|
+
- ``rows_with_null_vault_path`` → ``brain vault export --force`` to
|
|
1011
|
+
re-materialize mirror files for ingest rows missing one.
|
|
1012
|
+
- ``orphan_files`` → ``brain vault prune-orphans`` to delete mirror files
|
|
1013
|
+
whose document row was removed.
|
|
1014
|
+
- ``ghost_rows`` → manual ``brain rm <id>`` for each, or
|
|
1015
|
+
``brain vault export --force`` to re-create the missing files.
|
|
1016
|
+
"""
|
|
1017
|
+
|
|
1018
|
+
total_ingested_rows: int
|
|
1019
|
+
rows_with_null_vault_path: int
|
|
1020
|
+
ghost_rows: int # vault_path set, file missing on disk
|
|
1021
|
+
orphan_files: int # file on disk under _ingested/, no DB row matches its id
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def iter_orphan_mirror_files(
|
|
1025
|
+
conn: psycopg.Connection[Any], *, vault_path: Path
|
|
1026
|
+
) -> Iterator[Path]:
|
|
1027
|
+
"""Yield absolute paths under ``<vault>/_ingested/`` that have no DB row.
|
|
1028
|
+
|
|
1029
|
+
A file is "orphan" iff:
|
|
1030
|
+
|
|
1031
|
+
- It lives under ``<vault>/_ingested/`` (recursive walk).
|
|
1032
|
+
- It is a regular ``.md`` file.
|
|
1033
|
+
- Its YAML frontmatter parses cleanly and contains a string ``id`` key.
|
|
1034
|
+
- That ``id`` value matches no row in ``documents.id``.
|
|
1035
|
+
|
|
1036
|
+
Files without parseable frontmatter (e.g. ``_ingested/README.md`` written
|
|
1037
|
+
by ``brain vault init``, or any markdown file the user dropped in
|
|
1038
|
+
manually) are skipped — "no frontmatter" is intentional, not orphan.
|
|
1039
|
+
Files whose frontmatter has no ``id`` are likewise skipped: pruning would
|
|
1040
|
+
silently delete user-authored content.
|
|
1041
|
+
|
|
1042
|
+
Returns a generator so the caller (``brain doctor``) can show a count
|
|
1043
|
+
without holding the full path list. Iteration order follows
|
|
1044
|
+
:py:meth:`Path.rglob` sort for deterministic output. The corpus is
|
|
1045
|
+
bounded (≤10K rows in the personal-brain use case), so we materialize
|
|
1046
|
+
every ``documents.id`` into a single in-memory ``set`` to avoid issuing
|
|
1047
|
+
one SELECT per file.
|
|
1048
|
+
"""
|
|
1049
|
+
ingested_dir = vault_path / _INGESTED_DIR_NAME
|
|
1050
|
+
if not ingested_dir.is_dir():
|
|
1051
|
+
return
|
|
1052
|
+
rows = conn.execute("SELECT id::text FROM documents").fetchall()
|
|
1053
|
+
known_ids = {str(r[0]) for r in rows}
|
|
1054
|
+
for path, doc_id in _iter_ingested_mirror_ids(vault_path):
|
|
1055
|
+
if doc_id in known_ids:
|
|
1056
|
+
continue
|
|
1057
|
+
yield path
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def iter_stale_mirror_files(
|
|
1061
|
+
conn: psycopg.Connection[Any], *, vault_path: Path
|
|
1062
|
+
) -> Iterator[Path]:
|
|
1063
|
+
"""Yield mirror files whose id resolves but whose path is not the row's ``vault_path``.
|
|
1064
|
+
|
|
1065
|
+
A file is "stale" iff:
|
|
1066
|
+
|
|
1067
|
+
- It lives under ``<vault>/_ingested/`` (recursive walk).
|
|
1068
|
+
- It is a regular ``.md`` file.
|
|
1069
|
+
- Its YAML frontmatter parses and contains a string ``id``.
|
|
1070
|
+
- That ``id`` matches a row in ``documents``.
|
|
1071
|
+
- That row's ``documents.vault_path`` is set to a path **other** than this file.
|
|
1072
|
+
|
|
1073
|
+
These are leftovers from a slug-shape change (e.g. P1.5 reshaped Gmail
|
|
1074
|
+
mirrors from ``Mon,-27-Ap-19dd20fb-no-subject.md`` to
|
|
1075
|
+
``2026-04-28-03015b-no-subject.md``). The DB row's ``vault_path`` was
|
|
1076
|
+
updated but the old file was not unlinked, so it lingers on disk while
|
|
1077
|
+
the new file is the canonical mirror. Stale files don't get deleted by
|
|
1078
|
+
:func:`iter_orphan_mirror_files` because their id still resolves; this
|
|
1079
|
+
helper catches them.
|
|
1080
|
+
|
|
1081
|
+
Files whose frontmatter has no ``id``, or whose ``id`` doesn't resolve
|
|
1082
|
+
to any row, are NOT yielded — those are handled by
|
|
1083
|
+
:func:`iter_orphan_mirror_files`. Rows with ``vault_path IS NULL`` are
|
|
1084
|
+
treated as not-yet-mirrored and yield no stales (the file at this path
|
|
1085
|
+
might be the row's new canonical mirror once the next export runs).
|
|
1086
|
+
"""
|
|
1087
|
+
ingested_dir = vault_path / _INGESTED_DIR_NAME
|
|
1088
|
+
if not ingested_dir.is_dir():
|
|
1089
|
+
return
|
|
1090
|
+
rows = conn.execute(
|
|
1091
|
+
"SELECT id::text, vault_path FROM documents WHERE vault_path IS NOT NULL"
|
|
1092
|
+
).fetchall()
|
|
1093
|
+
canonical: dict[str, str] = {str(r[0]): str(r[1]) for r in rows}
|
|
1094
|
+
for path, doc_id in _iter_ingested_mirror_ids(vault_path):
|
|
1095
|
+
canonical_path = canonical.get(doc_id)
|
|
1096
|
+
if canonical_path is None:
|
|
1097
|
+
continue
|
|
1098
|
+
relative = path.relative_to(vault_path).as_posix()
|
|
1099
|
+
if relative == canonical_path:
|
|
1100
|
+
continue
|
|
1101
|
+
yield path
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def mirror_drift_summary(
|
|
1105
|
+
conn: psycopg.Connection[Any], *, vault_path: Path
|
|
1106
|
+
) -> MirrorDriftSummary:
|
|
1107
|
+
"""Compute the four-counter drift snapshot for ``<vault>/_ingested/``.
|
|
1108
|
+
|
|
1109
|
+
One SQL round-trip (``id``, ``kind``, ``vault_path`` for every row) feeds
|
|
1110
|
+
all three DB-derived signals — the ingested/NULL counts and the known-id
|
|
1111
|
+
set — plus one filesystem walk via :func:`_iter_ingested_mirror_ids`. Safe
|
|
1112
|
+
to call from ``brain doctor`` on every invocation: at personal-corpus scale
|
|
1113
|
+
the whole pass is dominated by walking the mirror tree, and that walk reads
|
|
1114
|
+
only each file's frontmatter block.
|
|
1115
|
+
|
|
1116
|
+
Ghost rows are counted purely against the filesystem: a row whose
|
|
1117
|
+
``vault_path`` points at a file that doesn't exist on disk under
|
|
1118
|
+
``vault_path`` contributes one ghost. We don't dedupe pairs (a single
|
|
1119
|
+
DB row with a missing file is one ghost regardless of whether an
|
|
1120
|
+
orphan file with the same id happens to also exist — these are
|
|
1121
|
+
independent symptoms with different fixes).
|
|
1122
|
+
"""
|
|
1123
|
+
rows = conn.execute("SELECT id::text, kind, vault_path FROM documents").fetchall()
|
|
1124
|
+
known_ids: set[str] = set()
|
|
1125
|
+
total_ingested = 0
|
|
1126
|
+
null_vault_path = 0
|
|
1127
|
+
ingested_vault_paths: list[str] = []
|
|
1128
|
+
for doc_id, kind, vault_path_value in rows:
|
|
1129
|
+
known_ids.add(str(doc_id))
|
|
1130
|
+
if kind != "ingested":
|
|
1131
|
+
continue
|
|
1132
|
+
total_ingested += 1
|
|
1133
|
+
if vault_path_value is None:
|
|
1134
|
+
null_vault_path += 1
|
|
1135
|
+
else:
|
|
1136
|
+
ingested_vault_paths.append(str(vault_path_value))
|
|
1137
|
+
|
|
1138
|
+
ghost_count = 0
|
|
1139
|
+
for relative in ingested_vault_paths:
|
|
1140
|
+
if not (vault_path / relative).is_file():
|
|
1141
|
+
ghost_count += 1
|
|
1142
|
+
|
|
1143
|
+
orphan_count = 0
|
|
1144
|
+
for _path, doc_id in _iter_ingested_mirror_ids(vault_path):
|
|
1145
|
+
if doc_id not in known_ids:
|
|
1146
|
+
orphan_count += 1
|
|
1147
|
+
|
|
1148
|
+
return MirrorDriftSummary(
|
|
1149
|
+
total_ingested_rows=total_ingested,
|
|
1150
|
+
rows_with_null_vault_path=null_vault_path,
|
|
1151
|
+
ghost_rows=ghost_count,
|
|
1152
|
+
orphan_files=orphan_count,
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def list_public_tables(conn: psycopg.Connection[Any]) -> list[str]:
|
|
1157
|
+
"""Return the sorted names of base tables in the ``public`` schema.
|
|
1158
|
+
|
|
1159
|
+
Used by ``brain analyze`` to validate a user-supplied table name before it
|
|
1160
|
+
is interpolated into an ``ANALYZE`` statement, giving a friendly error on a
|
|
1161
|
+
typo and a defense-in-depth check on top of identifier quoting.
|
|
1162
|
+
"""
|
|
1163
|
+
rows = conn.execute(
|
|
1164
|
+
"""
|
|
1165
|
+
SELECT table_name
|
|
1166
|
+
FROM information_schema.tables
|
|
1167
|
+
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
|
1168
|
+
ORDER BY table_name
|
|
1169
|
+
"""
|
|
1170
|
+
).fetchall()
|
|
1171
|
+
return [str(name) for (name,) in rows]
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def analyze_tables(conn: psycopg.Connection[Any], tables: list[str]) -> None:
|
|
1175
|
+
"""Run ``ANALYZE`` on each given table to refresh planner statistics.
|
|
1176
|
+
|
|
1177
|
+
Each identifier is quoted via :class:`psycopg.sql.Identifier`; callers
|
|
1178
|
+
validate ``tables`` against :func:`list_public_tables` before reaching here
|
|
1179
|
+
(same contract as :func:`_count_null_embedding`). ``ANALYZE`` is run one
|
|
1180
|
+
table at a time so a bad name fails loudly on that name rather than
|
|
1181
|
+
silently analyzing a prefix of the list.
|
|
1182
|
+
|
|
1183
|
+
The connection should be in ``autocommit`` mode (or committed by the
|
|
1184
|
+
caller): ``ANALYZE`` writes to ``pg_statistic``/``pg_class``, which is
|
|
1185
|
+
transactional and would otherwise be rolled back on connection close.
|
|
1186
|
+
"""
|
|
1187
|
+
for table in tables:
|
|
1188
|
+
conn.execute(sql.SQL("ANALYZE {table}").format(table=sql.Identifier(table)))
|