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/graph.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Derived wikilink graph: BFS + Personalized PageRank, DISCOVERY-ONLY (RET-03).
|
|
2
|
+
|
|
3
|
+
Structure in this vault is wikilinks, not folders (substrate-spec §1). For a
|
|
4
|
+
multi-entity / multi-hop question, following ``[[links]]`` between notes surfaces
|
|
5
|
+
context that neither lexical nor dense retrieval reaches in one shot. Two
|
|
6
|
+
on-demand traversals are offered:
|
|
7
|
+
|
|
8
|
+
* **Wikilink-BFS** — breadth-first neighbours of a seed set out to ``depth``
|
|
9
|
+
hops (treating links as undirected so backlinks count).
|
|
10
|
+
* **Personalized PageRank (PPR)** — random-walk-with-restart biased to the
|
|
11
|
+
seed set; ranks the whole reachable neighbourhood by graph centrality
|
|
12
|
+
*relative to the seeds*. Better than raw BFS when many notes are 1-2 hops
|
|
13
|
+
out and you want the few that matter.
|
|
14
|
+
|
|
15
|
+
THE GRAPH IS DISCOVERY-ONLY AND NEVER AUTHORITATIVE. It is derived from note
|
|
16
|
+
bodies (rebuildable, disposable) and its edges are heuristic (a ``[[link]]`` is
|
|
17
|
+
an association, not a verified claim). Every result is tagged
|
|
18
|
+
``authoritative: False`` / ``provenance: "graph-derived (discovery-only)"``. Its
|
|
19
|
+
sole job is to nominate candidate note ids to feed back into the AUTHORITATIVE
|
|
20
|
+
surfaces (hybrid_search / get / grep); a curated note and the retrieval cascade
|
|
21
|
+
WIN on any conflict. Callers must never quote a graph result as fact without
|
|
22
|
+
confirming it on the cited note.
|
|
23
|
+
|
|
24
|
+
Built on demand from the ``notes`` table (id + body) of an open index
|
|
25
|
+
connection — no schema change, no migration, no persisted edge table.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import datetime
|
|
30
|
+
import re
|
|
31
|
+
import sqlite3
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Any, Iterable
|
|
34
|
+
|
|
35
|
+
# [[target]] | [[target|alias]] | [[target#heading]] | [[target#heading|alias]]
|
|
36
|
+
# Alias text is matched non-greedily and right-anchored to the FINAL ]] so an
|
|
37
|
+
# alias containing nested brackets (e.g. "display [x]") doesn't truncate the
|
|
38
|
+
# match at the first ']' and drop the link entirely (M-5).
|
|
39
|
+
_WIKILINK = re.compile(r"\[\[([^\]\|#]+)(?:#[^\]\|]+)?(?:\|.+?)?\]\]")
|
|
40
|
+
|
|
41
|
+
PROVENANCE = "graph-derived (discovery-only)"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_wikilinks(body: str) -> list[str]:
|
|
45
|
+
"""Return the raw link targets in a note body, order-preserving, de-duped.
|
|
46
|
+
|
|
47
|
+
Strips ``#heading`` anchors and ``|alias`` display text; the target is the
|
|
48
|
+
note reference (an id, a path stem, or a title)."""
|
|
49
|
+
seen: dict[str, None] = {}
|
|
50
|
+
for m in _WIKILINK.finditer(body or ""):
|
|
51
|
+
target = m.group(1).strip()
|
|
52
|
+
if target:
|
|
53
|
+
seen.setdefault(target, None)
|
|
54
|
+
return list(seen)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class LinkGraph:
|
|
59
|
+
"""A directed wikilink graph with a resolver for id/stem/title targets.
|
|
60
|
+
|
|
61
|
+
``out[a]`` are the notes ``a`` links to; ``inn[b]`` are the notes that link
|
|
62
|
+
to ``b``. ``undirected_adj`` merges both directions (discovery treats a link
|
|
63
|
+
as a symmetric association)."""
|
|
64
|
+
|
|
65
|
+
out: dict[str, set[str]] = field(default_factory=dict)
|
|
66
|
+
inn: dict[str, set[str]] = field(default_factory=dict)
|
|
67
|
+
nodes: set[str] = field(default_factory=set)
|
|
68
|
+
unresolved: dict[str, list[str]] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
def neighbours(self, node: str) -> set[str]:
|
|
71
|
+
return self.out.get(node, set()) | self.inn.get(node, set())
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def undirected_adj(self) -> dict[str, set[str]]:
|
|
75
|
+
adj: dict[str, set[str]] = {n: set() for n in self.nodes}
|
|
76
|
+
for a, outs in self.out.items():
|
|
77
|
+
for b in outs:
|
|
78
|
+
adj.setdefault(a, set()).add(b)
|
|
79
|
+
adj.setdefault(b, set()).add(a)
|
|
80
|
+
return adj
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _build_resolver(rows: list[tuple[str, str, str]]) -> dict[str, str]:
|
|
84
|
+
"""Map every alias (id, path stem, lowercased title) -> canonical note id."""
|
|
85
|
+
resolver: dict[str, str] = {}
|
|
86
|
+
for nid, title, path in rows:
|
|
87
|
+
resolver[nid] = nid
|
|
88
|
+
resolver[nid.lower()] = nid
|
|
89
|
+
stem = path.rsplit("/", 1)[-1]
|
|
90
|
+
if stem.endswith(".md"):
|
|
91
|
+
stem = stem[:-3]
|
|
92
|
+
resolver.setdefault(stem, nid)
|
|
93
|
+
resolver.setdefault(stem.lower(), nid)
|
|
94
|
+
if title:
|
|
95
|
+
resolver.setdefault(title.lower(), nid)
|
|
96
|
+
return resolver
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_graph(
|
|
100
|
+
conn: sqlite3.Connection, *, extra_edges: Iterable[tuple[str, str]] | None = None
|
|
101
|
+
) -> LinkGraph:
|
|
102
|
+
"""Build the wikilink graph on demand from the index's ``notes`` table.
|
|
103
|
+
|
|
104
|
+
Discovery-only and derived: nothing is persisted; re-call to rebuild.
|
|
105
|
+
|
|
106
|
+
``extra_edges`` (GRF-01, ADR-0003 Ruling 6, "Optional") folds additional
|
|
107
|
+
undirected adjacency pairs — the graphify build's INFERRED edges — into
|
|
108
|
+
the SAME graph before BFS/PPR run, so ``graph_expand`` can optionally
|
|
109
|
+
treat them as discovery-only traversal input. Never persisted here; the
|
|
110
|
+
caller decides whether to pass any."""
|
|
111
|
+
rows = conn.execute("SELECT id, title, path, body FROM notes").fetchall()
|
|
112
|
+
id_rows = [(r[0], r[1] or "", r[2] or "") for r in rows]
|
|
113
|
+
resolver = _build_resolver(id_rows)
|
|
114
|
+
g = LinkGraph()
|
|
115
|
+
for nid, _title, _path in id_rows:
|
|
116
|
+
g.nodes.add(nid)
|
|
117
|
+
g.out.setdefault(nid, set())
|
|
118
|
+
g.inn.setdefault(nid, set())
|
|
119
|
+
for nid, _title, _path, body in ((r[0], r[1], r[2], r[3]) for r in rows):
|
|
120
|
+
for target in parse_wikilinks(body or ""):
|
|
121
|
+
tgt = resolver.get(target) or resolver.get(target.lower())
|
|
122
|
+
if tgt is None:
|
|
123
|
+
g.unresolved.setdefault(nid, []).append(target)
|
|
124
|
+
continue
|
|
125
|
+
if tgt == nid:
|
|
126
|
+
continue
|
|
127
|
+
g.out[nid].add(tgt)
|
|
128
|
+
g.inn[tgt].add(nid)
|
|
129
|
+
for a, b in extra_edges or ():
|
|
130
|
+
if a in g.nodes and b in g.nodes and a != b:
|
|
131
|
+
g.out[a].add(b)
|
|
132
|
+
g.inn[b].add(a)
|
|
133
|
+
return g
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def wikilink_bfs(
|
|
137
|
+
g: LinkGraph, seeds: Iterable[str], *, depth: int = 2
|
|
138
|
+
) -> list[dict[str, Any]]:
|
|
139
|
+
"""Breadth-first neighbours of the seeds out to ``depth`` hops (undirected).
|
|
140
|
+
|
|
141
|
+
Returns [{id, hops}] excluding the seeds themselves, nearest-first."""
|
|
142
|
+
adj = g.undirected_adj
|
|
143
|
+
seed_set = {s for s in seeds if s in g.nodes}
|
|
144
|
+
visited: dict[str, int] = {s: 0 for s in seed_set}
|
|
145
|
+
frontier = set(seed_set)
|
|
146
|
+
for hop in range(1, max(0, depth) + 1):
|
|
147
|
+
nxt: set[str] = set()
|
|
148
|
+
for node in frontier:
|
|
149
|
+
for nb in adj.get(node, set()):
|
|
150
|
+
if nb not in visited:
|
|
151
|
+
visited[nb] = hop
|
|
152
|
+
nxt.add(nb)
|
|
153
|
+
frontier = nxt
|
|
154
|
+
if not frontier:
|
|
155
|
+
break
|
|
156
|
+
out = [
|
|
157
|
+
{"id": nid, "hops": hops, "authoritative": False, "provenance": PROVENANCE}
|
|
158
|
+
for nid, hops in visited.items()
|
|
159
|
+
if nid not in seed_set
|
|
160
|
+
]
|
|
161
|
+
out.sort(key=lambda d: (d["hops"], d["id"]))
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def personalized_pagerank(
|
|
166
|
+
g: LinkGraph,
|
|
167
|
+
seeds: Iterable[str],
|
|
168
|
+
*,
|
|
169
|
+
alpha: float = 0.85,
|
|
170
|
+
iters: int = 40,
|
|
171
|
+
tol: float = 1e-9,
|
|
172
|
+
) -> dict[str, float]:
|
|
173
|
+
"""Random-walk-with-restart biased to ``seeds`` over the undirected graph.
|
|
174
|
+
|
|
175
|
+
``r = (1-alpha)*p + alpha * sum_j r[j] * (1/deg[j]) for j in adj``, where
|
|
176
|
+
``p`` restarts uniformly onto the seed set. Returns id -> score (sums ~1)."""
|
|
177
|
+
adj = g.undirected_adj
|
|
178
|
+
nodes = list(g.nodes)
|
|
179
|
+
if not nodes:
|
|
180
|
+
return {}
|
|
181
|
+
seed_set = [s for s in seeds if s in g.nodes]
|
|
182
|
+
if not seed_set:
|
|
183
|
+
return {}
|
|
184
|
+
restart = {n: 0.0 for n in nodes}
|
|
185
|
+
for s in seed_set:
|
|
186
|
+
restart[s] = 1.0 / len(seed_set)
|
|
187
|
+
rank = dict(restart)
|
|
188
|
+
deg = {n: len(adj.get(n, set())) for n in nodes}
|
|
189
|
+
for _ in range(max(1, iters)):
|
|
190
|
+
nxt = {n: (1.0 - alpha) * restart[n] for n in nodes}
|
|
191
|
+
for j in nodes:
|
|
192
|
+
dj = deg[j]
|
|
193
|
+
if dj == 0:
|
|
194
|
+
# Dangling node: teleport its mass back to the restart set.
|
|
195
|
+
share = alpha * rank[j]
|
|
196
|
+
for s in seed_set:
|
|
197
|
+
nxt[s] += share / len(seed_set)
|
|
198
|
+
continue
|
|
199
|
+
spread = alpha * rank[j] / dj
|
|
200
|
+
for nb in adj[j]:
|
|
201
|
+
nxt[nb] += spread
|
|
202
|
+
delta = sum(abs(nxt[n] - rank[n]) for n in nodes)
|
|
203
|
+
rank = nxt
|
|
204
|
+
if delta < tol:
|
|
205
|
+
break
|
|
206
|
+
return rank
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def graph_expand(
|
|
210
|
+
conn: sqlite3.Connection,
|
|
211
|
+
seeds: Iterable[str],
|
|
212
|
+
*,
|
|
213
|
+
depth: int = 2,
|
|
214
|
+
k: int = 10,
|
|
215
|
+
use_ppr: bool = True,
|
|
216
|
+
extra_edges: Iterable[tuple[str, str]] | None = None,
|
|
217
|
+
) -> dict[str, Any]:
|
|
218
|
+
"""On-demand multi-hop expansion for multi-entity / multi-hop queries.
|
|
219
|
+
|
|
220
|
+
Combines wikilink-BFS (reachability + hop distance) with PPR (centrality
|
|
221
|
+
relative to the seeds) and returns DISCOVERY-ONLY candidate note ids to feed
|
|
222
|
+
back into the authoritative surfaces. Never authoritative on its own.
|
|
223
|
+
|
|
224
|
+
``extra_edges`` (GRF-01, optional): graphify's INFERRED edges, folded into
|
|
225
|
+
the SAME derived graph when the caller opts in (``brain graph-expand
|
|
226
|
+
--use-inferred``) — still discovery-only, still gated the same way."""
|
|
227
|
+
g = build_graph(conn, extra_edges=extra_edges)
|
|
228
|
+
seed_list = list(dict.fromkeys(seeds))
|
|
229
|
+
resolver = _build_resolver(
|
|
230
|
+
[(r[0], r[1] or "", r[2] or "")
|
|
231
|
+
for r in conn.execute("SELECT id, title, path FROM notes").fetchall()]
|
|
232
|
+
)
|
|
233
|
+
resolved_seeds = [resolver.get(s) or resolver.get(s.lower()) or s for s in seed_list]
|
|
234
|
+
known_seeds = [s for s in resolved_seeds if s in g.nodes]
|
|
235
|
+
|
|
236
|
+
bfs = wikilink_bfs(g, known_seeds, depth=depth)
|
|
237
|
+
bfs_hops = {d["id"]: d["hops"] for d in bfs}
|
|
238
|
+
|
|
239
|
+
ppr_scores: dict[str, float] = (
|
|
240
|
+
personalized_pagerank(g, known_seeds) if use_ppr else {}
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
candidates = set(bfs_hops)
|
|
244
|
+
if use_ppr:
|
|
245
|
+
candidates |= {n for n, s in ppr_scores.items() if s > 0.0}
|
|
246
|
+
candidates -= set(known_seeds)
|
|
247
|
+
|
|
248
|
+
ranked = sorted(
|
|
249
|
+
candidates,
|
|
250
|
+
key=lambda n: (-ppr_scores.get(n, 0.0), bfs_hops.get(n, 99), n),
|
|
251
|
+
)[:k]
|
|
252
|
+
|
|
253
|
+
# Title + classification lookup (classification lets the CLI egress gate
|
|
254
|
+
# filter graph candidates so the discovery surface cannot leak the existence
|
|
255
|
+
# of a withheld note).
|
|
256
|
+
meta = {
|
|
257
|
+
r[0]: (r[1], r[2])
|
|
258
|
+
for r in conn.execute("SELECT id, title, classification FROM notes").fetchall()
|
|
259
|
+
}
|
|
260
|
+
results = [
|
|
261
|
+
{
|
|
262
|
+
"id": nid,
|
|
263
|
+
"title": meta.get(nid, (nid, ""))[0],
|
|
264
|
+
"classification": meta.get(nid, (nid, ""))[1],
|
|
265
|
+
"hops": bfs_hops.get(nid),
|
|
266
|
+
"ppr": round(ppr_scores.get(nid, 0.0), 6),
|
|
267
|
+
"authoritative": False,
|
|
268
|
+
"provenance": PROVENANCE,
|
|
269
|
+
}
|
|
270
|
+
for nid in ranked
|
|
271
|
+
]
|
|
272
|
+
return {
|
|
273
|
+
"seeds": seed_list,
|
|
274
|
+
"resolved_seeds": known_seeds,
|
|
275
|
+
"unresolved_seeds": [s for s in resolved_seeds if s not in g.nodes],
|
|
276
|
+
"depth": depth,
|
|
277
|
+
"method": ("wikilink-bfs+ppr" if use_ppr else "wikilink-bfs")
|
|
278
|
+
+ ("+inferred" if extra_edges else ""),
|
|
279
|
+
"authoritative": False,
|
|
280
|
+
"provenance": PROVENANCE,
|
|
281
|
+
"note": "discovery-only candidate ids; confirm on the cited note via "
|
|
282
|
+
"`brain get <id>` before asserting — never authoritative.",
|
|
283
|
+
"results": results,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# --------------------------------------------------------------------------
|
|
288
|
+
# Curation folds (AUT-02, ADR-0003 Ruling 5 Sunday branch): stale wikilink
|
|
289
|
+
# targets + a staleness revisit sample. Both reuse this same derived graph —
|
|
290
|
+
# DISCOVERY-ONLY, never authoritative, exactly like graph_expand above.
|
|
291
|
+
# --------------------------------------------------------------------------
|
|
292
|
+
def stale_wikilink_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
|
293
|
+
"""Outbound wikilinks whose target has vanished (no note resolves to it
|
|
294
|
+
any more) or has moved to ``archive/`` while still linked from an active
|
|
295
|
+
note. UNFILTERED (note-shaped, by id) — caller egress-gates ``from`` and
|
|
296
|
+
``target`` before surfacing, same discipline as ``near_dup``."""
|
|
297
|
+
rows = conn.execute(
|
|
298
|
+
"SELECT id, title, path, classification, body FROM notes"
|
|
299
|
+
).fetchall()
|
|
300
|
+
resolver = _build_resolver([(r[0], r[1] or "", r[2] or "") for r in rows])
|
|
301
|
+
meta = {
|
|
302
|
+
r[0]: {"id": r[0], "title": r[1], "path": r[2], "classification": r[3]}
|
|
303
|
+
for r in rows
|
|
304
|
+
}
|
|
305
|
+
out: list[dict[str, Any]] = []
|
|
306
|
+
for nid, _title, _path, _cls, body in rows:
|
|
307
|
+
for target in parse_wikilinks(body or ""):
|
|
308
|
+
resolved = resolver.get(target) or resolver.get(target.lower())
|
|
309
|
+
if resolved is None:
|
|
310
|
+
out.append({
|
|
311
|
+
"from": meta[nid], "target": None, "target_text": target,
|
|
312
|
+
"reason": "vanished",
|
|
313
|
+
})
|
|
314
|
+
continue
|
|
315
|
+
if resolved == nid:
|
|
316
|
+
continue
|
|
317
|
+
tpath = (meta[resolved]["path"] or "").replace("\\", "/")
|
|
318
|
+
if tpath.startswith("archive/") or "/archive/" in tpath:
|
|
319
|
+
out.append({
|
|
320
|
+
"from": meta[nid], "target": meta[resolved], "target_text": target,
|
|
321
|
+
"reason": "archived",
|
|
322
|
+
})
|
|
323
|
+
return out
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def revisit_sample(
|
|
327
|
+
conn: sqlite3.Connection, today: datetime.date, *, k: int = 10
|
|
328
|
+
) -> list[dict[str, Any]]:
|
|
329
|
+
"""Notes overdue for a re-read, ranked by ``age_days * (centrality + 1)``.
|
|
330
|
+
|
|
331
|
+
Centrality is the existing wikilink-BFS+PPR module's Personalized
|
|
332
|
+
PageRank run with EVERY note as its own seed (uniform restart across the
|
|
333
|
+
whole corpus) — i.e. standard whole-corpus PageRank, reusing
|
|
334
|
+
``personalized_pagerank`` rather than a new ranking module (this
|
|
335
|
+
supersedes the curation skill's previously-documented "global centrality
|
|
336
|
+
is gap G3, age-only fallback" framing). The ``+1`` smoothing means an
|
|
337
|
+
isolated/orphan note (centrality 0) still ranks by age alone rather than
|
|
338
|
+
scoring zero and never surfacing.
|
|
339
|
+
|
|
340
|
+
Upgrade path (s10, grf-01): once ``graphify`` builds INFERRED
|
|
341
|
+
(embedding-neighbour) edges, feed the merged explicit+INFERRED graph into
|
|
342
|
+
this same function for a richer centrality signal — the ranking formula
|
|
343
|
+
itself does not need to change.
|
|
344
|
+
|
|
345
|
+
UNFILTERED (note-shaped, by id) — caller egress-gates before surfacing.
|
|
346
|
+
"""
|
|
347
|
+
g = build_graph(conn)
|
|
348
|
+
centrality = personalized_pagerank(g, list(g.nodes)) if g.nodes else {}
|
|
349
|
+
rows = conn.execute(
|
|
350
|
+
"SELECT id, title, path, classification, updated FROM notes"
|
|
351
|
+
).fetchall()
|
|
352
|
+
scored: list[dict[str, Any]] = []
|
|
353
|
+
for nid, title, path, classification, updated in rows:
|
|
354
|
+
age_days = 0
|
|
355
|
+
try:
|
|
356
|
+
age_days = max(
|
|
357
|
+
(today - datetime.date.fromisoformat(str(updated)[:10])).days, 0
|
|
358
|
+
)
|
|
359
|
+
except (TypeError, ValueError):
|
|
360
|
+
pass
|
|
361
|
+
cscore = centrality.get(nid, 0.0)
|
|
362
|
+
score = age_days * (cscore + 1.0)
|
|
363
|
+
scored.append({
|
|
364
|
+
"id": nid, "title": title, "path": path, "classification": classification,
|
|
365
|
+
"updated": updated, "age_days": age_days,
|
|
366
|
+
"centrality": round(cscore, 6), "score": round(score, 3),
|
|
367
|
+
})
|
|
368
|
+
scored.sort(key=lambda d: (-d["score"], d["id"]))
|
|
369
|
+
return scored[:k]
|