brainiac-cli 0.16.0__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 +39 -0
- brain/__main__.py +14 -0
- brain/_assets/AGENTS.md +564 -0
- brain/_assets/overlay/template/brand/brand-guide.md +24 -0
- brain/_assets/overlay/template/keywords/glossary.md +15 -0
- brain/_assets/overlay/template/people/roster.md +14 -0
- brain/_assets/overlay/template/voice/voice-profile.md +34 -0
- brain/_assets/routines/manifest.json +257 -0
- brain/_assets/scripts/brain-brief-mac.plist +59 -0
- brain/_assets/scripts/brain-brief.sh +74 -0
- brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
- brain/_assets/scripts/brain-synthesis.sh +214 -0
- brain/_assets/scripts/install-brief-mac.sh +152 -0
- brain/_assets/scripts/install-brief-windows.ps1 +97 -0
- brain/_assets/scripts/register_tasks.py +386 -0
- brain/_assets/templates/company.md +21 -0
- brain/_assets/templates/concept.md +27 -0
- brain/_assets/templates/daily.md +20 -0
- brain/_assets/templates/decision.md +42 -0
- brain/_assets/templates/meeting.md +33 -0
- brain/_assets/templates/person.md +20 -0
- brain/_assets/templates/project.md +23 -0
- brain/_assets/templates/state-moc.md +40 -0
- brain/_version.py +12 -0
- brain/anchor.py +121 -0
- brain/audit.py +422 -0
- brain/backup.py +210 -0
- brain/brief.py +417 -0
- brain/capture.py +117 -0
- brain/chunk.py +249 -0
- brain/classification.py +134 -0
- brain/cli.py +1906 -0
- brain/config.py +368 -0
- brain/connect.py +362 -0
- brain/context.py +108 -0
- brain/core.py +3018 -0
- brain/doctor.py +1161 -0
- brain/egress.py +148 -0
- brain/embed.py +857 -0
- brain/encryption.py +217 -0
- brain/frontmatter.py +102 -0
- brain/golden_probe.py +678 -0
- brain/graph.py +369 -0
- brain/graphify.py +352 -0
- brain/index.py +1576 -0
- brain/ingest/__init__.py +19 -0
- brain/ingest/handlers/__init__.py +43 -0
- brain/ingest/handlers/base.py +95 -0
- brain/ingest/handlers/docx.py +78 -0
- brain/ingest/handlers/email.py +228 -0
- brain/ingest/handlers/html.py +142 -0
- brain/ingest/handlers/image.py +91 -0
- brain/ingest/handlers/pdf.py +99 -0
- brain/ingest/handlers/pptx.py +69 -0
- brain/ingest/handlers/tables.py +41 -0
- brain/ingest/handlers/text.py +43 -0
- brain/ingest/handlers/xlsx.py +100 -0
- brain/ingest/handlers/zip.py +163 -0
- brain/ingest/pipeline.py +839 -0
- brain/ingest/transcript.py +158 -0
- brain/init.py +870 -0
- brain/maintenance.py +2266 -0
- brain/mcp_adapter.py +217 -0
- brain/multihop.py +232 -0
- brain/notes.py +195 -0
- brain/overlay.py +183 -0
- brain/projection.py +79 -0
- brain/rerank.py +425 -0
- brain/snapshot.py +231 -0
- brain/update.py +743 -0
- brain/vectors.py +225 -0
- brainiac_cli-0.16.0.dist-info/METADATA +306 -0
- brainiac_cli-0.16.0.dist-info/RECORD +77 -0
- brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
- brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
- brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
- brainiac_cli-0.16.0.dist-info/top_level.txt +1 -0
brain/index.py
ADDED
|
@@ -0,0 +1,1576 @@
|
|
|
1
|
+
"""The derived SQLite index: FTS5 (lexical) + a vector backend (semantic).
|
|
2
|
+
|
|
3
|
+
Single file (default under app-data, see config.py). Derived and DISPOSABLE —
|
|
4
|
+
``rebuild`` drops and recreates everything from the Markdown in ``vault/``, so
|
|
5
|
+
deleting the file is always safe. Retrieval depends on the vector ADAPTER
|
|
6
|
+
(brain.vectors.VectorBackend), never on sqlite-vec directly.
|
|
7
|
+
|
|
8
|
+
S03 added:
|
|
9
|
+
* **Chunk-level vectors (IDX-02).** Notes are split into section/block chunks
|
|
10
|
+
(``brain.chunk``); each chunk is embedded with an in-language contextual
|
|
11
|
+
prefix. The vector backend is keyed by *chunk* rowid; a semantic hit maps
|
|
12
|
+
back to its note and we keep the best chunk per note.
|
|
13
|
+
* **Incremental sync (IDX-03).** ``sync`` re-indexes only notes whose
|
|
14
|
+
path+content-hash changed and propagates deletes — no full rebuild.
|
|
15
|
+
* **Model-change guard (IDX-01).** ``embed_model`` + ``embed_dim`` are stored
|
|
16
|
+
in ``meta``; a mismatch on ``sync`` forces a clean rebuild (Arctic vectors
|
|
17
|
+
must never be mixed with HashEmbedder vectors).
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import datetime as _dt
|
|
22
|
+
import hashlib
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import sqlite3
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from . import config, frontmatter
|
|
31
|
+
from . import classification as cls_mod
|
|
32
|
+
from .chunk import chunk_text
|
|
33
|
+
from .embed import Embedder, get_embedder
|
|
34
|
+
from .notes import Note, scan_vault
|
|
35
|
+
from .vectors import SqliteVecBackend, VectorBackend, get_backend
|
|
36
|
+
|
|
37
|
+
# Optional 3rd-party regex engine (mrab-regex) with a REAL per-call match
|
|
38
|
+
# timeout -- unlike stdlib `re`, `regex.search(text, timeout=...)` genuinely
|
|
39
|
+
# bounds catastrophic backtracking (confirmed: stdlib re on r'(a+)+$' vs 30
|
|
40
|
+
# 'a's takes ~47s; the `regex` module's timeout on the identical input returns
|
|
41
|
+
# in well under a millisecond). Declared as the optional `index` extra in
|
|
42
|
+
# pyproject.toml; falls back to stdlib `re` when not installed, in which case
|
|
43
|
+
# the pattern-length cap below is the sole ReDoS mitigation (documented
|
|
44
|
+
# residual risk: docs/SECURITY_NOTES.md).
|
|
45
|
+
try:
|
|
46
|
+
import regex as _grep_engine
|
|
47
|
+
_GREP_HAS_TIMEOUT = True
|
|
48
|
+
except ImportError: # pragma: no cover - exercised only when `regex` is absent
|
|
49
|
+
_grep_engine = re
|
|
50
|
+
_GREP_HAS_TIMEOUT = False
|
|
51
|
+
|
|
52
|
+
# ReDoS / resource-exhaustion guard for BrainIndex.grep (RET-04 hardening).
|
|
53
|
+
MAX_GREP_PATTERN_LEN = 200 # absurdly long patterns are the abuse surface, not legitimate use
|
|
54
|
+
GREP_REGEX_TIMEOUT_S = 2.0 # per-match wall-clock budget (only enforced with the `regex` engine)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _today() -> _dt.date:
|
|
58
|
+
"""Today, overridable via ``BRAIN_NOW=YYYY-MM-DD`` so recency ranking is
|
|
59
|
+
deterministic in tests (mirrors the injectable-clock pattern used by
|
|
60
|
+
maintenance staleness)."""
|
|
61
|
+
v = os.environ.get("BRAIN_NOW", "").strip()
|
|
62
|
+
if v:
|
|
63
|
+
try:
|
|
64
|
+
return _dt.date.fromisoformat(v[:10])
|
|
65
|
+
except ValueError:
|
|
66
|
+
pass
|
|
67
|
+
return _dt.date.today()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _env_float(name: str, default: float) -> float:
|
|
71
|
+
try:
|
|
72
|
+
return float(os.environ[name])
|
|
73
|
+
except (KeyError, ValueError):
|
|
74
|
+
return default
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Temporal-intent detector for query-aware recency weighting (EN + PT — the
|
|
78
|
+
# reference corpus is bilingual). Deliberately coarse: false positives only
|
|
79
|
+
# strengthen a gentle, bounded prior; false negatives fall back to the default.
|
|
80
|
+
_TEMPORAL_INTENT_RE = re.compile(
|
|
81
|
+
r"\b(latest|newest|current(?:ly)?|recent(?:ly)?|as of|today|now|up[- ]to[- ]date|"
|
|
82
|
+
r"this (?:week|month|quarter|year)|"
|
|
83
|
+
r"atual(?:mente)?|mais recentes?|últim[oa]s?|recentes?|hoje|"
|
|
84
|
+
r"est[ae] (?:semana|mês|trimestre|ano))\b", re.IGNORECASE)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _recency_factor(date_str: str, today: _dt.date, weight: float,
|
|
88
|
+
half_life: float) -> float:
|
|
89
|
+
"""Gentle multiplicative STALENESS PENALTY for the RRF fusion, bounded to
|
|
90
|
+
``(1 - weight, 1.0]``. A note dated today (or in the future) is neutral at
|
|
91
|
+
``1.0``; the penalty deepens as the note ages, halving its distance-from-full
|
|
92
|
+
every ``half_life`` days, asymptoting at ``1 - weight`` for very old notes.
|
|
93
|
+
An undated note (or ``weight<=0``) is neutral at ``1.0`` — undated notes are
|
|
94
|
+
never penalised.
|
|
95
|
+
|
|
96
|
+
A *penalty* (≤1), not a boost (>1), so the fused score never exceeds the RRF
|
|
97
|
+
ceiling ``2/(rrf_k+1)`` — the fusion-scale invariant the zone-authority prior
|
|
98
|
+
also respects. Relative order between any two DATED notes is identical to a
|
|
99
|
+
symmetric boost, so the newer of two topically-similar hits still wins."""
|
|
100
|
+
if weight <= 0 or not date_str:
|
|
101
|
+
return 1.0
|
|
102
|
+
try:
|
|
103
|
+
d = _dt.date.fromisoformat(date_str[:10])
|
|
104
|
+
except ValueError:
|
|
105
|
+
return 1.0
|
|
106
|
+
age = (today - d).days
|
|
107
|
+
if age <= 0:
|
|
108
|
+
return 1.0
|
|
109
|
+
return 1.0 - weight * (1.0 - 0.5 ** (age / half_life))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class GrepPatternError(ValueError):
|
|
113
|
+
"""A user-supplied grep pattern was rejected before compilation."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _grep_bounded_search(compiled, text: str):
|
|
117
|
+
"""``compiled.search(text)`` with a wall-clock budget when the `regex`
|
|
118
|
+
engine is available. A pathological pattern degrades to "no match on this
|
|
119
|
+
line" (never raises out of `grep`) rather than hanging the whole call."""
|
|
120
|
+
if _GREP_HAS_TIMEOUT:
|
|
121
|
+
try:
|
|
122
|
+
return compiled.search(text, timeout=GREP_REGEX_TIMEOUT_S)
|
|
123
|
+
except TimeoutError:
|
|
124
|
+
return None
|
|
125
|
+
return compiled.search(text)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
SCHEMA_VERSION = 3 # TMP-02: bitemporal columns added (notes gain 6 new cols).
|
|
129
|
+
# Migration-safe by construction: sync()'s _schema_ready()
|
|
130
|
+
# check already forces a rebuild() on any version mismatch —
|
|
131
|
+
# no separate ALTER-TABLE migration path is needed.
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class Hit:
|
|
136
|
+
id: str
|
|
137
|
+
title: str
|
|
138
|
+
classification: str
|
|
139
|
+
zone: str
|
|
140
|
+
path: str
|
|
141
|
+
score: float
|
|
142
|
+
source: str # "lexical" | "semantic" | "both"
|
|
143
|
+
snippet: str = ""
|
|
144
|
+
is_latest_version: str = "" # TMP-02: "true"|"false"|"" — post-egress field,
|
|
145
|
+
# never consulted by the classification gate.
|
|
146
|
+
date: str = "" # valid-time date (effective_date → document_date → created)
|
|
147
|
+
# — lets an agent see at a glance HOW CURRENT each hit is.
|
|
148
|
+
type: str = "" # note type (decision|source|note|…) — authority signal:
|
|
149
|
+
# a `decision` hit IS the decision layer; a `source` hit is
|
|
150
|
+
# material under consideration (2026-07-11: an agent
|
|
151
|
+
# promoted a draft memo's scenario into a "decision"
|
|
152
|
+
# because the ranked list didn't show which was which).
|
|
153
|
+
|
|
154
|
+
def to_dict(self) -> dict[str, Any]:
|
|
155
|
+
return {
|
|
156
|
+
"id": self.id,
|
|
157
|
+
"title": self.title,
|
|
158
|
+
"classification": self.classification,
|
|
159
|
+
"zone": self.zone,
|
|
160
|
+
"path": self.path,
|
|
161
|
+
"score": round(self.score, 6),
|
|
162
|
+
"source": self.source,
|
|
163
|
+
"snippet": self.snippet,
|
|
164
|
+
"is_latest_version": self.is_latest_version,
|
|
165
|
+
"date": self.date,
|
|
166
|
+
"type": self.type,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass
|
|
171
|
+
class _NotePlan:
|
|
172
|
+
"""A note's planned index rows (chunking/prefix/dedup done) BEFORE embedding.
|
|
173
|
+
|
|
174
|
+
Decouples planning from writing so ``rebuild`` can bulk-embed every note's
|
|
175
|
+
chunk inputs in one batched call (the S11 indexing speed fix) instead of one
|
|
176
|
+
tiny embed per note."""
|
|
177
|
+
|
|
178
|
+
note_rowid: int
|
|
179
|
+
row: dict[str, Any]
|
|
180
|
+
chunks: list[Any]
|
|
181
|
+
inputs: list[str]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class BrainIndex:
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
db_path: Path | None = None,
|
|
188
|
+
backend: VectorBackend | None = None,
|
|
189
|
+
embedder: Embedder | None = None,
|
|
190
|
+
*,
|
|
191
|
+
read_only: bool = False,
|
|
192
|
+
) -> None:
|
|
193
|
+
self.db_path = Path(db_path) if db_path else config.index_path()
|
|
194
|
+
self.backend: VectorBackend = backend or get_backend("auto")
|
|
195
|
+
# $BRAIN_EMBEDDER overrides embedder selection (auto|hash|arctic|catalog);
|
|
196
|
+
# default auto. CI + air-gapped validation force "hash" (offline, no model
|
|
197
|
+
# download), per get_embedder's contract — same one-line swap tests use.
|
|
198
|
+
# None ⇒ resolved LAZILY on first use (the `embedder` property below):
|
|
199
|
+
# constructing the index must never die on a missing embedder, or
|
|
200
|
+
# DV-03's fail-closed ($BRAIN_REQUIRE_REAL_EMBEDDER, defaulted on the
|
|
201
|
+
# VM leg) bites verbs that never embed — capture/draft-capture, grep,
|
|
202
|
+
# bases-query — instead of only the semantic path as documented.
|
|
203
|
+
self._embedder: Embedder | None = embedder
|
|
204
|
+
# Cache the reranker on the index instance so the ONNX session is loaded
|
|
205
|
+
# ONCE, not on every _apply_rerank call. Without this, qwen3-embed's
|
|
206
|
+
# TextCrossEncoder reloads the 573MB ONNX model per query (S11 finding),
|
|
207
|
+
# making rerank-bound eval pathologically slow. The cache is keyed on the
|
|
208
|
+
# resolved model id so a mid-session BRAIN_RERANKER_MODEL change is honoured.
|
|
209
|
+
self._reranker_cache: tuple[str, Any] | None = None
|
|
210
|
+
# Multi-hop retrieval (RET-06) caches: the wikilink graph and the entity
|
|
211
|
+
# lexicon are both derived from the immutable ``notes`` table, so build
|
|
212
|
+
# them once per index lifetime (not per query). None until first use.
|
|
213
|
+
self._link_graph: Any | None = None
|
|
214
|
+
self._entity_lex: Any | None = None
|
|
215
|
+
# read_only is the VM-leg posture (S06): the connection is opened
|
|
216
|
+
# ``mode=ro`` so the engine CANNOT open WAL or mutate the index. Any
|
|
217
|
+
# write raises ``sqlite3.OperationalError`` (attempt to write a readonly
|
|
218
|
+
# database) and no ``-wal``/``-shm`` sidecar is ever created.
|
|
219
|
+
self.read_only = read_only
|
|
220
|
+
self._conn: sqlite3.Connection | None = None
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def embedder(self) -> Embedder:
|
|
224
|
+
"""The query/index embedder, resolved on FIRST USE, not construction.
|
|
225
|
+
|
|
226
|
+
Only the paths that actually embed (search/hybrid-search, rebuild/sync,
|
|
227
|
+
near-dup scoring) ever touch this — so under DV-03's fail-closed policy
|
|
228
|
+
an EmbedderUnavailable raises exactly where the semantic contract is
|
|
229
|
+
exercised, and lexical/draft verbs keep working on a machine with no
|
|
230
|
+
real embedder (the documented DV-03 scope)."""
|
|
231
|
+
if self._embedder is None:
|
|
232
|
+
self._embedder = get_embedder(os.environ.get("BRAIN_EMBEDDER", "auto"))
|
|
233
|
+
return self._embedder
|
|
234
|
+
|
|
235
|
+
# -- connection -------------------------------------------------------
|
|
236
|
+
@property
|
|
237
|
+
def conn(self) -> sqlite3.Connection:
|
|
238
|
+
if self._conn is None:
|
|
239
|
+
if self.read_only:
|
|
240
|
+
# Open the (snapshot) DB strictly read-only. mode=ro means SQLite
|
|
241
|
+
# will not create the file, will not open a write journal/WAL, and
|
|
242
|
+
# fails any write — the VM-leg guarantee enforced at the engine.
|
|
243
|
+
uri = f"file:{self.db_path}?mode=ro"
|
|
244
|
+
self._conn = sqlite3.connect(uri, uri=True)
|
|
245
|
+
if isinstance(self.backend, SqliteVecBackend):
|
|
246
|
+
try:
|
|
247
|
+
self.backend.load_into(self._conn)
|
|
248
|
+
except Exception:
|
|
249
|
+
pass
|
|
250
|
+
# Belt-and-suspenders: forbid writes at the connection level too.
|
|
251
|
+
try:
|
|
252
|
+
self._conn.execute("PRAGMA query_only=ON")
|
|
253
|
+
except sqlite3.OperationalError:
|
|
254
|
+
pass
|
|
255
|
+
return self._conn
|
|
256
|
+
is_file_backed = self.db_path != Path(":memory:")
|
|
257
|
+
if is_file_backed:
|
|
258
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
self._conn = sqlite3.connect(str(self.db_path))
|
|
260
|
+
if is_file_backed and self.db_path.exists():
|
|
261
|
+
# sqlite3.connect() creates the file (when absent) with the
|
|
262
|
+
# process umask -- often 0o644 / world-readable on a typical
|
|
263
|
+
# single-user default. The index can hold note bodies up to and
|
|
264
|
+
# including MNPI-tier content (the classification gate is an
|
|
265
|
+
# egress *decision*, not containment), so tighten to owner-only
|
|
266
|
+
# immediately, regardless of umask.
|
|
267
|
+
config.secure_file_permissions(self.db_path)
|
|
268
|
+
# sqlite-vec needs its extension loaded on EVERY connection.
|
|
269
|
+
if isinstance(self.backend, SqliteVecBackend):
|
|
270
|
+
try:
|
|
271
|
+
self.backend.load_into(self._conn)
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
275
|
+
# M-6: without a busy_timeout, a concurrent writer's first INSERT
|
|
276
|
+
# raises "database is locked" immediately instead of waiting for
|
|
277
|
+
# the other writer's transaction to finish. 5s covers a normal
|
|
278
|
+
# sync; a still-locked DB past that surfaces as a real error.
|
|
279
|
+
self._conn.execute("PRAGMA busy_timeout=5000")
|
|
280
|
+
if is_file_backed:
|
|
281
|
+
# WAL mode creates -wal/-shm sidecars on first write; make sure
|
|
282
|
+
# those inherit the same owner-only posture too (they can carry
|
|
283
|
+
# the same sensitive content as the main DB file).
|
|
284
|
+
for suffix in ("-wal", "-shm"):
|
|
285
|
+
side = Path(str(self.db_path) + suffix)
|
|
286
|
+
if side.exists():
|
|
287
|
+
config.secure_file_permissions(side)
|
|
288
|
+
return self._conn
|
|
289
|
+
|
|
290
|
+
def close(self) -> None:
|
|
291
|
+
if self._conn is not None:
|
|
292
|
+
self._conn.close()
|
|
293
|
+
self._conn = None
|
|
294
|
+
|
|
295
|
+
# -- schema -----------------------------------------------------------
|
|
296
|
+
def _create_schema(self) -> None:
|
|
297
|
+
c = self.conn
|
|
298
|
+
c.execute("DROP TABLE IF EXISTS notes")
|
|
299
|
+
c.execute("DROP TABLE IF EXISTS notes_fts")
|
|
300
|
+
c.execute("DROP TABLE IF EXISTS chunks")
|
|
301
|
+
c.execute("DROP TABLE IF EXISTS meta")
|
|
302
|
+
c.execute(
|
|
303
|
+
"""CREATE TABLE notes (
|
|
304
|
+
rowid INTEGER PRIMARY KEY,
|
|
305
|
+
id TEXT UNIQUE, title TEXT, type TEXT,
|
|
306
|
+
classification TEXT, zone TEXT, path TEXT UNIQUE,
|
|
307
|
+
created TEXT, updated TEXT, sha256 TEXT, content_hash TEXT, body TEXT,
|
|
308
|
+
document_date TEXT, effective_date TEXT, superseded_date TEXT,
|
|
309
|
+
is_latest_version TEXT, superseded_by TEXT, previous_version TEXT
|
|
310
|
+
)"""
|
|
311
|
+
)
|
|
312
|
+
# Plain (non-contentless) fts5 so incremental DELETE WHERE rowid works.
|
|
313
|
+
c.execute("CREATE VIRTUAL TABLE notes_fts USING fts5(id, title, body)")
|
|
314
|
+
c.execute(
|
|
315
|
+
"""CREATE TABLE chunks (
|
|
316
|
+
rowid INTEGER PRIMARY KEY,
|
|
317
|
+
note_rowid INTEGER NOT NULL,
|
|
318
|
+
ordinal INTEGER, heading TEXT, lang TEXT, text TEXT
|
|
319
|
+
)"""
|
|
320
|
+
)
|
|
321
|
+
c.execute("CREATE INDEX idx_chunks_note ON chunks(note_rowid)")
|
|
322
|
+
c.execute("CREATE TABLE meta (k TEXT PRIMARY KEY, v TEXT)")
|
|
323
|
+
self._set_meta("schema_version", str(SCHEMA_VERSION))
|
|
324
|
+
self._set_meta("vector_backend", self.backend.name)
|
|
325
|
+
self._set_meta("embed_model", self.embedder.model_id)
|
|
326
|
+
self._set_meta("embed_dim", str(self.embedder.dim))
|
|
327
|
+
self.backend.setup(c, self.embedder.dim)
|
|
328
|
+
|
|
329
|
+
def _set_meta(self, k: str, v: str) -> None:
|
|
330
|
+
self.conn.execute("INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)", (k, v))
|
|
331
|
+
|
|
332
|
+
def get_meta(self, k: str) -> str | None:
|
|
333
|
+
try:
|
|
334
|
+
r = self.conn.execute("SELECT v FROM meta WHERE k=?", (k,)).fetchone()
|
|
335
|
+
except sqlite3.OperationalError:
|
|
336
|
+
return None
|
|
337
|
+
return r[0] if r else None
|
|
338
|
+
|
|
339
|
+
def _schema_ready(self) -> bool:
|
|
340
|
+
return self.get_meta("schema_version") == str(SCHEMA_VERSION)
|
|
341
|
+
|
|
342
|
+
def model_matches(self) -> bool:
|
|
343
|
+
"""True iff the stored embed_model/dim match the current embedder."""
|
|
344
|
+
return (
|
|
345
|
+
self.get_meta("embed_model") == self.embedder.model_id
|
|
346
|
+
and self.get_meta("embed_dim") == str(self.embedder.dim)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# -- insertion --------------------------------------------------------
|
|
350
|
+
def _next_rowid(self, table: str) -> int:
|
|
351
|
+
# FALSE POSITIVE (scanner: string-built SQL / hardcoded_sql_expressions):
|
|
352
|
+
# `table` is never user input -- it is a hardcoded literal ("chunks" /
|
|
353
|
+
# "notes") at both call sites below, never derived from a request
|
|
354
|
+
# argument. See docs/SECURITY_NOTES.md.
|
|
355
|
+
r = self.conn.execute(f"SELECT COALESCE(MAX(rowid), 0) FROM {table}").fetchone() # nosec B608
|
|
356
|
+
return int(r[0]) + 1
|
|
357
|
+
|
|
358
|
+
def _plan_note(self, note: Note, note_rowid: int) -> _NotePlan:
|
|
359
|
+
"""Plan one note's index rows WITHOUT embedding or DB writes.
|
|
360
|
+
|
|
361
|
+
Splits the chunking/context-prefix/dedup work out of the write path so
|
|
362
|
+
``rebuild`` can collect EVERY note's embed inputs first and embed them
|
|
363
|
+
in ONE bulk batched call (the S11 indexing speed fix — see ``rebuild``)
|
|
364
|
+
instead of ~one tiny embed per note. ``sync`` keeps the per-note path
|
|
365
|
+
(``_insert_note``), where only a handful of notes re-embed."""
|
|
366
|
+
row = note.to_row()
|
|
367
|
+
# Real-corpus robustness: a foreign vault has many frontmatter-bearing
|
|
368
|
+
# notes whose id falls back to a non-unique stem (e.g. dozens of
|
|
369
|
+
# SKILL.md / _index.md). notes.id is UNIQUE, so disambiguate a colliding
|
|
370
|
+
# id with a short path hash. Brain-native notes carry unique explicit
|
|
371
|
+
# ids, so this never fires for them (in-process tests are unaffected).
|
|
372
|
+
# Retrieval keys on path (Hit.path), so the synthetic id is internal only.
|
|
373
|
+
seen = getattr(self, "_seen_ids", None)
|
|
374
|
+
if seen is not None:
|
|
375
|
+
if row["id"] in seen:
|
|
376
|
+
# FALSE POSITIVE (scanner: weak-hash / hashlib-insecure-functions):
|
|
377
|
+
# SHA1 here is a non-security content-addressed de-dup suffix (a
|
|
378
|
+
# short, stable disambiguator for a colliding synthetic id), not a
|
|
379
|
+
# security boundary -- collision resistance / preimage resistance
|
|
380
|
+
# don't matter for this use. See docs/SECURITY_NOTES.md.
|
|
381
|
+
# nosemgrep: python.lang.security.insecure-hash-algorithms.insecure-hash-algorithm-sha1
|
|
382
|
+
row["id"] = f"{row['id']}__{hashlib.sha1(row['path'].encode()).hexdigest()[:8]}" # nosec B303 B324
|
|
383
|
+
seen.add(row["id"])
|
|
384
|
+
chunks = chunk_text(note.body)
|
|
385
|
+
if not chunks:
|
|
386
|
+
# A note with an empty body still gets one chunk (the title) so it is
|
|
387
|
+
# retrievable semantically.
|
|
388
|
+
from .chunk import Chunk, detect_language
|
|
389
|
+
|
|
390
|
+
chunks = [Chunk(0, "", note.title or note.id, detect_language(note.title))]
|
|
391
|
+
# UPG-04 Contextual Retrieval: generate a per-note doc-context once and
|
|
392
|
+
# prepend it to every chunk. Inert (returns "") when no LLM is configured
|
|
393
|
+
# ($BRAIN_CONTEXTUAL_LLM unset) — degrades cleanly to the S10 path.
|
|
394
|
+
from .context import doc_context as _doc_context
|
|
395
|
+
|
|
396
|
+
dctx = _doc_context(note.title or note.id, note.zone, note.body)
|
|
397
|
+
inputs = [ch.embed_input(note.title, note.zone, dctx) for ch in chunks]
|
|
398
|
+
return _NotePlan(note_rowid=note_rowid, row=row, chunks=chunks, inputs=inputs)
|
|
399
|
+
|
|
400
|
+
def _write_planned(
|
|
401
|
+
self, plan: "_NotePlan", vecs: list[list[float]], chunk_rowid: int
|
|
402
|
+
) -> int:
|
|
403
|
+
"""Write a planned note + its FTS row + its chunks (with vectors).
|
|
404
|
+
|
|
405
|
+
``vecs`` must be aligned 1:1 with ``plan.inputs``/``plan.chunks``. Pure
|
|
406
|
+
DB writes — no embedding (the bulk ``rebuild`` path embeds everything up
|
|
407
|
+
front). Returns the next free chunk rowid."""
|
|
408
|
+
c = self.conn
|
|
409
|
+
row = plan.row
|
|
410
|
+
c.execute(
|
|
411
|
+
"INSERT INTO notes(rowid, id, title, type, classification, zone, path,"
|
|
412
|
+
" created, updated, sha256, content_hash, body, document_date,"
|
|
413
|
+
" effective_date, superseded_date, is_latest_version, superseded_by,"
|
|
414
|
+
" previous_version) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
415
|
+
(
|
|
416
|
+
plan.note_rowid, row["id"], row["title"], row["type"], row["classification"],
|
|
417
|
+
row["zone"], row["path"], row["created"], row["updated"],
|
|
418
|
+
row["sha256"], row["content_hash"], row["body"],
|
|
419
|
+
row.get("document_date", ""), row.get("effective_date", ""),
|
|
420
|
+
row.get("superseded_date", ""), row.get("is_latest_version", ""),
|
|
421
|
+
row.get("superseded_by", ""), row.get("previous_version", ""),
|
|
422
|
+
),
|
|
423
|
+
)
|
|
424
|
+
c.execute(
|
|
425
|
+
"INSERT INTO notes_fts(rowid, id, title, body) VALUES (?,?,?,?)",
|
|
426
|
+
(plan.note_rowid, row["id"], row["title"], row["body"]),
|
|
427
|
+
)
|
|
428
|
+
for ch, vec in zip(plan.chunks, vecs):
|
|
429
|
+
c.execute(
|
|
430
|
+
"INSERT INTO chunks(rowid, note_rowid, ordinal, heading, lang, text)"
|
|
431
|
+
" VALUES (?,?,?,?,?,?)",
|
|
432
|
+
(chunk_rowid, plan.note_rowid, ch.ordinal, ch.heading, ch.lang, ch.text),
|
|
433
|
+
)
|
|
434
|
+
self.backend.upsert(c, chunk_rowid, vec)
|
|
435
|
+
chunk_rowid += 1
|
|
436
|
+
return chunk_rowid
|
|
437
|
+
|
|
438
|
+
def _insert_note(self, note: Note, note_rowid: int, chunk_rowid: int) -> int:
|
|
439
|
+
"""Plan + embed + write ONE note (the incremental ``sync`` path).
|
|
440
|
+
|
|
441
|
+
``rebuild`` does NOT use this — it plans every note first, then embeds
|
|
442
|
+
all inputs in one bulk batched call (see ``rebuild``), which is the S11
|
|
443
|
+
indexing speed fix. Returns the next free chunk rowid."""
|
|
444
|
+
plan = self._plan_note(note, note_rowid)
|
|
445
|
+
vecs = self.embedder.embed_batch(plan.inputs, is_query=False)
|
|
446
|
+
return self._write_planned(plan, vecs, chunk_rowid)
|
|
447
|
+
|
|
448
|
+
def _delete_note(self, note_rowid: int) -> None:
|
|
449
|
+
c = self.conn
|
|
450
|
+
for (crid,) in c.execute(
|
|
451
|
+
"SELECT rowid FROM chunks WHERE note_rowid=?", (note_rowid,)
|
|
452
|
+
).fetchall():
|
|
453
|
+
self.backend.delete(c, int(crid))
|
|
454
|
+
c.execute("DELETE FROM chunks WHERE note_rowid=?", (note_rowid,))
|
|
455
|
+
c.execute("DELETE FROM notes_fts WHERE rowid=?", (note_rowid,))
|
|
456
|
+
c.execute("DELETE FROM notes WHERE rowid=?", (note_rowid,))
|
|
457
|
+
|
|
458
|
+
# -- build (full) -----------------------------------------------------
|
|
459
|
+
def rebuild(self, vault: Path) -> dict[str, Any]:
|
|
460
|
+
"""Drop and rebuild the entire index from vault/. Always safe.
|
|
461
|
+
|
|
462
|
+
S11 indexing speed fix: chunking / embedding / writing are now THREE
|
|
463
|
+
separate passes. Previously ``_insert_note`` was called once per note
|
|
464
|
+
and embedded only that note's ~2-10 chunks, so the ONNX session ran one
|
|
465
|
+
tiny forward pass per note (~2254 for the real vault) that badly
|
|
466
|
+
under-used the batch dimension and the intra-op threads. Now every note
|
|
467
|
+
is planned (chunked + prefixed) first, ALL chunk inputs are embedded in
|
|
468
|
+
ONE bulk batched call (fastembed batches internally — default 256 — and
|
|
469
|
+
saturates the cores), and only then are the rows + vectors written.
|
|
470
|
+
Same vectors, same retrieval — just far fewer, much larger forward
|
|
471
|
+
passes."""
|
|
472
|
+
self._create_schema()
|
|
473
|
+
self._seen_ids: set[str] = set() # collision-only id dedup (real corpora)
|
|
474
|
+
# Pass 1: plan every note (chunk + contextual prefix + id dedup) — no embedding.
|
|
475
|
+
plans: list[_NotePlan] = [
|
|
476
|
+
self._plan_note(note, i)
|
|
477
|
+
for i, note in enumerate(scan_vault(vault), start=1)
|
|
478
|
+
]
|
|
479
|
+
# Pass 2: embed ALL chunk inputs in ONE bulk batched call.
|
|
480
|
+
all_inputs = [inp for p in plans for inp in p.inputs]
|
|
481
|
+
all_vecs = (
|
|
482
|
+
self.embedder.embed_batch(all_inputs, is_query=False) if all_inputs else []
|
|
483
|
+
)
|
|
484
|
+
# Pass 3: write notes + FTS + chunks + vectors.
|
|
485
|
+
chunk_rowid = 1
|
|
486
|
+
vi = 0
|
|
487
|
+
for p in plans:
|
|
488
|
+
nch = len(p.inputs)
|
|
489
|
+
chunk_rowid = self._write_planned(p, all_vecs[vi : vi + nch], chunk_rowid)
|
|
490
|
+
vi += nch
|
|
491
|
+
self.conn.commit()
|
|
492
|
+
self._seen_ids = None # scope dedup strictly to the rebuild loop
|
|
493
|
+
return {
|
|
494
|
+
"indexed": len(plans),
|
|
495
|
+
"chunks": chunk_rowid - 1,
|
|
496
|
+
"backend": self.backend.name,
|
|
497
|
+
"embed_model": self.embedder.model_id,
|
|
498
|
+
"embed_dim": self.embedder.dim,
|
|
499
|
+
"db": str(self.db_path),
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
# -- build (incremental, IDX-03) -------------------------------------
|
|
503
|
+
def sync(self, vault: Path) -> dict[str, Any]:
|
|
504
|
+
"""Incrementally reconcile the index with vault/ by path + content-hash.
|
|
505
|
+
|
|
506
|
+
Only changed/new notes are re-indexed; notes whose file vanished are
|
|
507
|
+
deleted (delete-propagation). A schema or embed-model mismatch forces a
|
|
508
|
+
clean rebuild (mixing model vectors would corrupt retrieval)."""
|
|
509
|
+
if not self._schema_ready():
|
|
510
|
+
res = self.rebuild(vault)
|
|
511
|
+
res["mode"] = "rebuild(no-schema)"
|
|
512
|
+
return res
|
|
513
|
+
if not self.model_matches():
|
|
514
|
+
res = self.rebuild(vault)
|
|
515
|
+
res["mode"] = "rebuild(model-change)"
|
|
516
|
+
return res
|
|
517
|
+
|
|
518
|
+
c = self.conn
|
|
519
|
+
# Current on-disk state: path -> Note.
|
|
520
|
+
on_disk: dict[str, Note] = {}
|
|
521
|
+
for note in scan_vault(vault):
|
|
522
|
+
on_disk[note.path.as_posix()] = note
|
|
523
|
+
# Indexed state: path -> (note_rowid, content_hash).
|
|
524
|
+
indexed: dict[str, tuple[int, str]] = {
|
|
525
|
+
r[0]: (int(r[1]), r[2] or "")
|
|
526
|
+
for r in c.execute("SELECT path, rowid, content_hash FROM notes").fetchall()
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
added = updated = unchanged = deleted = 0
|
|
530
|
+
chunk_rowid = self._next_rowid("chunks")
|
|
531
|
+
|
|
532
|
+
# Delete-propagation FIRST (H-2): a renamed/moved note keeps its id but
|
|
533
|
+
# gets a new path, landing in both "path not in on_disk" (old path,
|
|
534
|
+
# deleted below) and "path not in indexed" (new path, inserted above).
|
|
535
|
+
# If we insert before deleting, the new-path insert can collide with
|
|
536
|
+
# the still-present old-path row on the UNIQUE `id` column. Deleting
|
|
537
|
+
# every stale path (by path, and belt-and-suspenders by id collision)
|
|
538
|
+
# before the insert/update pass makes rename-with-same-id a no-crash,
|
|
539
|
+
# normal reconcile.
|
|
540
|
+
for path, (note_rowid, _h) in indexed.items():
|
|
541
|
+
if path not in on_disk:
|
|
542
|
+
self._delete_note(note_rowid)
|
|
543
|
+
deleted += 1
|
|
544
|
+
|
|
545
|
+
# Re-fetch indexed ids after the deletion pass above so a same-id
|
|
546
|
+
# rename never hits "UNIQUE constraint failed: notes.id".
|
|
547
|
+
indexed_ids: dict[str, int] = {
|
|
548
|
+
r[0]: int(r[1])
|
|
549
|
+
for r in c.execute("SELECT id, rowid FROM notes").fetchall()
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
for path, note in on_disk.items():
|
|
553
|
+
if path not in indexed:
|
|
554
|
+
if note.id in indexed_ids:
|
|
555
|
+
self._delete_note(indexed_ids[note.id])
|
|
556
|
+
del indexed_ids[note.id]
|
|
557
|
+
note_rowid = self._next_rowid("notes")
|
|
558
|
+
chunk_rowid = self._insert_note(note, note_rowid, chunk_rowid)
|
|
559
|
+
added += 1
|
|
560
|
+
elif indexed[path][1] != note.content_hash:
|
|
561
|
+
old_rowid = indexed[path][0]
|
|
562
|
+
self._delete_note(old_rowid)
|
|
563
|
+
# reuse the old note_rowid for stability
|
|
564
|
+
chunk_rowid = self._insert_note(note, old_rowid, chunk_rowid)
|
|
565
|
+
updated += 1
|
|
566
|
+
else:
|
|
567
|
+
unchanged += 1
|
|
568
|
+
|
|
569
|
+
self.conn.commit()
|
|
570
|
+
total_chunks = int(
|
|
571
|
+
c.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
|
|
572
|
+
)
|
|
573
|
+
return {
|
|
574
|
+
"mode": "incremental",
|
|
575
|
+
"added": added,
|
|
576
|
+
"updated": updated,
|
|
577
|
+
"unchanged": unchanged,
|
|
578
|
+
"deleted": deleted,
|
|
579
|
+
"indexed": added + updated + unchanged,
|
|
580
|
+
"chunks": total_chunks,
|
|
581
|
+
"backend": self.backend.name,
|
|
582
|
+
"embed_model": self.embedder.model_id,
|
|
583
|
+
"embed_dim": self.embedder.dim,
|
|
584
|
+
"db": str(self.db_path),
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
# -- retrieval --------------------------------------------------------
|
|
588
|
+
def _note_row(self, rowid: int) -> dict[str, Any] | None:
|
|
589
|
+
r = self.conn.execute(
|
|
590
|
+
"SELECT id,title,classification,zone,path,body,is_latest_version,type"
|
|
591
|
+
" FROM notes WHERE rowid=?",
|
|
592
|
+
(rowid,),
|
|
593
|
+
).fetchone()
|
|
594
|
+
if not r:
|
|
595
|
+
return None
|
|
596
|
+
return {
|
|
597
|
+
"id": r[0], "title": r[1], "classification": r[2],
|
|
598
|
+
"zone": r[3], "path": r[4], "body": r[5], "is_latest_version": r[6] or "",
|
|
599
|
+
"type": r[7] or "",
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
@staticmethod
|
|
603
|
+
def _snippet(body: str, n: int = 160) -> str:
|
|
604
|
+
s = " ".join(body.split())
|
|
605
|
+
return s[:n] + ("…" if len(s) > n else "")
|
|
606
|
+
|
|
607
|
+
# Default typed-zone authority weights (anti-burial).
|
|
608
|
+
# PT-02 (s05): curated_weight=2.0 / meetings_damp=0.55 is the CV-SELECTED
|
|
609
|
+
# candidate from a 5x4=20-point (curated boost x "40 Meetings" damp) grid,
|
|
610
|
+
# chosen by stratified 5-fold CV on train+dev ONLY (H34) — see
|
|
611
|
+
# `eval/pt_zonefix_sweep.py` and `docs/eval-bench/pt-fix.md`. All 5 outer
|
|
612
|
+
# folds independently selected this SAME point (stable, not noise). It
|
|
613
|
+
# replaces the pre-S05 1.35-only default, which was provably a no-op on
|
|
614
|
+
# the migrated index (see `_resolve_zone` docstring — the prior needs
|
|
615
|
+
# BOTH the source_zone fix AND a weight strong enough to move fused RRF
|
|
616
|
+
# ranks once alive). Damping "40 Meetings" (rather than a bigger uniform
|
|
617
|
+
# curated boost) is what avoids trading away monolingual_pt / multi_hop,
|
|
618
|
+
# per `docs/eval-bench/pt-diagnosis.md` §3 E5's own warning.
|
|
619
|
+
_DEFAULT_ZONE_WEIGHTS = {
|
|
620
|
+
"10 People": 2.0, "20 Companies": 2.0, "30 Projects": 2.0,
|
|
621
|
+
"60 Concepts": 2.0, "70 Decisions": 2.0,
|
|
622
|
+
"40 Meetings": 0.55,
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
def _zone_weight(self, zone: str) -> float:
|
|
626
|
+
"""Authority multiplier for a note's zone (see hybrid_search). Curated
|
|
627
|
+
typed zones get a modest boost over voluminous transcript/source zones;
|
|
628
|
+
unknown zones default to 1.0. Override via BRAIN_ZONE_WEIGHTS (JSON)."""
|
|
629
|
+
weights = getattr(self, "_zone_weights", None)
|
|
630
|
+
if weights is None:
|
|
631
|
+
import json as _json
|
|
632
|
+
import os as _os
|
|
633
|
+
weights = dict(self._DEFAULT_ZONE_WEIGHTS)
|
|
634
|
+
raw = _os.environ.get("BRAIN_ZONE_WEIGHTS")
|
|
635
|
+
if raw:
|
|
636
|
+
try:
|
|
637
|
+
weights.update({str(k): float(v) for k, v in _json.loads(raw).items()})
|
|
638
|
+
except Exception:
|
|
639
|
+
pass
|
|
640
|
+
self._zone_weights = weights
|
|
641
|
+
return float(weights.get(zone, 1.0))
|
|
642
|
+
|
|
643
|
+
def _resolve_zone(self, zone_col: str, path: str) -> str:
|
|
644
|
+
"""Anti-burial authority KEY for a note (PT-02, s05).
|
|
645
|
+
|
|
646
|
+
The live migrated index flattens every Johnny-Decimal zone to
|
|
647
|
+
``brain``/``raw`` in the ``notes.zone`` column (People pages land in
|
|
648
|
+
``brain/areas/`` next to Companies; meeting transcripts land in
|
|
649
|
+
``raw/``). ``_zone_weight`` is keyed on the ORIGINAL zone names
|
|
650
|
+
(``"10 People"``, ``"40 Meetings"``, ...), so on the flattened column
|
|
651
|
+
it was a no-op — see `docs/eval-bench/pt-diagnosis.md` root cause 1.
|
|
652
|
+
|
|
653
|
+
This is a RETRIEVAL-TIME-ONLY fix (H23/H11 reversibility gate): the
|
|
654
|
+
migration tool (`tools/apply_live_migration.py`) already writes the
|
|
655
|
+
original zone into each migrated note's frontmatter as
|
|
656
|
+
``source_zone:`` alongside ``source_path:``. Rather than re-indexing
|
|
657
|
+
to carry that field into the SQLite schema, we read it straight off
|
|
658
|
+
the note's file (identified by the already-indexed ``path`` column)
|
|
659
|
+
at query time — no index/schema/vector change, fully reversible by
|
|
660
|
+
deleting this method and the call site. Brain-native notes created
|
|
661
|
+
after the migration have no ``source_zone`` (they were never
|
|
662
|
+
Johnny-Decimal); those fall back to the flattened ``zone`` column
|
|
663
|
+
unchanged, so this fix only ever *adds* signal, never removes it.
|
|
664
|
+
|
|
665
|
+
Only the frontmatter block (first ~2 KB) is read, not the full note
|
|
666
|
+
body — cheap even for large meeting transcripts. Results are cached
|
|
667
|
+
per ``(path, mtime)`` for the life of the index object so a query
|
|
668
|
+
that touches the same candidate note twice (or a session with many
|
|
669
|
+
queries) does not re-read the file repeatedly; a changed mtime
|
|
670
|
+
invalidates the cache entry automatically.
|
|
671
|
+
|
|
672
|
+
Kill switch: ``BRAIN_ZONE_SOURCE_MODE=column`` disables this and
|
|
673
|
+
restores the pre-fix behaviour (flattened column only) for rollback
|
|
674
|
+
without a code change.
|
|
675
|
+
"""
|
|
676
|
+
if os.environ.get("BRAIN_ZONE_SOURCE_MODE", "auto").strip().lower() == "column":
|
|
677
|
+
return zone_col
|
|
678
|
+
cache = getattr(self, "_source_zone_cache", None)
|
|
679
|
+
if cache is None:
|
|
680
|
+
cache = {}
|
|
681
|
+
self._source_zone_cache = cache
|
|
682
|
+
try:
|
|
683
|
+
mtime_ns = os.stat(path).st_mtime_ns
|
|
684
|
+
except OSError:
|
|
685
|
+
return zone_col
|
|
686
|
+
key = (path, mtime_ns)
|
|
687
|
+
if key in cache:
|
|
688
|
+
return cache[key] or zone_col
|
|
689
|
+
source_zone: str | None = None
|
|
690
|
+
try:
|
|
691
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
692
|
+
head = fh.read(2048)
|
|
693
|
+
if head.startswith("---"):
|
|
694
|
+
end = head.find("---", 3)
|
|
695
|
+
block = head[3:end] if end != -1 else head[3:]
|
|
696
|
+
meta = frontmatter.parse(block)
|
|
697
|
+
sz = meta.get("source_zone")
|
|
698
|
+
if isinstance(sz, str) and sz.strip():
|
|
699
|
+
source_zone = sz.strip()
|
|
700
|
+
except OSError:
|
|
701
|
+
source_zone = None
|
|
702
|
+
cache[key] = source_zone
|
|
703
|
+
return source_zone or zone_col
|
|
704
|
+
|
|
705
|
+
# Near-duplicate transcript suppression (PT-02, s05 — diagnosis cause #3).
|
|
706
|
+
# OFF BY DEFAULT (`_DEFAULT_DEDUP_THRESHOLD = None`). The s05 CV sweep
|
|
707
|
+
# (`eval/pt_dedup_sweep.py`, train+dev, H34) found the lever adds EXACTLY
|
|
708
|
+
# ZERO incremental Recall@10 on top of the zone-authority fix — every one
|
|
709
|
+
# of 5 CV folds selected "disabled" — because (a) the 0.55x zone damp
|
|
710
|
+
# already demotes the flooding transcript zone, and (b) the residual PT→EN
|
|
711
|
+
# failures are e5-small embedder-floor golds at note-rank 100-250, beyond
|
|
712
|
+
# the candidate pool no top-k reordering can reach (diagnosis E4/E6, em-01
|
|
713
|
+
# scope). Shipping it ON by default would add latency + risk for no proven
|
|
714
|
+
# value, so it stays off. The machinery + `BRAIN_DEDUP_THRESHOLD` (float in
|
|
715
|
+
# (0,1)) / `BRAIN_DEDUP_SCOPE=all|transcript` knobs are retained + tested
|
|
716
|
+
# for a future corpus / post-embedder-swap re-eval. See
|
|
717
|
+
# `docs/eval-bench/pt-fix.md`.
|
|
718
|
+
_DEFAULT_DEDUP_THRESHOLD: float | None = None
|
|
719
|
+
_TRANSCRIPT_ZONES = frozenset({"40 Meetings", "raw"})
|
|
720
|
+
|
|
721
|
+
def _dedup_params(self) -> tuple[float | None, str]:
|
|
722
|
+
thr = self._DEFAULT_DEDUP_THRESHOLD
|
|
723
|
+
raw = os.environ.get("BRAIN_DEDUP_THRESHOLD")
|
|
724
|
+
if raw is not None:
|
|
725
|
+
try:
|
|
726
|
+
thr = float(raw)
|
|
727
|
+
except ValueError:
|
|
728
|
+
pass
|
|
729
|
+
scope = os.environ.get("BRAIN_DEDUP_SCOPE", "transcript").strip().lower()
|
|
730
|
+
return thr, scope
|
|
731
|
+
|
|
732
|
+
def _suppress_near_dups(
|
|
733
|
+
self,
|
|
734
|
+
ordered: list[int],
|
|
735
|
+
best_chunk_rowid: dict[int, int],
|
|
736
|
+
zmap: dict[int, str],
|
|
737
|
+
col_zone: dict[int, str],
|
|
738
|
+
in_lex: set[int],
|
|
739
|
+
) -> list[int]:
|
|
740
|
+
"""Retrieval-time near-duplicate SUPPRESSION (H11/H23 — never an
|
|
741
|
+
index-time deletion; suppressed notes are DEMOTED to the tail, so they
|
|
742
|
+
still surface at a larger k and nothing is removed from the index).
|
|
743
|
+
|
|
744
|
+
Root cause #3 of `docs/eval-bench/pt-diagnosis.md`: meeting transcripts
|
|
745
|
+
are near-duplicative (6,752 chunk pairs >=0.80 cosine; 3,988 >=0.97) and
|
|
746
|
+
monopolise the top-k (100% of the failing cross-lingual top-10),
|
|
747
|
+
crowding out the single canonical curated note. This pass walks the
|
|
748
|
+
fused ranking best-first and, when a TRANSCRIPT-zone candidate's
|
|
749
|
+
representative (best-chunk) vector is >= the threshold cosine to an
|
|
750
|
+
already-KEPT candidate's vector, defers it — keeping the cluster's
|
|
751
|
+
highest-ranked representative but freeing the slots its near-clones
|
|
752
|
+
would occupy. It is deliberately CONSERVATIVE (diagnosis §5 F3 risk):
|
|
753
|
+
* only transcript-zone candidates are eligible for suppression
|
|
754
|
+
(scope=transcript, the default) — a curated note is never
|
|
755
|
+
suppressed, so a genuinely-relevant canonical hit cannot be lost;
|
|
756
|
+
* the FIRST (highest-ranked) member of any near-dup cluster always
|
|
757
|
+
survives, so a relevant transcript still surfaces (mono-PT uses
|
|
758
|
+
transcript golds — diagnosis E3b);
|
|
759
|
+
* lexical ("both"/exact) hits are never suppressed;
|
|
760
|
+
* a candidate with no dense best-chunk vector (lexical-only) is never
|
|
761
|
+
suppressed and is not usable as a suppressor reference.
|
|
762
|
+
"""
|
|
763
|
+
thr, scope = self._dedup_params()
|
|
764
|
+
if thr is None or not (0.0 < thr < 1.0) or len(ordered) <= 1:
|
|
765
|
+
return ordered
|
|
766
|
+
from .vectors import cosine
|
|
767
|
+
|
|
768
|
+
want = [
|
|
769
|
+
best_chunk_rowid[rid] for rid in ordered if rid in best_chunk_rowid
|
|
770
|
+
]
|
|
771
|
+
vecs_by_chunk = self.backend.get_vectors(self.conn, want) if want else {}
|
|
772
|
+
|
|
773
|
+
def _vec(rid: int) -> list[float] | None:
|
|
774
|
+
cr = best_chunk_rowid.get(rid)
|
|
775
|
+
return vecs_by_chunk.get(cr) if cr is not None else None
|
|
776
|
+
|
|
777
|
+
def _is_transcript(rid: int) -> bool:
|
|
778
|
+
return (zmap.get(rid, "") in self._TRANSCRIPT_ZONES
|
|
779
|
+
or col_zone.get(rid, "") in self._TRANSCRIPT_ZONES)
|
|
780
|
+
|
|
781
|
+
kept: list[int] = []
|
|
782
|
+
kept_vecs: list[list[float]] = []
|
|
783
|
+
deferred: list[int] = []
|
|
784
|
+
for rid in ordered:
|
|
785
|
+
v = _vec(rid)
|
|
786
|
+
eligible = (
|
|
787
|
+
v is not None
|
|
788
|
+
and rid not in in_lex
|
|
789
|
+
and (scope == "all" or _is_transcript(rid))
|
|
790
|
+
)
|
|
791
|
+
if eligible and any(cosine(v, kv) >= thr for kv in kept_vecs):
|
|
792
|
+
deferred.append(rid)
|
|
793
|
+
continue
|
|
794
|
+
kept.append(rid)
|
|
795
|
+
if v is not None:
|
|
796
|
+
kept_vecs.append(v)
|
|
797
|
+
return kept + deferred
|
|
798
|
+
|
|
799
|
+
# -- ranked sub-lists for fusion (RET-01) ----------------------------
|
|
800
|
+
def _lexical_ranked(self, query: str, n: int) -> list[int]:
|
|
801
|
+
"""FTS5 BM25 ranked note rowids, best-first. (`rank` is BM25; lower is
|
|
802
|
+
better, so ``ORDER BY rank`` is best-first.)"""
|
|
803
|
+
c = self.conn
|
|
804
|
+
try:
|
|
805
|
+
toks = [t for t in query.replace('"', " ").split() if t]
|
|
806
|
+
fts_q = " OR ".join(f'"{t}"' for t in toks) if toks else '""'
|
|
807
|
+
return [
|
|
808
|
+
int(rowid)
|
|
809
|
+
for (rowid,) in c.execute(
|
|
810
|
+
"SELECT rowid FROM notes_fts WHERE notes_fts MATCH ? "
|
|
811
|
+
"ORDER BY rank LIMIT ?",
|
|
812
|
+
(fts_q, n),
|
|
813
|
+
)
|
|
814
|
+
]
|
|
815
|
+
except sqlite3.OperationalError:
|
|
816
|
+
return []
|
|
817
|
+
|
|
818
|
+
def decision_layer_hits(self, query: str, k: int = 8) -> list["Hit"]:
|
|
819
|
+
"""RET-10b: TARGETED lexical probe over the DECISION LAYER — live
|
|
820
|
+
``type: decision`` notes BM25-ranked against ``query``. Exists so a
|
|
821
|
+
dossier's decision layer never depends on decision notes cracking
|
|
822
|
+
the general semantic top-k (measured: a phrasing shift pushed them
|
|
823
|
+
below rank 60 and the sweep's decision layer came back empty while
|
|
824
|
+
the notes plainly existed). Decisions are scarce, FTS is indexed —
|
|
825
|
+
this probe is one cheap query."""
|
|
826
|
+
c = self.conn
|
|
827
|
+
try:
|
|
828
|
+
toks = [t for t in query.replace('"', " ").split() if t]
|
|
829
|
+
fts_q = " OR ".join(f'"{t}"' for t in toks) if toks else '""'
|
|
830
|
+
rowids = [
|
|
831
|
+
int(r) for (r,) in c.execute(
|
|
832
|
+
"SELECT f.rowid FROM notes_fts f JOIN notes n ON n.rowid = f.rowid "
|
|
833
|
+
"WHERE f.notes_fts MATCH ? AND n.type = 'decision' "
|
|
834
|
+
"AND COALESCE(n.is_latest_version,'') != 'false' "
|
|
835
|
+
"ORDER BY f.rank LIMIT ?", (fts_q, k))
|
|
836
|
+
]
|
|
837
|
+
except sqlite3.OperationalError:
|
|
838
|
+
return []
|
|
839
|
+
date_expr = ("COALESCE(NULLIF(effective_date,''), "
|
|
840
|
+
"NULLIF(document_date,''), created)")
|
|
841
|
+
hits: list[Hit] = []
|
|
842
|
+
for rid in rowids:
|
|
843
|
+
row = self._note_row(rid)
|
|
844
|
+
if not row:
|
|
845
|
+
continue
|
|
846
|
+
(d,) = c.execute(
|
|
847
|
+
f"SELECT {date_expr} FROM notes WHERE rowid = ?", (rid,)).fetchone() # nosec B608 — placeholders only
|
|
848
|
+
hits.append(Hit(
|
|
849
|
+
id=row["id"], title=row["title"],
|
|
850
|
+
classification=row["classification"], zone=row["zone"],
|
|
851
|
+
path=row["path"], score=0.0, source="lexical",
|
|
852
|
+
snippet=self._snippet(row["body"]),
|
|
853
|
+
is_latest_version=row.get("is_latest_version", ""),
|
|
854
|
+
date=str(d or ""), type=row.get("type", ""),
|
|
855
|
+
))
|
|
856
|
+
return hits
|
|
857
|
+
|
|
858
|
+
def _dense_ranked(
|
|
859
|
+
self, query: str, n: int
|
|
860
|
+
) -> tuple[list[int], dict[int, str], dict[int, int]]:
|
|
861
|
+
"""Dense (vector) ranked note rowids best-first + best chunk text per note
|
|
862
|
+
+ best chunk ROWID per note (the last enables retrieval-time near-dup
|
|
863
|
+
suppression to fetch each note's representative vector without
|
|
864
|
+
re-embedding).
|
|
865
|
+
|
|
866
|
+
Embeds the query LAZILY here (with the canonical ``query:`` prefix) — the
|
|
867
|
+
only place a query embedding is computed, so lexical-only tools never pay
|
|
868
|
+
the embed cost. Goes through the ``VectorBackend`` ADAPTER (CORE-01); it
|
|
869
|
+
never depends on sqlite-vec directly, so brute-force is identical.
|
|
870
|
+
|
|
871
|
+
Embedder-pending guard (S02/CS-01): a cold-start install builds the
|
|
872
|
+
index with an offline placeholder (``BRAIN_EMBEDDER=hash``) so lexical
|
|
873
|
+
search works without a network model download. If the LIVE embedder
|
|
874
|
+
(real, once cached) doesn't match what the stored chunk vectors were
|
|
875
|
+
built with (:meth:`model_matches`), embedding the query with the real
|
|
876
|
+
model and comparing it against placeholder passage vectors would be
|
|
877
|
+
pure noise — worse than no dense leg at all. So this degrades to
|
|
878
|
+
FTS-only (empty dense list) until a rebuild/sync re-embeds with the
|
|
879
|
+
matching model (``brain warmup`` then `brain sync`, which self-heals
|
|
880
|
+
via the SAME model-mismatch check `sync()` already applies)."""
|
|
881
|
+
if not self.model_matches():
|
|
882
|
+
return [], {}, {}
|
|
883
|
+
c = self.conn
|
|
884
|
+
qvec = self.embedder.embed(query, is_query=True)
|
|
885
|
+
chunk_hits = self.backend.search(c, qvec, n * 4)
|
|
886
|
+
best: dict[int, float] = {}
|
|
887
|
+
best_chunk_text: dict[int, str] = {}
|
|
888
|
+
best_chunk_rowid: dict[int, int] = {}
|
|
889
|
+
order: list[int] = []
|
|
890
|
+
for chunk_rowid, score in chunk_hits:
|
|
891
|
+
row = c.execute(
|
|
892
|
+
"SELECT note_rowid, text FROM chunks WHERE rowid=?", (chunk_rowid,)
|
|
893
|
+
).fetchone()
|
|
894
|
+
if not row:
|
|
895
|
+
continue
|
|
896
|
+
nrid, ctext = int(row[0]), row[1]
|
|
897
|
+
if score > best.get(nrid, -1.0):
|
|
898
|
+
best[nrid] = score
|
|
899
|
+
best_chunk_text[nrid] = ctext
|
|
900
|
+
best_chunk_rowid[nrid] = int(chunk_rowid)
|
|
901
|
+
# Re-rank notes by their best chunk score (chunk_hits is chunk-order).
|
|
902
|
+
order = sorted(best, key=lambda r: best[r], reverse=True)[:n]
|
|
903
|
+
return order, best_chunk_text, best_chunk_rowid
|
|
904
|
+
|
|
905
|
+
def search(self, query: str, k: int = 10) -> list[Hit]:
|
|
906
|
+
"""Back-compat alias for :meth:`hybrid_search` (reranking off)."""
|
|
907
|
+
return self.hybrid_search(query, k=k)
|
|
908
|
+
|
|
909
|
+
def hybrid_search(
|
|
910
|
+
self,
|
|
911
|
+
query: str,
|
|
912
|
+
k: int = 10,
|
|
913
|
+
*,
|
|
914
|
+
rrf_k: int = 60,
|
|
915
|
+
candidate_factor: int = 8,
|
|
916
|
+
rerank: bool = False,
|
|
917
|
+
reranker: Any | None = None,
|
|
918
|
+
rerank_top: int = 15,
|
|
919
|
+
) -> list[Hit]:
|
|
920
|
+
"""Fuse FTS5 BM25 (lexical, note-level) + dense vectors (semantic,
|
|
921
|
+
chunk-level → folded to note) into ONE ranking via Reciprocal Rank
|
|
922
|
+
Fusion, RRF(k=60). UNFILTERED — classification filtering is the
|
|
923
|
+
integration surface's job (CLI).
|
|
924
|
+
|
|
925
|
+
RRF score for a note = Σ over each list it appears in of
|
|
926
|
+
``1 / (rrf_k + rank)`` (rank 1-based). RRF needs only the *rank* of each
|
|
927
|
+
item in each list, so the two retrievers' incomparable score scales
|
|
928
|
+
(BM25 vs cosine) never have to be reconciled — the property that makes
|
|
929
|
+
the fusion at-least-as-good-as-either across languages.
|
|
930
|
+
|
|
931
|
+
Adapter seam (HARDENED:codex): the dense list comes through the
|
|
932
|
+
``VectorBackend`` adapter, NOT a hard-wired sqlite-vec call, so a pre-v1
|
|
933
|
+
sqlite-vec change cannot force a retrieval rewrite and brute-force fuses
|
|
934
|
+
identically. The reranker (RET-02) is strictly optional and bounded to
|
|
935
|
+
the top ``rerank_top`` (10-20) candidates.
|
|
936
|
+
"""
|
|
937
|
+
n = max(k * candidate_factor, k)
|
|
938
|
+
lex = self._lexical_ranked(query, n)
|
|
939
|
+
dense, best_chunk_text, best_chunk_rowid = self._dense_ranked(query, n)
|
|
940
|
+
|
|
941
|
+
in_lex = set(lex)
|
|
942
|
+
in_dense = set(dense)
|
|
943
|
+
scores: dict[int, float] = {}
|
|
944
|
+
for rank, rid in enumerate(lex, start=1):
|
|
945
|
+
scores[rid] = scores.get(rid, 0.0) + 1.0 / (rrf_k + rank)
|
|
946
|
+
for rank, rid in enumerate(dense, start=1):
|
|
947
|
+
scores[rid] = scores.get(rid, 0.0) + 1.0 / (rrf_k + rank)
|
|
948
|
+
|
|
949
|
+
# Zone-authority prior (RET-01 anti-burial). Curated/typed notes
|
|
950
|
+
# (Concepts/Decisions/Projects/People/Companies) are authoritative
|
|
951
|
+
# SUMMARIES; raw transcript/source zones are voluminous and near-
|
|
952
|
+
# duplicative, so a single canonical note is easily out-ranked purely by
|
|
953
|
+
# the *volume* of transcript chunks semantically near a query — most
|
|
954
|
+
# acutely for cross-lingual hits, where the canonical note is reachable
|
|
955
|
+
# only via the dense leg. We multiply the fused score by a modest
|
|
956
|
+
# per-zone weight so authority, not volume, decides ties. This mirrors
|
|
957
|
+
# the vault's own typed-zone design (it is NOT tuned to any eval); the
|
|
958
|
+
# weights are deliberately gentle so a genuinely relevant transcript
|
|
959
|
+
# still ranks. Tunable via BRAIN_ZONE_WEIGHTS (json zone->float).
|
|
960
|
+
#
|
|
961
|
+
# SCOPE (RET-01b). A *uniform* boost (scope=all) rescues cross-lingually
|
|
962
|
+
# buried canonical notes but collateral-damages exact-match retrieval: an
|
|
963
|
+
# identifier query's correct hit lives in any zone, and boosting curated
|
|
964
|
+
# zones demotes it. The burial it must fix is, by construction, a
|
|
965
|
+
# DENSE-ONLY phenomenon — a PT query reaching an EN-content canonical note
|
|
966
|
+
# shares no tokens, so it never appears in the lexical leg. So
|
|
967
|
+
# scope=semantic_only applies the prior ONLY to notes found via the dense
|
|
968
|
+
# leg and NOT the lexical leg, leaving exact-match (lexical / "both") hits
|
|
969
|
+
# at weight 1.0. This protects lexical_identifier while still de-burying
|
|
970
|
+
# the cross-lingual golds. It is the DEFAULT because it is principled
|
|
971
|
+
# (an exact-token match needs no authority help and must not be demoted)
|
|
972
|
+
# and dominated scope=all on every segment in the S10 e5-small A/B
|
|
973
|
+
# (identifier 0.958 vs 0.833; overall 0.573 vs 0.526 at the best weight).
|
|
974
|
+
# Tunable via BRAIN_ZONE_SCOPE = all | semantic_only (default
|
|
975
|
+
# semantic_only). Evidence: docs/operations/s10-pt-rootcause-and-fix.md.
|
|
976
|
+
zmap: dict[int, str] = {}
|
|
977
|
+
col_zone: dict[int, str] = {}
|
|
978
|
+
rdate: dict[int, str] = {}
|
|
979
|
+
if scores:
|
|
980
|
+
rids = tuple(scores)
|
|
981
|
+
# FALSE POSITIVE (scanner: string-built SQL / hardcoded_sql_expressions):
|
|
982
|
+
# `qmarks` interpolates only literal "?" placeholder characters (one
|
|
983
|
+
# per element of `rids`) -- the VALUES themselves are bound as query
|
|
984
|
+
# params (`rids`, passed separately below), never string-formatted
|
|
985
|
+
# into the SQL text. See docs/SECURITY_NOTES.md.
|
|
986
|
+
qmarks = ",".join("?" * len(rids))
|
|
987
|
+
# Valid-time fallback: effective_date → document_date → created,
|
|
988
|
+
# the same chain bases-query uses (§2/ADR-0003 Ruling 2).
|
|
989
|
+
date_expr = ("COALESCE(NULLIF(effective_date,''), "
|
|
990
|
+
"NULLIF(document_date,''), created)")
|
|
991
|
+
for r, z, p, d in self.conn.execute(
|
|
992
|
+
f"SELECT rowid, zone, path, {date_expr} FROM notes " # nosec B608
|
|
993
|
+
f"WHERE rowid IN ({qmarks})", rids
|
|
994
|
+
):
|
|
995
|
+
rid = int(r)
|
|
996
|
+
col_zone[rid] = z or ""
|
|
997
|
+
zmap[rid] = self._resolve_zone(z or "", p or "")
|
|
998
|
+
rdate[rid] = d or ""
|
|
999
|
+
scope = os.environ.get("BRAIN_ZONE_SCOPE", "semantic_only").strip().lower()
|
|
1000
|
+
for rid in scores:
|
|
1001
|
+
if scope == "semantic_only" and rid in in_lex:
|
|
1002
|
+
continue # exact-match hit — authority prior does not apply
|
|
1003
|
+
scores[rid] *= self._zone_weight(zmap.get(rid, ""))
|
|
1004
|
+
|
|
1005
|
+
# Recency prior (RET-07). The RRF fusion above is time-blind: a stale
|
|
1006
|
+
# version of a document outranks its current successor purely on text
|
|
1007
|
+
# similarity, so a "latest developments" query grounds on months-old
|
|
1008
|
+
# material. A gentle, multiplicative staleness penalty (≤1.0, so the
|
|
1009
|
+
# fused score stays under the RRF ceiling like the zone prior) makes
|
|
1010
|
+
# the more recent of two topically-similar hits win — without
|
|
1011
|
+
# reconciling score scales (same reason RRF uses ranks). Neutral
|
|
1012
|
+
# (×1.0) for undated notes, so nothing is penalised for lacking a date.
|
|
1013
|
+
# Knobs: BRAIN_RECENCY_WEIGHT (0 disables), BRAIN_RECENCY_HALFLIFE_DAYS.
|
|
1014
|
+
# Query-intent weighting (2026-07-11, the documented upgrade path
|
|
1015
|
+
# after the G&P benchmark): a query that is ABOUT currentness
|
|
1016
|
+
# ("latest", "current", "as of", PT equivalents) doubles the
|
|
1017
|
+
# staleness penalty and halves the half-life, so months-old
|
|
1018
|
+
# near-ties stop outranking the current claim. Env knobs, when
|
|
1019
|
+
# set, override BOTH modes absolutely (unchanged contract).
|
|
1020
|
+
temporal = bool(_TEMPORAL_INTENT_RE.search(query))
|
|
1021
|
+
rweight = _env_float("BRAIN_RECENCY_WEIGHT", 0.5 if temporal else 0.25)
|
|
1022
|
+
if rweight > 0:
|
|
1023
|
+
rhalf = _env_float("BRAIN_RECENCY_HALFLIFE_DAYS",
|
|
1024
|
+
90.0 if temporal else 180.0)
|
|
1025
|
+
today = _today()
|
|
1026
|
+
for rid in scores:
|
|
1027
|
+
scores[rid] *= _recency_factor(
|
|
1028
|
+
rdate.get(rid, ""), today, rweight, rhalf)
|
|
1029
|
+
|
|
1030
|
+
def _source(rid: int) -> str:
|
|
1031
|
+
if rid in in_lex and rid in in_dense:
|
|
1032
|
+
return "both"
|
|
1033
|
+
return "lexical" if rid in in_lex else "semantic"
|
|
1034
|
+
|
|
1035
|
+
ordered = sorted(scores, key=lambda r: (-scores[r], r))
|
|
1036
|
+
ordered = self._suppress_near_dups(
|
|
1037
|
+
ordered, best_chunk_rowid, zmap, col_zone, in_lex)
|
|
1038
|
+
|
|
1039
|
+
hits: list[Hit] = []
|
|
1040
|
+
for rid in ordered:
|
|
1041
|
+
row = self._note_row(rid)
|
|
1042
|
+
if not row:
|
|
1043
|
+
continue
|
|
1044
|
+
snippet_src = best_chunk_text.get(rid, row["body"])
|
|
1045
|
+
hits.append(
|
|
1046
|
+
Hit(
|
|
1047
|
+
id=row["id"], title=row["title"],
|
|
1048
|
+
classification=row["classification"], zone=row["zone"],
|
|
1049
|
+
path=row["path"], score=scores[rid], source=_source(rid),
|
|
1050
|
+
snippet=self._snippet(snippet_src),
|
|
1051
|
+
is_latest_version=row.get("is_latest_version", ""),
|
|
1052
|
+
date=rdate.get(rid, ""),
|
|
1053
|
+
type=row.get("type", ""),
|
|
1054
|
+
)
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
if rerank and hits:
|
|
1058
|
+
hits = self._apply_rerank(query, hits, reranker, rerank_top)
|
|
1059
|
+
return hits[:k]
|
|
1060
|
+
|
|
1061
|
+
def freshness(self, newest_hit_date: str, max_tier: str) -> dict[str, Any]:
|
|
1062
|
+
"""RET-09: how much NEWER material the vault holds past
|
|
1063
|
+
``newest_hit_date`` (valid-time chain: effective_date → document_date
|
|
1064
|
+
→ created), respecting the caller's egress cap.
|
|
1065
|
+
|
|
1066
|
+
Motivation (2026-07 G&P benchmark): an agent answering a "latest
|
|
1067
|
+
decisions" question from a coherent set of curated hits has no signal
|
|
1068
|
+
that the vault continues PAST its newest hit — it declares victory on
|
|
1069
|
+
stale material. This is that signal: cheap (one aggregate query),
|
|
1070
|
+
generic (no topic modelling), and honest about the cap (a capped
|
|
1071
|
+
caller learns only counts, consistent with the egress report's
|
|
1072
|
+
existing ``withheld`` counter)."""
|
|
1073
|
+
date_expr = ("COALESCE(NULLIF(effective_date,''), "
|
|
1074
|
+
"NULLIF(document_date,''), created)")
|
|
1075
|
+
# Only ISO-shaped dates participate: a garbage `created` value like
|
|
1076
|
+
# "unknown" sorts lexicographically above every real date and would
|
|
1077
|
+
# both inflate the count and win the MAX.
|
|
1078
|
+
where = (f"{date_expr} > ? AND {date_expr} GLOB "
|
|
1079
|
+
"'[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'")
|
|
1080
|
+
params: list[str] = [newest_hit_date]
|
|
1081
|
+
if max_tier != cls_mod.TIERS[-1]:
|
|
1082
|
+
# Below the MNPI cap, count only notes the caller could actually
|
|
1083
|
+
# surface (unlabelled ranks MNPI, so it is excluded here too).
|
|
1084
|
+
allowed = [t for t in cls_mod.TIERS
|
|
1085
|
+
if cls_mod.RANK[t] <= cls_mod.RANK.get(max_tier, 0)]
|
|
1086
|
+
where += f" AND classification IN ({','.join('?' * len(allowed))})"
|
|
1087
|
+
params += allowed
|
|
1088
|
+
row = self.conn.execute(
|
|
1089
|
+
f"SELECT COUNT(*), MAX({date_expr}) FROM notes WHERE {where}", # nosec B608 — placeholders only
|
|
1090
|
+
params).fetchone()
|
|
1091
|
+
return {"newest_hit_date": newest_hit_date,
|
|
1092
|
+
"newer_count": int(row[0] or 0),
|
|
1093
|
+
"vault_newest": row[1] or ""}
|
|
1094
|
+
|
|
1095
|
+
# -- multi-hop graph-augmented retrieval (RET-06) --------------------
|
|
1096
|
+
def _link_graph_cached(self) -> Any:
|
|
1097
|
+
from .graph import build_graph
|
|
1098
|
+
if self._link_graph is None:
|
|
1099
|
+
self._link_graph = build_graph(self.conn)
|
|
1100
|
+
return self._link_graph
|
|
1101
|
+
|
|
1102
|
+
def _entity_lexicon_cached(self) -> Any:
|
|
1103
|
+
from .multihop import EntityLexicon
|
|
1104
|
+
if self._entity_lex is None:
|
|
1105
|
+
self._entity_lex = EntityLexicon.build(self.conn)
|
|
1106
|
+
return self._entity_lex
|
|
1107
|
+
|
|
1108
|
+
def hybrid_search_graph(
|
|
1109
|
+
self,
|
|
1110
|
+
query: str,
|
|
1111
|
+
k: int = 10,
|
|
1112
|
+
*,
|
|
1113
|
+
rerank: bool = False,
|
|
1114
|
+
rerank_top: int = 15,
|
|
1115
|
+
rrf_k: int = 60,
|
|
1116
|
+
depth: int = 2,
|
|
1117
|
+
graph_weight: float = 0.5,
|
|
1118
|
+
seed_flat_top: int = 3,
|
|
1119
|
+
flat_pool: int = 30,
|
|
1120
|
+
return_trace: bool = False,
|
|
1121
|
+
) -> list[Hit] | tuple[list[Hit], dict]:
|
|
1122
|
+
"""Gated graph-augmented multi-hop retrieval (RET-06).
|
|
1123
|
+
|
|
1124
|
+
For a SINGLE-HOP query (the gate does not fire) this returns EXACTLY
|
|
1125
|
+
``hybrid_search(query, k, rerank=...)`` — same call, same result — so
|
|
1126
|
+
single-hop latency and quality can never regress. For a multi-hop-shaped
|
|
1127
|
+
query (>= 2 named non-hub entities) it fetches a wider flat pool, expands
|
|
1128
|
+
the wikilink graph from the named entities + top flat hits, and fuses the
|
|
1129
|
+
graph candidates into the flat ranking (flat-dominant weighted RRF).
|
|
1130
|
+
|
|
1131
|
+
DISCOVERY-ONLY (RET-03): the graph only nominates candidate note ids that
|
|
1132
|
+
flat retrieval could reach; it never fabricates a note and never
|
|
1133
|
+
overrides an authoritative flat hit. See ``brain.multihop``."""
|
|
1134
|
+
from .multihop import graph_augmented_ranking
|
|
1135
|
+
|
|
1136
|
+
lexicon = self._entity_lexicon_cached()
|
|
1137
|
+
mentions = lexicon.mentions(query)
|
|
1138
|
+
from .multihop import is_multihop_shaped
|
|
1139
|
+
|
|
1140
|
+
if not is_multihop_shaped(mentions):
|
|
1141
|
+
# PASSTHROUGH — byte-identical to flat. The graph is never built.
|
|
1142
|
+
hits = self.hybrid_search(
|
|
1143
|
+
query, k=k, rerank=rerank, rerank_top=rerank_top, rrf_k=rrf_k
|
|
1144
|
+
)
|
|
1145
|
+
return (hits, {"fired": False, "entities": [m.surface for m in mentions]}) \
|
|
1146
|
+
if return_trace else hits
|
|
1147
|
+
|
|
1148
|
+
# Multi-hop path: wider flat pool so tail relevant notes exist to promote.
|
|
1149
|
+
pool = self.hybrid_search(
|
|
1150
|
+
query, k=max(k, flat_pool), rerank=rerank, rerank_top=rerank_top,
|
|
1151
|
+
rrf_k=rrf_k,
|
|
1152
|
+
)
|
|
1153
|
+
pool_by_id = {h.id: h for h in pool}
|
|
1154
|
+
flat_ids = [h.id for h in pool]
|
|
1155
|
+
graph = self._link_graph_cached()
|
|
1156
|
+
fired, ranked_ids, trace = graph_augmented_ranking(
|
|
1157
|
+
query, flat_ids, lexicon, graph,
|
|
1158
|
+
depth=depth, graph_weight=graph_weight, rrf_k=rrf_k,
|
|
1159
|
+
seed_flat_top=seed_flat_top,
|
|
1160
|
+
)
|
|
1161
|
+
# Assemble Hit objects in fused order. Notes flat already retrieved reuse
|
|
1162
|
+
# their Hit (title/snippet); graph-only notes are hydrated from the notes
|
|
1163
|
+
# table and tagged source="graph" so a caller sees the discovery
|
|
1164
|
+
# provenance. CRITICAL: re-stamp a strictly-DESCENDING score encoding the
|
|
1165
|
+
# fused RANK so the fused order survives any ``{path: score}`` round-trip
|
|
1166
|
+
# (e.g. the eval harness re-sorts a run by score) — mirrors the
|
|
1167
|
+
# post-fusion re-stamp in ``BrainCore.search_multi`` (RET-05b).
|
|
1168
|
+
from dataclasses import replace
|
|
1169
|
+
|
|
1170
|
+
top = ranked_ids[:k]
|
|
1171
|
+
n = len(top)
|
|
1172
|
+
out: list[Hit] = []
|
|
1173
|
+
for i, nid in enumerate(top):
|
|
1174
|
+
h = pool_by_id.get(nid)
|
|
1175
|
+
if h is None:
|
|
1176
|
+
h = self._graph_hit(nid)
|
|
1177
|
+
if h is None:
|
|
1178
|
+
continue
|
|
1179
|
+
out.append(replace(h, score=float(n - i)))
|
|
1180
|
+
return (out, trace) if return_trace else out
|
|
1181
|
+
|
|
1182
|
+
def _graph_hit(self, note_id: str) -> Hit | None:
|
|
1183
|
+
"""Build a Hit for a graph-ONLY candidate (flat never retrieved it).
|
|
1184
|
+
Tagged source="graph" for discovery provenance; the score is re-stamped
|
|
1185
|
+
by the caller to encode fused rank."""
|
|
1186
|
+
row = self._note_row(self._rowid_of(note_id))
|
|
1187
|
+
if not row:
|
|
1188
|
+
return None
|
|
1189
|
+
return Hit(
|
|
1190
|
+
id=row["id"], title=row["title"],
|
|
1191
|
+
classification=row["classification"], zone=row["zone"],
|
|
1192
|
+
path=row["path"], score=0.0, source="graph",
|
|
1193
|
+
snippet=self._snippet(row["body"]),
|
|
1194
|
+
is_latest_version=row.get("is_latest_version", ""),
|
|
1195
|
+
type=row.get("type", ""),
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
def _apply_rerank(
|
|
1199
|
+
self, query: str, hits: list[Hit], reranker: Any | None, rerank_top: int
|
|
1200
|
+
) -> list[Hit]:
|
|
1201
|
+
"""Re-order ONLY the top ``rerank_top`` hits with a cross-encoder; the
|
|
1202
|
+
tail is left untouched and appended after. The window is clamped to
|
|
1203
|
+
[10, ceiling] where ceiling is 20 by default but raisable via
|
|
1204
|
+
BRAIN_RERANK_MAX; ``BRAIN_RERANK_TOP`` overrides the requested window
|
|
1205
|
+
size itself (so a wide-candidate pass can be driven by env without
|
|
1206
|
+
changing call sites). Skippable: a None/identity reranker is a no-op
|
|
1207
|
+
(RET-02)."""
|
|
1208
|
+
from .rerank import NoopReranker, clamp_rerank_top, get_reranker, _resolve_reranker_model
|
|
1209
|
+
|
|
1210
|
+
env_top = os.environ.get("BRAIN_RERANK_TOP")
|
|
1211
|
+
if env_top:
|
|
1212
|
+
try:
|
|
1213
|
+
rerank_top = int(env_top)
|
|
1214
|
+
except ValueError:
|
|
1215
|
+
pass
|
|
1216
|
+
# Resolve the reranker ONCE and cache it on the instance. A caller-supplied
|
|
1217
|
+
# reranker wins; otherwise we honour $BRAIN_RERANKER_MODEL, caching the
|
|
1218
|
+
# constructed cross-encoder so its ONNX session is loaded only once per
|
|
1219
|
+
# index lifetime (not per query — S11 perf fix).
|
|
1220
|
+
if reranker is not None:
|
|
1221
|
+
rr = reranker
|
|
1222
|
+
else:
|
|
1223
|
+
mid = _resolve_reranker_model()
|
|
1224
|
+
if self._reranker_cache and self._reranker_cache[0] == mid:
|
|
1225
|
+
rr = self._reranker_cache[1]
|
|
1226
|
+
else:
|
|
1227
|
+
rr = get_reranker("auto")
|
|
1228
|
+
self._reranker_cache = (mid, rr)
|
|
1229
|
+
top_n = clamp_rerank_top(rerank_top)
|
|
1230
|
+
head, tail = hits[:top_n], hits[top_n:]
|
|
1231
|
+
passages = [
|
|
1232
|
+
(self._note_row(self._rowid_of(h.id)) or {}).get("body", h.snippet) or h.snippet
|
|
1233
|
+
for h in head
|
|
1234
|
+
]
|
|
1235
|
+
passages = [p[:2000] for p in passages]
|
|
1236
|
+
try:
|
|
1237
|
+
rel = rr.rerank(query, passages)
|
|
1238
|
+
except Exception:
|
|
1239
|
+
# SKIPPABLE contract (RET-02): if the cross-encoder runtime/model is
|
|
1240
|
+
# unavailable (offline, not bundled), degrade to identity — never let
|
|
1241
|
+
# an absent precision-booster break retrieval.
|
|
1242
|
+
rel = NoopReranker().rerank(query, passages)
|
|
1243
|
+
reordered = [h for _, h in sorted(zip(rel, head), key=lambda t: t[0], reverse=True)]
|
|
1244
|
+
return reordered + tail
|
|
1245
|
+
|
|
1246
|
+
def _rowid_of(self, note_id: str) -> int:
|
|
1247
|
+
r = self.conn.execute("SELECT rowid FROM notes WHERE id=?", (note_id,)).fetchone()
|
|
1248
|
+
return int(r[0]) if r else -1
|
|
1249
|
+
|
|
1250
|
+
# -- agentic tool surface (RET-04): grep + bases_query ---------------
|
|
1251
|
+
def grep(
|
|
1252
|
+
self, pattern: str, *, k: int = 20, ignore_case: bool = True, regex: bool = False
|
|
1253
|
+
) -> list[dict[str, Any]]:
|
|
1254
|
+
"""Lexical-first exact/regex scan over note bodies — NO embedding.
|
|
1255
|
+
|
|
1256
|
+
The agent's lexical-first entry point: it never embeds the query, so it
|
|
1257
|
+
is the cheap first probe before escalating to :meth:`hybrid_search`.
|
|
1258
|
+
Returns note-shaped dicts (filterable by the CLI egress gate) with the
|
|
1259
|
+
first matching line as the snippet and a match count.
|
|
1260
|
+
|
|
1261
|
+
Bounded against ReDoS / resource exhaustion (RET-04 hardening):
|
|
1262
|
+
``pattern`` is length-capped (:data:`MAX_GREP_PATTERN_LEN`) before
|
|
1263
|
+
compilation, and every match is wall-clock-bounded
|
|
1264
|
+
(:data:`GREP_REGEX_TIMEOUT_S`) when the optional `regex` engine is
|
|
1265
|
+
installed — see :func:`_grep_bounded_search`.
|
|
1266
|
+
"""
|
|
1267
|
+
if len(pattern) > MAX_GREP_PATTERN_LEN:
|
|
1268
|
+
raise GrepPatternError(
|
|
1269
|
+
f"grep pattern too long ({len(pattern)} chars; max "
|
|
1270
|
+
f"{MAX_GREP_PATTERN_LEN}) — refusing to compile "
|
|
1271
|
+
"(ReDoS / resource-exhaustion guard)"
|
|
1272
|
+
)
|
|
1273
|
+
# M-1: without the `regex` engine, a user-supplied --regex pattern has
|
|
1274
|
+
# NO wall-clock bound (stdlib `re` can hang on catastrophic backtracking
|
|
1275
|
+
# even under MAX_GREP_PATTERN_LEN, e.g. `(a+)+$`). Refuse explicit regex
|
|
1276
|
+
# mode outright on the minimal build rather than silently degrading to
|
|
1277
|
+
# an unbounded engine (VM-reachable surface).
|
|
1278
|
+
if regex and not _GREP_HAS_TIMEOUT:
|
|
1279
|
+
raise GrepPatternError(
|
|
1280
|
+
"grep --regex requires the 'regex' engine (pip install "
|
|
1281
|
+
"'brainiac-cli[index]') for a bounded match timeout; "
|
|
1282
|
+
"the minimal build has no ReDoS-safe regex path"
|
|
1283
|
+
)
|
|
1284
|
+
_re = _grep_engine # the timeout-capable `regex` engine when available, else stdlib re
|
|
1285
|
+
|
|
1286
|
+
flags = _re.IGNORECASE if ignore_case else 0
|
|
1287
|
+
# Multi-word NATURAL-LANGUAGE handling: a literal full-question pattern
|
|
1288
|
+
# never matches a line verbatim, so a non-regex multi-token query is
|
|
1289
|
+
# treated as OR-of-terms (significant tokens only) and ranked by how many
|
|
1290
|
+
# DISTINCT terms a note hits, then total matches. Single-token and
|
|
1291
|
+
# explicit --regex patterns keep exact literal/regex behaviour (so the
|
|
1292
|
+
# tool's precise-pattern contract — and its tests — are unchanged).
|
|
1293
|
+
_STOP = {"the", "a", "an", "of", "to", "is", "are", "was", "were", "what",
|
|
1294
|
+
"which", "who", "and", "or", "for", "on", "in", "about", "que",
|
|
1295
|
+
"qual", "quais", "foi", "sobre", "ele", "ela", "com", "para",
|
|
1296
|
+
"uma", "dos", "das", "no", "na", "em", "se", "de", "do", "da"}
|
|
1297
|
+
terms: list[str] = []
|
|
1298
|
+
if not regex:
|
|
1299
|
+
terms = [t for t in _re.split(r"\W+", pattern, flags=_re.UNICODE)
|
|
1300
|
+
if len(t) > 2 and t.lower() not in _STOP]
|
|
1301
|
+
multi = (not regex) and len(terms) > 1
|
|
1302
|
+
if multi:
|
|
1303
|
+
rxs = [_re.compile(_re.escape(t), flags) for t in terms]
|
|
1304
|
+
else:
|
|
1305
|
+
try:
|
|
1306
|
+
rx = _re.compile(pattern if regex else _re.escape(pattern), flags)
|
|
1307
|
+
except _re.error:
|
|
1308
|
+
rx = _re.compile(_re.escape(pattern), flags)
|
|
1309
|
+
rxs = [rx]
|
|
1310
|
+
rows = self.conn.execute(
|
|
1311
|
+
"SELECT id,title,classification,zone,path,body FROM notes"
|
|
1312
|
+
).fetchall()
|
|
1313
|
+
out: list[dict[str, Any]] = []
|
|
1314
|
+
for r in rows:
|
|
1315
|
+
body = r[5] or ""
|
|
1316
|
+
matches = [ln for ln in body.splitlines()
|
|
1317
|
+
if any(_grep_bounded_search(x, ln) for x in rxs)]
|
|
1318
|
+
if not matches:
|
|
1319
|
+
continue
|
|
1320
|
+
distinct = (
|
|
1321
|
+
sum(1 for x in rxs if any(_grep_bounded_search(x, ln) for ln in matches))
|
|
1322
|
+
if multi else 1
|
|
1323
|
+
)
|
|
1324
|
+
out.append({
|
|
1325
|
+
"id": r[0], "title": r[1], "classification": r[2],
|
|
1326
|
+
"zone": r[3], "path": r[4],
|
|
1327
|
+
"match_count": len(matches),
|
|
1328
|
+
"terms_matched": distinct,
|
|
1329
|
+
"snippet": self._snippet(matches[0]),
|
|
1330
|
+
"source": "grep",
|
|
1331
|
+
})
|
|
1332
|
+
out.sort(key=lambda d: (-d.get("terms_matched", 1), -d["match_count"], d["id"]))
|
|
1333
|
+
return out[:k]
|
|
1334
|
+
|
|
1335
|
+
def bases_query(
|
|
1336
|
+
self,
|
|
1337
|
+
filters: dict[str, str] | None = None,
|
|
1338
|
+
*,
|
|
1339
|
+
k: int = 50,
|
|
1340
|
+
order_by: str = "updated",
|
|
1341
|
+
latest_only: bool = False,
|
|
1342
|
+
as_of: str | None = None,
|
|
1343
|
+
) -> list[dict[str, Any]]:
|
|
1344
|
+
"""Structured frontmatter query (an Obsidian-Bases-style view) over the
|
|
1345
|
+
indexed columns — NO embedding. Filters are exact-match on
|
|
1346
|
+
id/title/type/classification/zone/path; unknown keys are ignored. Returns
|
|
1347
|
+
note-shaped dicts for the CLI egress gate.
|
|
1348
|
+
|
|
1349
|
+
TMP-02 temporal views (ADR-0003 Ruling 2/8 — the Latest Only / As Of
|
|
1350
|
+
Bases):
|
|
1351
|
+
|
|
1352
|
+
- ``latest_only``: excludes any note explicitly retired
|
|
1353
|
+
(``is_latest_version: false``). A note that never entered a
|
|
1354
|
+
supersession chain has no opinion here and is included (it IS the
|
|
1355
|
+
current — only — version of itself).
|
|
1356
|
+
- ``as_of``: an ISO date; returns notes valid AT that date under
|
|
1357
|
+
valid-time semantics — ``effective_date`` if present, else
|
|
1358
|
+
``document_date``, else ``created`` (fallback chain, per the ADR) —
|
|
1359
|
+
excluding anything not yet effective by that date or already
|
|
1360
|
+
superseded by then. Composable with ``latest_only`` (as-of naturally
|
|
1361
|
+
admits a since-superseded note back in, latest_only would then
|
|
1362
|
+
exclude it again — apply them together only if that's the intent;
|
|
1363
|
+
the CLI keeps them as independent flags).
|
|
1364
|
+
"""
|
|
1365
|
+
cols = {"id", "title", "type", "classification", "zone", "path",
|
|
1366
|
+
"created", "updated"}
|
|
1367
|
+
filters = filters or {}
|
|
1368
|
+
where, params = [], []
|
|
1369
|
+
# FALSE POSITIVE (scanner: string-built SQL / hardcoded_sql_expressions):
|
|
1370
|
+
# `key` / `order_col` are only ever interpolated after an explicit
|
|
1371
|
+
# `in cols` allowlist check against the fixed column set above -- an
|
|
1372
|
+
# unrecognised key/order_by is dropped/defaulted, never reaches the SQL
|
|
1373
|
+
# text. Every VALUE (`val`, `k`) is a bound param, never interpolated.
|
|
1374
|
+
# See docs/SECURITY_NOTES.md.
|
|
1375
|
+
for key, val in filters.items():
|
|
1376
|
+
if key in cols:
|
|
1377
|
+
where.append(f"{key} = ?") # nosec B608 - key is allowlisted above
|
|
1378
|
+
params.append(val)
|
|
1379
|
+
if latest_only:
|
|
1380
|
+
where.append("is_latest_version IS NOT 'false'")
|
|
1381
|
+
if as_of:
|
|
1382
|
+
where.append(
|
|
1383
|
+
"COALESCE(NULLIF(effective_date,''), NULLIF(document_date,''), created) <= ?"
|
|
1384
|
+
)
|
|
1385
|
+
params.append(as_of)
|
|
1386
|
+
where.append("(superseded_date IS NULL OR superseded_date = '' OR superseded_date > ?)")
|
|
1387
|
+
params.append(as_of)
|
|
1388
|
+
order_col = order_by if order_by in cols else "updated"
|
|
1389
|
+
sql = (
|
|
1390
|
+
"SELECT id,title,classification,zone,path,type,updated,is_latest_version FROM notes"
|
|
1391
|
+
+ (" WHERE " + " AND ".join(where) if where else "")
|
|
1392
|
+
+ f" ORDER BY {order_col} DESC, id ASC LIMIT ?" # nosec B608 - order_col is allowlisted above
|
|
1393
|
+
)
|
|
1394
|
+
params.append(k)
|
|
1395
|
+
rows = self.conn.execute(sql, params).fetchall()
|
|
1396
|
+
keys = ["id", "title", "classification", "zone", "path", "type", "updated",
|
|
1397
|
+
"is_latest_version"]
|
|
1398
|
+
return [dict(zip(keys, r)) for r in rows]
|
|
1399
|
+
|
|
1400
|
+
def graph_expand(
|
|
1401
|
+
self, seeds: list[str], *, depth: int = 2, k: int = 10, use_ppr: bool = True,
|
|
1402
|
+
extra_edges: list[tuple[str, str]] | None = None,
|
|
1403
|
+
) -> dict[str, Any]:
|
|
1404
|
+
"""On-demand wikilink-BFS + PPR expansion (RET-03). DISCOVERY-ONLY — the
|
|
1405
|
+
derived graph is never authoritative; results carry that flag.
|
|
1406
|
+
``extra_edges`` (GRF-01, optional) folds graphify's INFERRED edges in."""
|
|
1407
|
+
from .graph import graph_expand as _expand
|
|
1408
|
+
|
|
1409
|
+
return _expand(self.conn, seeds, depth=depth, k=k, use_ppr=use_ppr,
|
|
1410
|
+
extra_edges=extra_edges)
|
|
1411
|
+
|
|
1412
|
+
def get(self, note_id: str) -> dict[str, Any] | None:
|
|
1413
|
+
r = self.conn.execute(
|
|
1414
|
+
"SELECT id,title,type,classification,zone,path,created,updated,sha256,body,"
|
|
1415
|
+
"is_latest_version,superseded_by,previous_version,superseded_date"
|
|
1416
|
+
" FROM notes WHERE id=?",
|
|
1417
|
+
(note_id,),
|
|
1418
|
+
).fetchone()
|
|
1419
|
+
if not r:
|
|
1420
|
+
return None
|
|
1421
|
+
keys = ["id", "title", "type", "classification", "zone", "path",
|
|
1422
|
+
"created", "updated", "sha256", "body",
|
|
1423
|
+
"is_latest_version", "superseded_by", "previous_version", "superseded_date"]
|
|
1424
|
+
return dict(zip(keys, r))
|
|
1425
|
+
|
|
1426
|
+
def recent(self, limit: int = 10) -> list[dict[str, Any]]:
|
|
1427
|
+
rows = self.conn.execute(
|
|
1428
|
+
"SELECT id,title,classification,zone,path,updated FROM notes "
|
|
1429
|
+
"ORDER BY updated DESC, id ASC LIMIT ?",
|
|
1430
|
+
(limit,),
|
|
1431
|
+
).fetchall()
|
|
1432
|
+
keys = ["id", "title", "classification", "zone", "path", "updated"]
|
|
1433
|
+
return [dict(zip(keys, r)) for r in rows]
|
|
1434
|
+
|
|
1435
|
+
# -- maintenance: near-dup scan (G1) + unclassified lint --------------
|
|
1436
|
+
def near_dup(self, *, min_score: float = 0.95, k: int = 5) -> list[dict[str, Any]]:
|
|
1437
|
+
"""Corpus-wide near-duplicate scan (brain-cli-gaps.md G1).
|
|
1438
|
+
|
|
1439
|
+
Repoints the old SC-cosine integrity-scan §A directly onto the brain
|
|
1440
|
+
vector backend — no MCP round-trip, no raw pairwise O(n^2) python cosine
|
|
1441
|
+
matrix. For EVERY note, probe the backend ANN index with that note's own
|
|
1442
|
+
representative (first) chunk vector to NOMINATE its ``k`` nearest
|
|
1443
|
+
neighbours, then score each nominated pair by **true cosine** between the
|
|
1444
|
+
two notes' own vectors. Cost is O(n) batched embeds + O(n) backend
|
|
1445
|
+
searches (each sub-linear under sqlite-vec, linear-but-cheap under the
|
|
1446
|
+
brute-force fallback) — NOT the naive O(n^2) the gap doc flagged as the
|
|
1447
|
+
heavy path.
|
|
1448
|
+
|
|
1449
|
+
BACKEND-INDEPENDENT THRESHOLD (the load-bearing detail): the backend's
|
|
1450
|
+
own ``search`` SCORE is metric-dependent — brute-force returns cosine,
|
|
1451
|
+
but sqlite-vec's ``vec0`` returns an L2-derived ``1/(1+d)`` similarity on
|
|
1452
|
+
a DIFFERENT scale (the same near-dup pair scores ~0.96 cosine vs ~0.79
|
|
1453
|
+
under vec0). A ``min_score`` like 0.95 would mean two different things
|
|
1454
|
+
across backends. So we use ``search`` ONLY to nominate candidates and
|
|
1455
|
+
recompute the actual pair score as cosine over the embedder vectors
|
|
1456
|
+
(which are L2-normalised), making ``min_score`` mean the same cosine
|
|
1457
|
+
threshold on EVERY backend.
|
|
1458
|
+
|
|
1459
|
+
UNFILTERED (note-shaped, by id) — the CLI egress-gates BOTH members of
|
|
1460
|
+
every pair before surfacing (G1's explicit requirement)."""
|
|
1461
|
+
from .chunk import Chunk
|
|
1462
|
+
from .vectors import cosine
|
|
1463
|
+
|
|
1464
|
+
c = self.conn
|
|
1465
|
+
rows = c.execute(
|
|
1466
|
+
"SELECT n.rowid, n.id, n.title, n.zone, c.heading, c.lang, c.text "
|
|
1467
|
+
"FROM notes n JOIN chunks c ON c.note_rowid = n.rowid AND c.ordinal = 0"
|
|
1468
|
+
).fetchall()
|
|
1469
|
+
if len(rows) < 2:
|
|
1470
|
+
return []
|
|
1471
|
+
rowid_to_id = {int(r[0]): r[1] for r in rows}
|
|
1472
|
+
# Re-derive the EXACT representation each chunk was STORED with — the
|
|
1473
|
+
# contextual-prefix + chunk text ``embed_input`` (IDX-02) — so the probe
|
|
1474
|
+
# vector is apples-to-apples comparable to the stored passage vectors.
|
|
1475
|
+
# Probing with the bare chunk text alone (no prefix) under-measures
|
|
1476
|
+
# similarity by a wide margin (the prefix tokens dominate a short
|
|
1477
|
+
# chunk's bag-of-tokens) and uses symmetric is_query=False ("passage:")
|
|
1478
|
+
# encoding throughout — near-dup is a passage<->passage comparison, not
|
|
1479
|
+
# a query->passage retrieval, so the asymmetric query prefix a
|
|
1480
|
+
# search()-style probe would use is the wrong encoding here.
|
|
1481
|
+
texts = [
|
|
1482
|
+
Chunk(ordinal=0, heading=r[4] or "", text=r[6] or "", lang=r[5] or "en")
|
|
1483
|
+
.embed_input(r[2] or r[1], r[3] or "", "")
|
|
1484
|
+
for r in rows
|
|
1485
|
+
]
|
|
1486
|
+
vecs = self.embedder.embed_batch(texts, is_query=False)
|
|
1487
|
+
vec_by_note: dict[int, list[float]] = {
|
|
1488
|
+
int(r[0]): v for r, v in zip(rows, vecs)
|
|
1489
|
+
}
|
|
1490
|
+
# chunk rowid (ordinal=0) -> owning note rowid, for mapping search hits back.
|
|
1491
|
+
chunk_to_note: dict[int, int] = {
|
|
1492
|
+
int(crid): int(nrid)
|
|
1493
|
+
for nrid, crid in c.execute(
|
|
1494
|
+
"SELECT note_rowid, rowid FROM chunks WHERE ordinal = 0"
|
|
1495
|
+
).fetchall()
|
|
1496
|
+
}
|
|
1497
|
+
best: dict[tuple[str, str], float] = {}
|
|
1498
|
+
for (note_rowid, *_rest), vec in zip(rows, vecs):
|
|
1499
|
+
for hit_chunk_rowid, _backend_score in self.backend.search(c, vec, k + 1):
|
|
1500
|
+
other_rowid = chunk_to_note.get(int(hit_chunk_rowid))
|
|
1501
|
+
if other_rowid is None or other_rowid == note_rowid:
|
|
1502
|
+
continue
|
|
1503
|
+
# Recompute the pair score as TRUE cosine (backend-independent).
|
|
1504
|
+
score = cosine(vec, vec_by_note[other_rowid])
|
|
1505
|
+
if score < min_score:
|
|
1506
|
+
continue
|
|
1507
|
+
a, b = rowid_to_id[note_rowid], rowid_to_id[other_rowid]
|
|
1508
|
+
key = (a, b) if a < b else (b, a)
|
|
1509
|
+
if score > best.get(key, -1.0):
|
|
1510
|
+
best[key] = score
|
|
1511
|
+
|
|
1512
|
+
out: list[dict[str, Any]] = []
|
|
1513
|
+
for (a_id, b_id), score in best.items():
|
|
1514
|
+
a_row, b_row = self._note_row(self._rowid_of(a_id)), self._note_row(self._rowid_of(b_id))
|
|
1515
|
+
if not a_row or not b_row:
|
|
1516
|
+
continue
|
|
1517
|
+
out.append({
|
|
1518
|
+
"a": {"id": a_row["id"], "title": a_row["title"],
|
|
1519
|
+
"classification": a_row["classification"], "zone": a_row["zone"],
|
|
1520
|
+
"path": a_row["path"]},
|
|
1521
|
+
"b": {"id": b_row["id"], "title": b_row["title"],
|
|
1522
|
+
"classification": b_row["classification"], "zone": b_row["zone"],
|
|
1523
|
+
"path": b_row["path"]},
|
|
1524
|
+
"score": round(score, 6),
|
|
1525
|
+
})
|
|
1526
|
+
out.sort(key=lambda d: -d["score"])
|
|
1527
|
+
return out
|
|
1528
|
+
|
|
1529
|
+
def stale_wikilink_targets(self) -> list[dict[str, Any]]:
|
|
1530
|
+
"""Wikilinks whose target vanished or moved to ``archive/`` (AUT-02,
|
|
1531
|
+
curation Sunday fold). Reuses the ``graph`` module's derived graph —
|
|
1532
|
+
DISCOVERY-ONLY, UNFILTERED (the CLI egress-gates before surfacing)."""
|
|
1533
|
+
from .graph import stale_wikilink_targets as _stale
|
|
1534
|
+
|
|
1535
|
+
return _stale(self.conn)
|
|
1536
|
+
|
|
1537
|
+
def revisit_sample(self, *, today: Any = None, k: int = 10) -> list[dict[str, Any]]:
|
|
1538
|
+
"""Staleness revisit sample ranked by age x whole-corpus PageRank
|
|
1539
|
+
centrality (AUT-02, curation Sunday fold). UNFILTERED — the CLI
|
|
1540
|
+
egress-gates before surfacing."""
|
|
1541
|
+
import datetime as _dt
|
|
1542
|
+
|
|
1543
|
+
from .graph import revisit_sample as _revisit
|
|
1544
|
+
|
|
1545
|
+
return _revisit(self.conn, today or _dt.date.today(), k=k)
|
|
1546
|
+
|
|
1547
|
+
def unclassified_notes(self, *, k: int = 100) -> list[dict[str, Any]]:
|
|
1548
|
+
"""Notes whose ``classification`` is missing/empty or not a recognised
|
|
1549
|
+
tier — the curation-lint default-deny finding (no wikilink-graph orphan
|
|
1550
|
+
detection here; that stays vault-overlay tooling, see G4 / task-disposition
|
|
1551
|
+
row 4). UNFILTERED — note-shaped for the CLI egress gate."""
|
|
1552
|
+
from . import classification as cls
|
|
1553
|
+
|
|
1554
|
+
rows = self.conn.execute(
|
|
1555
|
+
"SELECT id,title,classification,zone,path FROM notes ORDER BY id"
|
|
1556
|
+
).fetchall()
|
|
1557
|
+
out = []
|
|
1558
|
+
for r in rows:
|
|
1559
|
+
if cls.is_default_denied(r[2]):
|
|
1560
|
+
out.append({"id": r[0], "title": r[1], "classification": r[2],
|
|
1561
|
+
"zone": r[3], "path": r[4]})
|
|
1562
|
+
return out[:k]
|
|
1563
|
+
|
|
1564
|
+
def stats(self) -> dict[str, Any]:
|
|
1565
|
+
c = self.conn
|
|
1566
|
+
notes = int(c.execute("SELECT COUNT(*) FROM notes").fetchone()[0])
|
|
1567
|
+
chunks = int(c.execute("SELECT COUNT(*) FROM chunks").fetchone()[0])
|
|
1568
|
+
return {
|
|
1569
|
+
"notes": notes,
|
|
1570
|
+
"chunks": chunks,
|
|
1571
|
+
"schema_version": self.get_meta("schema_version"),
|
|
1572
|
+
"vector_backend": self.get_meta("vector_backend"),
|
|
1573
|
+
"embed_model": self.get_meta("embed_model"),
|
|
1574
|
+
"embed_dim": self.get_meta("embed_dim"),
|
|
1575
|
+
"db": str(self.db_path),
|
|
1576
|
+
}
|