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/mcp_adapter.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""OPTIONAL, DELETABLE ~50-line MCP adapter for the pure Claude Desktop CHAT tab.
|
|
2
|
+
|
|
3
|
+
This is the ONE surface that cannot run a shell command — so it gets a thin MCP
|
|
4
|
+
bridge. Every OTHER harness (Codex, Claude Code, Gemini CLI, the Desktop Code
|
|
5
|
+
tab, the Cowork VM) calls the ``brain`` CLI directly; MCP is NEVER the
|
|
6
|
+
foundation. Delete this file and nothing else breaks.
|
|
7
|
+
|
|
8
|
+
The bridge wraps the SAME ``BrainCore`` + the SAME deny-by-default
|
|
9
|
+
``ClassificationFilter`` the CLI applies at stdout — there is no second egress
|
|
10
|
+
path to keep in sync. It exposes ONLY the read verbs (``search`` / ``get`` /
|
|
11
|
+
``recent``); it never exposes write/draft/host-broker commands. ``dispatch`` is
|
|
12
|
+
the pure, importable, testable core; ``serve`` is the (optional) stdio transport
|
|
13
|
+
glue requiring ``pip install 'brainiac-cli[mcp]'``.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from . import classification as cls
|
|
21
|
+
from . import egress
|
|
22
|
+
from .core import BrainCore
|
|
23
|
+
|
|
24
|
+
READ_TOOLS = ("search", "get", "recent", "bases_query", "dossier")
|
|
25
|
+
|
|
26
|
+
# Server-side egress ceiling (SEC-01 hardening). A caller-supplied ``max_tier``
|
|
27
|
+
# was previously honored unbounded — an MCP client could simply ASK for
|
|
28
|
+
# ``max_tier="MNPI"`` and receive it. That is a human-gated elevation on the
|
|
29
|
+
# CLI (an explicit ``--max-tier`` flag someone typed), but the MCP transport has
|
|
30
|
+
# no equivalent "a person is watching this" signal, so the adapter now clamps
|
|
31
|
+
# EVERY request to a ceiling the operator configures out-of-band. A caller may
|
|
32
|
+
# still request something NARROWER than the ceiling (always honored); it can
|
|
33
|
+
# never request higher.
|
|
34
|
+
EGRESS_CEILING_ENV_VAR = "BRAIN_MAX_EGRESS_TIER"
|
|
35
|
+
DEFAULT_EGRESS_CEILING_TIER = "MNPI" # matches cls.DEFAULT_MAX_TIER: the MCP
|
|
36
|
+
# adapter is a HOST-side surface (Chat tab / Cowork MCP-on-host), so it gets
|
|
37
|
+
# the same full-vault default as the host CLI (owner decision, 2026-07-10).
|
|
38
|
+
# Operators who want a hard server-side cap set $BRAIN_MAX_EGRESS_TIER; the
|
|
39
|
+
# clamp mechanism below is unchanged and still narrows every request to it.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _egress_ceiling_tier() -> str:
|
|
43
|
+
"""The operator-configured hard ceiling for MCP egress. Unset or an
|
|
44
|
+
unrecognised value falls back to the conservative default — fail-closed,
|
|
45
|
+
never fail-open on a typo'd env var."""
|
|
46
|
+
raw = os.environ.get(EGRESS_CEILING_ENV_VAR, DEFAULT_EGRESS_CEILING_TIER).strip()
|
|
47
|
+
return raw if raw in cls.RANK else DEFAULT_EGRESS_CEILING_TIER
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _clamp_max_tier(requested_tier: str) -> str:
|
|
51
|
+
"""Clamp a caller-supplied ``max_tier`` to ``min(requested_rank, ceiling_rank)``.
|
|
52
|
+
|
|
53
|
+
An unrecognised ``requested_tier`` is passed through UNCHANGED so the
|
|
54
|
+
existing ``ClassificationFilter`` validation (``egress.apply_gate`` ->
|
|
55
|
+
``cls.ClassificationFilter.__post_init__``) still raises its normal, clear
|
|
56
|
+
"unknown max_tier" error — this clamp only ever narrows a VALID request, it
|
|
57
|
+
never manufactures or swallows a validation error.
|
|
58
|
+
"""
|
|
59
|
+
requested = requested_tier.strip()
|
|
60
|
+
if requested not in cls.RANK:
|
|
61
|
+
return requested_tier
|
|
62
|
+
ceiling_rank = cls.RANK[_egress_ceiling_tier()]
|
|
63
|
+
clamped_rank = min(cls.RANK[requested], ceiling_rank)
|
|
64
|
+
return cls.TIERS[clamped_rank]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _filtered(items: list[dict], max_tier: str) -> tuple[list[dict], dict]:
|
|
68
|
+
# Same single egress chokepoint as the CLI (SEC-01) — no second egress path.
|
|
69
|
+
return egress.apply_gate(items, max_tier)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def dispatch(tool: str, args: dict[str, Any], *, core: BrainCore | None = None,
|
|
73
|
+
vault: str | None = None) -> dict[str, Any]:
|
|
74
|
+
"""Run one read tool through the SAME egress gate as the CLI. Pure + testable.
|
|
75
|
+
|
|
76
|
+
``max_tier`` is clamped server-side to the ``BRAIN_MAX_EGRESS_TIER`` ceiling
|
|
77
|
+
(default "Internal") BEFORE it reaches the classification filter — a caller
|
|
78
|
+
cannot self-elevate past the configured ceiling by simply asking.
|
|
79
|
+
"""
|
|
80
|
+
core = core or BrainCore(vault=vault) # host or vm; reads only either way
|
|
81
|
+
max_tier = _clamp_max_tier(str(args.get("max_tier", cls.DEFAULT_MAX_TIER)))
|
|
82
|
+
if tool in ("search", "hybrid-search"):
|
|
83
|
+
hits = [h.to_dict() for h in core.hybrid_search(str(args["query"]), k=int(args.get("k", 10)))]
|
|
84
|
+
surfaced, report = _filtered(hits, max_tier)
|
|
85
|
+
out: dict[str, Any] = {"results": surfaced, "egress": report}
|
|
86
|
+
# RET-09 freshness signal — same contract as the CLI (see
|
|
87
|
+
# cli._freshness_block): tells the agent when the vault continues
|
|
88
|
+
# past its newest hit, so "latest/current" answers don't silently
|
|
89
|
+
# ground on stale-but-coherent material.
|
|
90
|
+
dates = [h.get("date", "") for h in surfaced if h.get("date")]
|
|
91
|
+
if dates:
|
|
92
|
+
try:
|
|
93
|
+
fresh = core.source_freshness(max(dates), max_tier)
|
|
94
|
+
except Exception: # noqa: BLE001 — freshness must never break search
|
|
95
|
+
fresh = None
|
|
96
|
+
if fresh and fresh.get("newer_count", 0) > 0:
|
|
97
|
+
fresh["hint"] = (
|
|
98
|
+
f"{fresh['newer_count']} note(s)/source(s) are newer than your "
|
|
99
|
+
f"newest hit ({fresh['newest_hit_date']}; vault newest "
|
|
100
|
+
f"{fresh['vault_newest']}) — for 'latest/current' questions, "
|
|
101
|
+
f"probe past these hits (recent, bases_query latest_only=True, "
|
|
102
|
+
f"or a narrower search) before treating this as current.")
|
|
103
|
+
if fresh:
|
|
104
|
+
out["freshness"] = fresh
|
|
105
|
+
return out
|
|
106
|
+
if tool in ("get", "read"):
|
|
107
|
+
note = core.get(str(args["id"]))
|
|
108
|
+
surfaced, report = _filtered([note] if note else [], max_tier)
|
|
109
|
+
return {"result": surfaced[0] if surfaced else None, "egress": report}
|
|
110
|
+
if tool == "recent":
|
|
111
|
+
surfaced, report = _filtered(core.recent(limit=int(args.get("n", 10))), max_tier)
|
|
112
|
+
return {"results": surfaced, "egress": report}
|
|
113
|
+
if tool == "dossier":
|
|
114
|
+
res = core.dossier(str(args["query"]), k=int(args.get("k", 12)))
|
|
115
|
+
decisions, drep = _filtered(res["decisions"], max_tier)
|
|
116
|
+
sources, srep = _filtered(res["sources"], max_tier)
|
|
117
|
+
# Merge the two egress reports by NAMED keys — a naive comprehension
|
|
118
|
+
# KeyErrors on conditional keys (casing_mismatch_warnings appears
|
|
119
|
+
# only when a wrong-case tier exists in that half).
|
|
120
|
+
report: dict[str, Any] = {
|
|
121
|
+
k2: drep[k2] + srep[k2]
|
|
122
|
+
for k2 in ("total", "surfaced", "withheld",
|
|
123
|
+
"withheld_unlabelled_default_deny")
|
|
124
|
+
}
|
|
125
|
+
report["max_tier"] = drep["max_tier"]
|
|
126
|
+
casing = sorted(set(drep.get("casing_mismatch_warnings", []))
|
|
127
|
+
| set(srep.get("casing_mismatch_warnings", [])))
|
|
128
|
+
if casing:
|
|
129
|
+
report["casing_mismatch_warnings"] = casing
|
|
130
|
+
return {"query": res["query"], "decisions": decisions,
|
|
131
|
+
"sources": sources,
|
|
132
|
+
"retired_excluded": res["retired_excluded"], "egress": report}
|
|
133
|
+
if tool in ("bases-query", "bases_query"):
|
|
134
|
+
filters = dict(args.get("where") or {})
|
|
135
|
+
items = core.bases_query(
|
|
136
|
+
filters, k=int(args.get("k", 50)),
|
|
137
|
+
latest_only=bool(args.get("latest_only", False)),
|
|
138
|
+
as_of=args.get("as_of") or None)
|
|
139
|
+
surfaced, report = _filtered(items, max_tier)
|
|
140
|
+
return {"results": surfaced, "egress": report}
|
|
141
|
+
raise ValueError(f"unknown / non-read tool {tool!r}; MCP adapter exposes only {READ_TOOLS}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def serve(vault: str | None = None) -> None: # pragma: no cover - transport glue
|
|
145
|
+
"""Optional stdio MCP server for the Chat tab. Requires the `mcp` package."""
|
|
146
|
+
from mcp.server.fastmcp import FastMCP
|
|
147
|
+
|
|
148
|
+
core = BrainCore(vault=vault)
|
|
149
|
+
server = FastMCP("brain")
|
|
150
|
+
|
|
151
|
+
@server.tool()
|
|
152
|
+
def search(query: str, k: int = 10, max_tier: str = cls.DEFAULT_MAX_TIER) -> dict:
|
|
153
|
+
"""Hybrid (BM25+dense) retrieval over the vault, egress-filtered.
|
|
154
|
+
|
|
155
|
+
Every hit carries `date` (its valid time), `is_latest_version`, and
|
|
156
|
+
`type` — the AUTHORITY signal: a `type: decision` hit IS the
|
|
157
|
+
recorded decision layer; a `type: source` hit (memos, decks,
|
|
158
|
+
drafts) is material under consideration and NEVER overturns a
|
|
159
|
+
decision on its own — if a newer source conflicts with a decision
|
|
160
|
+
note, report the tension, don't promote the proposal. READ THE
|
|
161
|
+
`freshness` BLOCK: when it reports sources newer than your newest
|
|
162
|
+
hit, the vault continues past what you just retrieved — for
|
|
163
|
+
'latest/current' questions, probe further (recent, bases_query with
|
|
164
|
+
latest_only=True, or a narrower search) before treating the result
|
|
165
|
+
as the current state. If `egress.hint` reports withheld notes,
|
|
166
|
+
re-call with a higher max_tier instead of concluding the vault is
|
|
167
|
+
empty. Curated notes (zone brain/) are synthesis; zone raw/ holds
|
|
168
|
+
the newest unprocessed sources — check both for recency-sensitive
|
|
169
|
+
questions."""
|
|
170
|
+
return dispatch("search", {"query": query, "k": k, "max_tier": max_tier}, core=core)
|
|
171
|
+
|
|
172
|
+
@server.tool()
|
|
173
|
+
def get(id: str, max_tier: str = cls.DEFAULT_MAX_TIER) -> dict:
|
|
174
|
+
"""Fetch one full note by id, egress-filtered. Inspect
|
|
175
|
+
`superseded_by` / `previous_version` / `is_latest_version` on the
|
|
176
|
+
result to walk a version chain ("previous version", "what replaced
|
|
177
|
+
this")."""
|
|
178
|
+
return dispatch("get", {"id": id, "max_tier": max_tier}, core=core)
|
|
179
|
+
|
|
180
|
+
@server.tool()
|
|
181
|
+
def recent(n: int = 10, max_tier: str = cls.DEFAULT_MAX_TIER) -> dict:
|
|
182
|
+
"""List the most recently created/updated notes, egress-filtered —
|
|
183
|
+
the cheapest way to see what entered the vault lately (use after a
|
|
184
|
+
search whose freshness block reported newer sources)."""
|
|
185
|
+
return dispatch("recent", {"n": n, "max_tier": max_tier}, core=core)
|
|
186
|
+
|
|
187
|
+
@server.tool()
|
|
188
|
+
def dossier(query: str, k: int = 12, max_tier: str = cls.DEFAULT_MAX_TIER) -> dict:
|
|
189
|
+
"""THE ONE-CALL SWEEP for decision-state questions ("what have we
|
|
190
|
+
decided", "latest decisions", "current state of X"). Returns the
|
|
191
|
+
decision layer and the sources under consideration SEPARATED, with
|
|
192
|
+
each decision carrying a `tensions` list — newer sources that
|
|
193
|
+
post-date it (report the tension, never promote the proposal) —
|
|
194
|
+
and retired versions already excluded. Prefer this over plain
|
|
195
|
+
search for decision-state questions; fall back to search/get for
|
|
196
|
+
everything else."""
|
|
197
|
+
return dispatch("dossier", {"query": query, "k": k, "max_tier": max_tier}, core=core)
|
|
198
|
+
|
|
199
|
+
@server.tool()
|
|
200
|
+
def bases_query(where: dict | None = None, k: int = 50,
|
|
201
|
+
latest_only: bool = False, as_of: str = "",
|
|
202
|
+
max_tier: str = cls.DEFAULT_MAX_TIER) -> dict:
|
|
203
|
+
"""Structured frontmatter query (no embedding), egress-filtered.
|
|
204
|
+
`where` filters exact frontmatter keys (e.g. {"type": "decision"}).
|
|
205
|
+
TEMPORAL ROUTING: for "what's current/latest" use latest_only=True
|
|
206
|
+
(excludes superseded notes); for "as of <date>" pass
|
|
207
|
+
as_of="YYYY-MM-DD" (point-in-time view). Prefer this over semantic
|
|
208
|
+
search when the question is really about time or note metadata."""
|
|
209
|
+
return dispatch("bases_query", {
|
|
210
|
+
"where": where or {}, "k": k, "latest_only": latest_only,
|
|
211
|
+
"as_of": as_of or None, "max_tier": max_tier}, core=core)
|
|
212
|
+
|
|
213
|
+
server.run()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__": # pragma: no cover
|
|
217
|
+
serve()
|
brain/multihop.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Graph-augmented multi-hop retrieval (RET-06).
|
|
2
|
+
|
|
3
|
+
Flat top-k retrieval answers "which single note is most similar to this query".
|
|
4
|
+
A *multi-hop* question — "how does Alex Silva's exit connect to the org-transition
|
|
5
|
+
programme", "Contoso runs S/4HANA while Northwind runs a different SAP estate, what
|
|
6
|
+
does the JV do" — asks about a RELATIONSHIP between two or more named entities.
|
|
7
|
+
The relevant evidence is often spread across several notes linked by
|
|
8
|
+
``[[wikilinks]]``; no single note is top-k-similar to the whole question, so
|
|
9
|
+
flat dense/lexical retrieval misses the corroborating notes in the tail.
|
|
10
|
+
|
|
11
|
+
This module adds a *gated* graph-expansion step on top of ``hybrid_search``:
|
|
12
|
+
|
|
13
|
+
1. **Gate (query-intrinsic).** Detect whether the query is "multi-hop-shaped"
|
|
14
|
+
— it names **>= 2 distinct entities** (people / companies / decisions /
|
|
15
|
+
projects / concepts) that exist as notes. A ubiquitous hub entity
|
|
16
|
+
(``contoso`` — mentioned by almost every query in this corpus) does NOT count
|
|
17
|
+
toward the threshold, though it may still seed the walk. Single-hop
|
|
18
|
+
queries fail the gate and are returned by flat ``hybrid_search`` UNCHANGED
|
|
19
|
+
— so single-hop latency and quality can never regress (the graph code is
|
|
20
|
+
never even reached).
|
|
21
|
+
|
|
22
|
+
2. **Seed.** Seeds = the entity notes named in the query, UNION the top few
|
|
23
|
+
flat hits (the notes flat retrieval already judged relevant).
|
|
24
|
+
|
|
25
|
+
3. **Expand.** Wikilink-BFS + Personalized-PageRank from the seeds
|
|
26
|
+
(``brain.graph``) — DISCOVERY-ONLY (RET-03): the derived graph is never
|
|
27
|
+
authoritative, it only nominates candidate note ids.
|
|
28
|
+
|
|
29
|
+
4. **Re-rank (flat-dominant weighted RRF).** Fuse the flat ranking with the
|
|
30
|
+
graph ranking. The flat list carries weight 1.0; the graph list a smaller
|
|
31
|
+
``graph_weight`` (<= 1). This is deliberately conservative: the top flat
|
|
32
|
+
hit can never be displaced by a discovery-only candidate, and a graph-only
|
|
33
|
+
note (one flat never retrieved) lands in the tail — but a note that flat
|
|
34
|
+
retrieved *weakly* (rank 11-30) AND the graph corroborates gets promoted
|
|
35
|
+
into the top-k. That promotion of a graph-corroborated flat-tail note is
|
|
36
|
+
the multi-hop recall/nDCG mechanism.
|
|
37
|
+
|
|
38
|
+
The graph and the entity lexicon are built once and cached on the owning index
|
|
39
|
+
(both are derived from the immutable ``notes`` table), so the per-query cost of
|
|
40
|
+
the multi-hop path is a walk over an in-memory graph, and the per-query cost of
|
|
41
|
+
the single-hop path is one cheap regex scan over ~250 entity surface forms.
|
|
42
|
+
"""
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import re
|
|
46
|
+
import sqlite3
|
|
47
|
+
import unicodedata
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
|
|
50
|
+
from .graph import (
|
|
51
|
+
LinkGraph,
|
|
52
|
+
personalized_pagerank,
|
|
53
|
+
wikilink_bfs,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Entity-typed notes whose titles are the multi-hop "hop" vocabulary. These are
|
|
57
|
+
# the ``type:`` frontmatter values the projection assigns to People / Companies
|
|
58
|
+
# / Decisions / Projects / Concepts notes.
|
|
59
|
+
ENTITY_TYPES: tuple[str, ...] = ("person", "company", "decision", "project", "concept")
|
|
60
|
+
|
|
61
|
+
# Ubiquitous hub entities: mentioned by nearly every query in this corpus, so
|
|
62
|
+
# they are not DISCRIMINATING evidence of multi-hop intent. They may still seed
|
|
63
|
+
# the graph walk, but they do not count toward the >= 2 gate.
|
|
64
|
+
HUB_STOPLIST: frozenset[str] = frozenset({"contoso"})
|
|
65
|
+
|
|
66
|
+
# Minimum surface-form length — avoids matching 2-3 char acronyms that collide
|
|
67
|
+
# with common substrings (word-boundary anchored, but still noisy when short).
|
|
68
|
+
_MIN_FORM_LEN = 4
|
|
69
|
+
|
|
70
|
+
# Generic single-word concept titles that are too common to be entity evidence.
|
|
71
|
+
_GENERIC_FORMS: frozenset[str] = frozenset(
|
|
72
|
+
{"governance", "legal", "budget", "roadmap", "strategy", "security",
|
|
73
|
+
"compliance", "risk", "data", "cloud", "digital"}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _norm(s: str | None) -> str:
|
|
78
|
+
return unicodedata.normalize("NFC", (s or "")).lower().strip()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class EntityMention:
|
|
83
|
+
note_id: str
|
|
84
|
+
surface: str
|
|
85
|
+
etype: str
|
|
86
|
+
is_hub: bool
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class EntityLexicon:
|
|
90
|
+
"""Query -> entity-note mentions, built from the entity-typed notes.
|
|
91
|
+
|
|
92
|
+
Cheap to query (regex scan over the compiled surface forms), built once."""
|
|
93
|
+
|
|
94
|
+
__slots__ = ("_forms",)
|
|
95
|
+
|
|
96
|
+
def __init__(self, forms: list[tuple[re.Pattern[str], str, str, str, bool]]):
|
|
97
|
+
self._forms = forms
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def build(cls, conn: sqlite3.Connection) -> "EntityLexicon":
|
|
101
|
+
placeholders = ",".join("?" * len(ENTITY_TYPES))
|
|
102
|
+
rows = conn.execute(
|
|
103
|
+
f"SELECT id, title, type FROM notes WHERE type IN ({placeholders})",
|
|
104
|
+
ENTITY_TYPES,
|
|
105
|
+
).fetchall()
|
|
106
|
+
forms: list[tuple[re.Pattern[str], str, str, str, bool]] = []
|
|
107
|
+
for nid, title, etype in rows:
|
|
108
|
+
surface = _norm(title)
|
|
109
|
+
if len(surface) < _MIN_FORM_LEN or surface in _GENERIC_FORMS:
|
|
110
|
+
continue
|
|
111
|
+
pat = re.compile(r"(?<!\w)" + re.escape(surface) + r"(?!\w)")
|
|
112
|
+
forms.append((pat, nid, surface, etype, surface in HUB_STOPLIST))
|
|
113
|
+
# Longest surface first so a specific "s/4hana" is preferred over a bare
|
|
114
|
+
# substring when both would match (mentions() de-dups by note id anyway).
|
|
115
|
+
forms.sort(key=lambda t: -len(t[2]))
|
|
116
|
+
return cls(forms)
|
|
117
|
+
|
|
118
|
+
def mentions(self, query: str) -> list[EntityMention]:
|
|
119
|
+
"""Distinct entity notes whose surface form appears in the query."""
|
|
120
|
+
q = _norm(query)
|
|
121
|
+
seen: dict[str, EntityMention] = {}
|
|
122
|
+
for pat, nid, surface, etype, is_hub in self._forms:
|
|
123
|
+
if nid in seen:
|
|
124
|
+
continue
|
|
125
|
+
if pat.search(q):
|
|
126
|
+
seen[nid] = EntityMention(nid, surface, etype, is_hub)
|
|
127
|
+
return list(seen.values())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_multihop_shaped(mentions: list[EntityMention]) -> bool:
|
|
131
|
+
"""True iff the query names >= 2 distinct NON-HUB entity notes.
|
|
132
|
+
|
|
133
|
+
This is the deployable, query-intrinsic gate: a multi-hop question is one
|
|
134
|
+
that references at least two specific entities whose relationship the graph
|
|
135
|
+
can traverse. Hub entities (``contoso``) are excluded from the count so that
|
|
136
|
+
"<hub> + one incidental entity" single-hop queries do not trip the gate."""
|
|
137
|
+
non_hub = {m.note_id for m in mentions if not m.is_hub}
|
|
138
|
+
return len(non_hub) >= 2
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def rank_graph_candidates(
|
|
142
|
+
graph: LinkGraph,
|
|
143
|
+
seeds: list[str],
|
|
144
|
+
*,
|
|
145
|
+
depth: int = 2,
|
|
146
|
+
limit: int = 30,
|
|
147
|
+
) -> list[str]:
|
|
148
|
+
"""Ordered discovery-only candidate note ids from a PREBUILT graph.
|
|
149
|
+
|
|
150
|
+
Mirrors ``graph.graph_expand`` but takes an already-built graph so the eval
|
|
151
|
+
harness can reuse one graph across every query. Ranks by
|
|
152
|
+
``(ppr desc, hops asc, id)`` and drops the seeds themselves."""
|
|
153
|
+
known = [s for s in dict.fromkeys(seeds) if s in graph.nodes]
|
|
154
|
+
if not known:
|
|
155
|
+
return []
|
|
156
|
+
bfs = wikilink_bfs(graph, known, depth=depth)
|
|
157
|
+
hops = {d["id"]: d["hops"] for d in bfs}
|
|
158
|
+
ppr = personalized_pagerank(graph, known)
|
|
159
|
+
seed_set = set(known)
|
|
160
|
+
cands = (set(hops) | {n for n, s in ppr.items() if s > 0.0}) - seed_set
|
|
161
|
+
ranked = sorted(cands, key=lambda n: (-ppr.get(n, 0.0), hops.get(n, 99), n))
|
|
162
|
+
return ranked[:limit]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def fuse_flat_and_graph(
|
|
166
|
+
flat_ids: list[str],
|
|
167
|
+
graph_ids: list[str],
|
|
168
|
+
*,
|
|
169
|
+
rrf_k: int = 60,
|
|
170
|
+
graph_weight: float = 0.5,
|
|
171
|
+
) -> list[tuple[str, float]]:
|
|
172
|
+
"""Flat-dominant weighted Reciprocal Rank Fusion.
|
|
173
|
+
|
|
174
|
+
``score(id) = 1/(rrf_k + rank_flat) + graph_weight * 1/(rrf_k + rank_graph)``
|
|
175
|
+
|
|
176
|
+
With ``graph_weight <= 1`` the flat list dominates: the flat rank-1 note
|
|
177
|
+
(contribution ``1/(rrf_k+1)``) can never be overtaken by a graph-ONLY note
|
|
178
|
+
(max contribution ``graph_weight/(rrf_k+1)``). A note present in BOTH lists
|
|
179
|
+
accumulates from both and is promoted. Returns ``[(id, score)]`` descending;
|
|
180
|
+
ties broken by flat order (stable)."""
|
|
181
|
+
scores: dict[str, float] = {}
|
|
182
|
+
order: dict[str, int] = {}
|
|
183
|
+
for rank, nid in enumerate(flat_ids, start=1):
|
|
184
|
+
scores[nid] = scores.get(nid, 0.0) + 1.0 / (rrf_k + rank)
|
|
185
|
+
order.setdefault(nid, rank)
|
|
186
|
+
for rank, nid in enumerate(graph_ids, start=1):
|
|
187
|
+
scores[nid] = scores.get(nid, 0.0) + graph_weight * (1.0 / (rrf_k + rank))
|
|
188
|
+
order.setdefault(nid, 10_000 + rank)
|
|
189
|
+
return sorted(scores.items(), key=lambda kv: (-kv[1], order[kv[0]]))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def graph_augmented_ranking(
|
|
193
|
+
query: str,
|
|
194
|
+
flat_ids: list[str],
|
|
195
|
+
lexicon: EntityLexicon,
|
|
196
|
+
graph: LinkGraph,
|
|
197
|
+
*,
|
|
198
|
+
depth: int = 2,
|
|
199
|
+
graph_weight: float = 0.5,
|
|
200
|
+
rrf_k: int = 60,
|
|
201
|
+
seed_flat_top: int = 3,
|
|
202
|
+
candidate_limit: int = 30,
|
|
203
|
+
) -> tuple[bool, list[str], dict]:
|
|
204
|
+
"""Return ``(fired, ranked_ids, trace)``.
|
|
205
|
+
|
|
206
|
+
``fired`` is False for single-hop queries — then ``ranked_ids == flat_ids``
|
|
207
|
+
unchanged. When fired, seeds = named entities UNION the top ``seed_flat_top``
|
|
208
|
+
flat hits; graph candidates are fused into the flat ranking (flat-dominant)."""
|
|
209
|
+
mentions = lexicon.mentions(query)
|
|
210
|
+
fired = is_multihop_shaped(mentions)
|
|
211
|
+
trace: dict = {
|
|
212
|
+
"fired": fired,
|
|
213
|
+
"entities": [m.surface for m in mentions],
|
|
214
|
+
"non_hub_entities": [m.surface for m in mentions if not m.is_hub],
|
|
215
|
+
}
|
|
216
|
+
if not fired:
|
|
217
|
+
return False, list(flat_ids), trace
|
|
218
|
+
|
|
219
|
+
entity_seeds = [m.note_id for m in mentions]
|
|
220
|
+
seeds = list(dict.fromkeys(entity_seeds + flat_ids[:seed_flat_top]))
|
|
221
|
+
graph_ids = rank_graph_candidates(
|
|
222
|
+
graph, seeds, depth=depth, limit=candidate_limit
|
|
223
|
+
)
|
|
224
|
+
trace["seeds"] = seeds
|
|
225
|
+
trace["graph_candidates"] = graph_ids
|
|
226
|
+
if not graph_ids:
|
|
227
|
+
# No reachable neighbourhood — nothing to fuse; flat order stands.
|
|
228
|
+
return True, list(flat_ids), trace
|
|
229
|
+
fused = fuse_flat_and_graph(
|
|
230
|
+
flat_ids, graph_ids, rrf_k=rrf_k, graph_weight=graph_weight
|
|
231
|
+
)
|
|
232
|
+
return True, [nid for nid, _ in fused], trace
|
brain/notes.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Note model + vault scanning. Markdown files are the single source of truth."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
import warnings
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Iterator
|
|
11
|
+
|
|
12
|
+
from . import frontmatter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def sha256_text(text: str) -> str:
|
|
16
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# TMP-02: bitemporal frontmatter (ADR-0003 Ruling 2) — mirrors the id-resolution
|
|
20
|
+
# tools/validate.py already does (link_id) so a "[[id]]"/"[[id|alias]]" wikilink
|
|
21
|
+
# value indexes the same as a bare id.
|
|
22
|
+
_WIKILINK = re.compile(r"^\[\[([^\]|]+)(?:\|[^\]]*)?\]\]$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _bitemporal_link(val: object) -> str:
|
|
26
|
+
if not isinstance(val, str) or not val.strip():
|
|
27
|
+
return ""
|
|
28
|
+
m = _WIKILINK.match(val.strip())
|
|
29
|
+
return m.group(1).strip() if m else val.strip()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _bitemporal_bool(val: object) -> str:
|
|
33
|
+
"""Normalize a raw frontmatter bool to "true"/"false"/"" (unset) for the
|
|
34
|
+
index column — never a Python bool, so SQL equality stays trivial."""
|
|
35
|
+
if isinstance(val, bool):
|
|
36
|
+
return "true" if val else "false"
|
|
37
|
+
if isinstance(val, str):
|
|
38
|
+
v = val.strip().lower()
|
|
39
|
+
if v in ("true", "yes"):
|
|
40
|
+
return "true"
|
|
41
|
+
if v in ("false", "no"):
|
|
42
|
+
return "false"
|
|
43
|
+
return ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Bare-slug charset for note ids that become filesystem paths (kebab slugs like
|
|
47
|
+
# `arctic-embed-choice`, `2026-06-27-arctic-benchmark`, `draft-ab12cd34ef56`).
|
|
48
|
+
# No separators, no leading dot, and '..' is rejected explicitly below.
|
|
49
|
+
_SLUG_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def safe_slug(ident: object) -> str:
|
|
53
|
+
"""Validate an (untrusted) note id as a bare, path-safe slug — fail closed.
|
|
54
|
+
|
|
55
|
+
Trust-boundary guard (C-1/C-2): an id from --id or untrusted YAML
|
|
56
|
+
frontmatter becomes ``<dir>/<id>.md``, so anything but a bare slug
|
|
57
|
+
(separators, '..', absolute paths, control chars, empty) is a traversal
|
|
58
|
+
vector and is REFUSED with ValueError — never silently renamed.
|
|
59
|
+
|
|
60
|
+
The id is NFC-normalized BEFORE validation so a decomposed variant cannot
|
|
61
|
+
reconstitute '..' or '/' after the check (fullwidth forms are not in the
|
|
62
|
+
allowed charset, so they are rejected outright).
|
|
63
|
+
"""
|
|
64
|
+
s = unicodedata.normalize("NFC", str(ident))
|
|
65
|
+
if not s or ".." in s or _SLUG_RE.fullmatch(s) is None:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"unsafe note id {ident!r}: must be a bare slug "
|
|
68
|
+
"([A-Za-z0-9._-], no leading '.', no '/', no '..')"
|
|
69
|
+
)
|
|
70
|
+
return s
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class Note:
|
|
75
|
+
id: str
|
|
76
|
+
title: str
|
|
77
|
+
type: str
|
|
78
|
+
classification: str # RAW frontmatter value (may be missing -> "" ; filter applies default-deny)
|
|
79
|
+
zone: str # "raw" | "brain"
|
|
80
|
+
path: Path
|
|
81
|
+
body: str
|
|
82
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
83
|
+
created: str = ""
|
|
84
|
+
updated: str = ""
|
|
85
|
+
sha256: str = ""
|
|
86
|
+
content_hash: str = "" # sha256 of the FULL on-disk file text (change detection)
|
|
87
|
+
# TMP-02 bitemporal keys (ADR-0003 Ruling 2) — all optional, "" when absent.
|
|
88
|
+
document_date: str = ""
|
|
89
|
+
effective_date: str = ""
|
|
90
|
+
superseded_date: str = ""
|
|
91
|
+
is_latest_version: str = "" # "true" | "false" | "" (unset -> treated as current)
|
|
92
|
+
superseded_by: str = ""
|
|
93
|
+
previous_version: str = "" # previous_version, falling back to `replaces`
|
|
94
|
+
|
|
95
|
+
def to_row(self) -> dict[str, Any]:
|
|
96
|
+
return {
|
|
97
|
+
"id": self.id,
|
|
98
|
+
"title": self.title,
|
|
99
|
+
"type": self.type,
|
|
100
|
+
"classification": self.classification,
|
|
101
|
+
"zone": self.zone,
|
|
102
|
+
"path": self.path.as_posix(),
|
|
103
|
+
"created": self.created,
|
|
104
|
+
"updated": self.updated,
|
|
105
|
+
"sha256": self.sha256,
|
|
106
|
+
"content_hash": self.content_hash,
|
|
107
|
+
"body": self.body,
|
|
108
|
+
"document_date": self.document_date,
|
|
109
|
+
"effective_date": self.effective_date,
|
|
110
|
+
"superseded_date": self.superseded_date,
|
|
111
|
+
"is_latest_version": self.is_latest_version,
|
|
112
|
+
"superseded_by": self.superseded_by,
|
|
113
|
+
"previous_version": self.previous_version,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _zone_of(path: Path, vault: Path) -> str:
|
|
118
|
+
try:
|
|
119
|
+
rel = path.relative_to(vault)
|
|
120
|
+
except ValueError:
|
|
121
|
+
return "brain"
|
|
122
|
+
return rel.parts[0] if rel.parts else "brain"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def load_note(path: Path, vault: Path) -> Note | None:
|
|
126
|
+
try:
|
|
127
|
+
text = path.read_text(encoding="utf-8")
|
|
128
|
+
except (UnicodeDecodeError, OSError) as exc:
|
|
129
|
+
# H-3: a single bad-encoding (or unreadable) file must not abort a
|
|
130
|
+
# whole-vault rebuild/sync. Skip it with a warning rather than
|
|
131
|
+
# silently mangling its content (no errors="replace") — an excluded
|
|
132
|
+
# note is honest; a mojibake-indexed one is not.
|
|
133
|
+
warnings.warn(f"skipping unreadable note {path}: {exc}", stacklevel=2)
|
|
134
|
+
return None
|
|
135
|
+
meta, body = frontmatter.parse_text(text)
|
|
136
|
+
if not meta:
|
|
137
|
+
return None
|
|
138
|
+
zone = _zone_of(path, vault)
|
|
139
|
+
nid = str(meta.get("id") or path.stem)
|
|
140
|
+
return Note(
|
|
141
|
+
content_hash=sha256_text(text),
|
|
142
|
+
id=nid,
|
|
143
|
+
title=str(meta.get("title") or nid),
|
|
144
|
+
type=str(meta.get("type") or ("source" if zone == "raw" else "note")),
|
|
145
|
+
classification=str(meta.get("classification") or ""),
|
|
146
|
+
zone=zone,
|
|
147
|
+
path=path,
|
|
148
|
+
body=body,
|
|
149
|
+
meta=meta,
|
|
150
|
+
created=str(meta.get("created") or meta.get("captured") or ""),
|
|
151
|
+
updated=str(meta.get("updated") or meta.get("captured") or ""),
|
|
152
|
+
sha256=str(meta.get("sha256") or sha256_text(body)),
|
|
153
|
+
document_date=str(meta.get("document_date") or ""),
|
|
154
|
+
effective_date=str(meta.get("effective_date") or ""),
|
|
155
|
+
superseded_date=str(meta.get("superseded_date") or ""),
|
|
156
|
+
is_latest_version=_bitemporal_bool(meta.get("is_latest_version")),
|
|
157
|
+
superseded_by=_bitemporal_link(meta.get("superseded_by")),
|
|
158
|
+
previous_version=(_bitemporal_link(meta.get("previous_version"))
|
|
159
|
+
or _bitemporal_link(meta.get("replaces"))),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def scan_vault(vault: Path) -> Iterator[Note]:
|
|
164
|
+
"""Yield every note under vault/, skipping the .brain/ runtime cache, the
|
|
165
|
+
top-level inbox/ drop zone, archived ingestion originals under
|
|
166
|
+
raw/originals/, and the generated backlinks.md."""
|
|
167
|
+
for p in sorted(vault.rglob("*.md")):
|
|
168
|
+
sp = p.as_posix()
|
|
169
|
+
if "/.brain/" in sp:
|
|
170
|
+
continue
|
|
171
|
+
try:
|
|
172
|
+
rel_parts = p.relative_to(vault).parts
|
|
173
|
+
except ValueError:
|
|
174
|
+
rel_parts = ()
|
|
175
|
+
# ADR-0003 Ruling 1: the ingestion drop zone is a visible top-level dir
|
|
176
|
+
# (not hidden like .brain/), but is never indexed — only the
|
|
177
|
+
# extracted raw/ source a handler promotes is a real note.
|
|
178
|
+
# C4: anchored to the vault-relative TOP-LEVEL path segment only — a
|
|
179
|
+
# prior unanchored "/inbox/" substring match wrongly excluded any note
|
|
180
|
+
# under a directory named "inbox" at ANY depth (e.g.
|
|
181
|
+
# brain/resources/inbox/reading-list.md), silently dropping it from
|
|
182
|
+
# the index.
|
|
183
|
+
if rel_parts and rel_parts[0] == "inbox":
|
|
184
|
+
continue
|
|
185
|
+
# C5: raw/originals/ holds the archived, immutable ORIGINAL file a
|
|
186
|
+
# handler claimed during ingestion (e.g. a promoted .md's own source
|
|
187
|
+
# copy) — it is evidence, never a real note, and must not be
|
|
188
|
+
# double-indexed alongside the promoted raw/<slug>.md note.
|
|
189
|
+
if rel_parts[:2] == ("raw", "originals"):
|
|
190
|
+
continue
|
|
191
|
+
if p.name == "backlinks.md":
|
|
192
|
+
continue
|
|
193
|
+
note = load_note(p, vault)
|
|
194
|
+
if note is not None:
|
|
195
|
+
yield note
|