brainplug 0.4.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.
- brainplug/__init__.py +3 -0
- brainplug/__main__.py +300 -0
- brainplug/agent.py +749 -0
- brainplug/analysis.py +453 -0
- brainplug/app.py +184 -0
- brainplug/brain.py +184 -0
- brainplug/connect.py +354 -0
- brainplug/demo.py +165 -0
- brainplug/export.py +142 -0
- brainplug/memory.py +356 -0
- brainplug/notion_api.py +135 -0
- brainplug/pipeline.py +95 -0
- brainplug/render.py +47 -0
- brainplug/sources.py +1193 -0
- brainplug/stopwords.py +33 -0
- brainplug/sync.py +109 -0
- brainplug/templates/app.html +212 -0
- brainplug/templates/viewer.html +1050 -0
- brainplug/vendor/INTER_LICENSE +93 -0
- brainplug/vendor/THREE_LICENSE +21 -0
- brainplug/vendor/inter-latin.woff2 +0 -0
- brainplug/vendor/three.module.min.js +6 -0
- brainplug-0.4.0.dist-info/METADATA +364 -0
- brainplug-0.4.0.dist-info/RECORD +28 -0
- brainplug-0.4.0.dist-info/WHEEL +5 -0
- brainplug-0.4.0.dist-info/entry_points.txt +2 -0
- brainplug-0.4.0.dist-info/licenses/LICENSE +21 -0
- brainplug-0.4.0.dist-info/top_level.txt +1 -0
brainplug/analysis.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Turn items into topics, connections, two 3D layouts and summary numbers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from datetime import datetime, timedelta, tzinfo
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from sklearn.cluster import KMeans
|
|
11
|
+
from sklearn.decomposition import PCA, TruncatedSVD
|
|
12
|
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
|
13
|
+
from sklearn.metrics import silhouette_score
|
|
14
|
+
from sklearn.neighbors import NearestNeighbors
|
|
15
|
+
from sklearn.preprocessing import normalize
|
|
16
|
+
|
|
17
|
+
from . import __version__, brain
|
|
18
|
+
from .sources import Item
|
|
19
|
+
from .stopwords import STOP_WORDS
|
|
20
|
+
|
|
21
|
+
PALETTE = [
|
|
22
|
+
"#4cc9f0", "#f72585", "#b8f35a", "#ffb703", "#9d4edd", "#2ec4b6", "#ff6b6b",
|
|
23
|
+
"#ffd6a5", "#80ffdb", "#c77dff", "#f15bb5", "#00bbf9", "#fee440", "#a0c4ff",
|
|
24
|
+
"#ff9f1c", "#06d6a0",
|
|
25
|
+
]
|
|
26
|
+
KINDS = ["chat", "note", "message"]
|
|
27
|
+
SOURCE_NAMES = {
|
|
28
|
+
"chatgpt": "ChatGPT", "claude": "Claude", "gemini": "Gemini", "copilot": "Copilot",
|
|
29
|
+
"perplexity": "Perplexity", "obsidian": "Obsidian", "notion": "Notion", "notes": "Notes",
|
|
30
|
+
"word": "Word", "pdf": "PDF", "gmail": "Gmail", "whatsapp": "WhatsApp", "memory": "Memory",
|
|
31
|
+
"demo": "Demo", "claudecode": "Claude Code", "codex": "Codex",
|
|
32
|
+
}
|
|
33
|
+
TOKEN_PATTERN = r"(?u)\b[^\W\d_][^\W\d_]+\b"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _stamps(item: Item) -> list[datetime]:
|
|
37
|
+
"""When you wrote: one timestamp per message, or the note's own date."""
|
|
38
|
+
return item.parts or [item.created]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --------------------------------------------------------------------------- #
|
|
42
|
+
# topics
|
|
43
|
+
# --------------------------------------------------------------------------- #
|
|
44
|
+
def documents(items: list[Item]) -> list[str]:
|
|
45
|
+
# Titles and tags are strong hints, so they count twice. Only your own
|
|
46
|
+
# words are used: they say what *you* care about. A WhatsApp "title" is a
|
|
47
|
+
# person's name, which says nothing about the topic, so it is left out.
|
|
48
|
+
return [(f"{i.text[:20000]}" if i.source == "whatsapp" else
|
|
49
|
+
f"{i.title}. {i.title}. {' '.join(i.tags)} {' '.join(i.tags)} {i.text[:20000]}")
|
|
50
|
+
for i in items]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def vectorize(docs: list[str]):
|
|
54
|
+
options = dict(lowercase=True, stop_words=STOP_WORDS, token_pattern=TOKEN_PATTERN,
|
|
55
|
+
ngram_range=(1, 2), max_features=8000, sublinear_tf=True)
|
|
56
|
+
try:
|
|
57
|
+
vec = TfidfVectorizer(min_df=2 if len(docs) >= 20 else 1, max_df=0.5, **options)
|
|
58
|
+
return vec, vec.fit_transform(docs)
|
|
59
|
+
except ValueError: # tiny or very repetitive record: keep every term
|
|
60
|
+
vec = TfidfVectorizer(min_df=1, max_df=1.0, **options)
|
|
61
|
+
return vec, vec.fit_transform(docs)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def embed(X, seed: int = 0, model: dict | None = None) -> np.ndarray:
|
|
65
|
+
n_comp = max(2, min(100, X.shape[0] - 1, X.shape[1] - 1))
|
|
66
|
+
svd = TruncatedSVD(n_components=n_comp, random_state=seed)
|
|
67
|
+
with warnings.catch_warnings():
|
|
68
|
+
warnings.simplefilter("ignore") # identical documents make the variance ratio 0/0
|
|
69
|
+
Z = svd.fit_transform(X)
|
|
70
|
+
if model is not None:
|
|
71
|
+
model["svd"] = svd
|
|
72
|
+
return normalize(Z)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def choose_k(Z: np.ndarray, seed: int = 0) -> int:
|
|
76
|
+
n = len(Z)
|
|
77
|
+
if n < 12:
|
|
78
|
+
return max(1, min(3, n))
|
|
79
|
+
lo, hi = 4, int(min(14, max(5, n // 12)))
|
|
80
|
+
best_k, best_s = lo, -1.0
|
|
81
|
+
for k in range(lo, hi + 1):
|
|
82
|
+
labels = KMeans(n_clusters=k, n_init=5, random_state=seed).fit_predict(Z)
|
|
83
|
+
s = silhouette_score(Z, labels, metric="cosine", sample_size=min(n, 2000),
|
|
84
|
+
random_state=seed)
|
|
85
|
+
s += 0.004 * k # small bonus for more topics so the map isn't too coarse
|
|
86
|
+
if s > best_s:
|
|
87
|
+
best_k, best_s = k, s
|
|
88
|
+
return best_k
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def label_topics(X, labels: np.ndarray, vocab: np.ndarray, top: int = 3) -> list[list[str]]:
|
|
92
|
+
overall = np.asarray(X.mean(axis=0)).ravel()
|
|
93
|
+
names = []
|
|
94
|
+
for k in range(labels.max() + 1):
|
|
95
|
+
score = np.asarray(X[labels == k].mean(axis=0)).ravel() - 0.5 * overall
|
|
96
|
+
picked: list[str] = []
|
|
97
|
+
for idx in np.argsort(score)[::-1]:
|
|
98
|
+
term = vocab[idx]
|
|
99
|
+
# skip terms that repeat a word already picked ("pandas" vs "pandas groupby")
|
|
100
|
+
if any(w in p.split() for w in term.split() for p in picked):
|
|
101
|
+
continue
|
|
102
|
+
picked.append(term)
|
|
103
|
+
if len(picked) == top:
|
|
104
|
+
break
|
|
105
|
+
names.append(picked)
|
|
106
|
+
return names
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def item_terms(X, vocab: np.ndarray, top: int = 4) -> list[list[str]]:
|
|
110
|
+
out = []
|
|
111
|
+
X = X.tocsr()
|
|
112
|
+
for i in range(X.shape[0]):
|
|
113
|
+
row = X.getrow(i)
|
|
114
|
+
order = np.argsort(row.data)[::-1][: top * 2]
|
|
115
|
+
picked: list[str] = []
|
|
116
|
+
for j in order:
|
|
117
|
+
term = vocab[row.indices[j]]
|
|
118
|
+
if not any(w in p.split() for w in term.split() for p in picked):
|
|
119
|
+
picked.append(term)
|
|
120
|
+
if len(picked) == top:
|
|
121
|
+
break
|
|
122
|
+
out.append(picked)
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --------------------------------------------------------------------------- #
|
|
127
|
+
# connections
|
|
128
|
+
# --------------------------------------------------------------------------- #
|
|
129
|
+
def connections(Z: np.ndarray, items: list[Item], per_item: int = 3,
|
|
130
|
+
min_sim: float = 0.5, limit: int = 8000) -> list[list]:
|
|
131
|
+
"""Edges [i, j, weight, kind]: kind 0 = similar content, 1 = explicit link."""
|
|
132
|
+
n = len(items)
|
|
133
|
+
edges: dict[tuple[int, int], tuple[float, int]] = {}
|
|
134
|
+
if n > 2:
|
|
135
|
+
nn = NearestNeighbors(n_neighbors=min(per_item + 1, n), metric="cosine").fit(Z)
|
|
136
|
+
dist, idx = nn.kneighbors(Z)
|
|
137
|
+
for i in range(n):
|
|
138
|
+
for d, j in zip(dist[i], idx[i]):
|
|
139
|
+
if i != j and 1 - d >= min_sim:
|
|
140
|
+
key = (min(i, int(j)), max(i, int(j)))
|
|
141
|
+
edges[key] = (max(edges.get(key, (0.0, 0))[0], float(1 - d)), 0)
|
|
142
|
+
by_title: dict[str, int] = {}
|
|
143
|
+
for i, item in enumerate(items):
|
|
144
|
+
by_title.setdefault(item.title.strip().lower(), i)
|
|
145
|
+
for i, item in enumerate(items):
|
|
146
|
+
for target in item.links: # [[wikilinks]] and Notion page links
|
|
147
|
+
j = by_title.get(target.strip().lower())
|
|
148
|
+
if j is not None and j != i:
|
|
149
|
+
edges[(min(i, j), max(i, j))] = (1.0, 1)
|
|
150
|
+
out = [[a, b, round(w, 2), kind] for (a, b), (w, kind) in edges.items()]
|
|
151
|
+
out.sort(key=lambda e: (-e[3], -e[2]))
|
|
152
|
+
return out[:limit]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def topic_bridges(Z: np.ndarray, labels: np.ndarray, k: int, ratio: float = 0.6,
|
|
156
|
+
top: int = 3) -> list[dict]:
|
|
157
|
+
"""Pairs of topics joined by items that sit between both: an item is a bridge
|
|
158
|
+
when it is almost as close to another topic's centre as to its own."""
|
|
159
|
+
if k < 2:
|
|
160
|
+
return []
|
|
161
|
+
centres = normalize(np.vstack([Z[labels == t].mean(axis=0) for t in range(k)]))
|
|
162
|
+
sim = Z @ centres.T
|
|
163
|
+
rows = np.arange(len(Z))
|
|
164
|
+
own = sim[rows, labels]
|
|
165
|
+
sim[rows, labels] = -1
|
|
166
|
+
second, s2 = sim.argmax(axis=1), sim.max(axis=1)
|
|
167
|
+
pairs: Counter = Counter()
|
|
168
|
+
examples: dict[tuple[int, int], list] = {}
|
|
169
|
+
for i in np.where((s2 >= ratio * own) & (s2 > 0.2))[0]:
|
|
170
|
+
key = (int(min(labels[i], second[i])), int(max(labels[i], second[i])))
|
|
171
|
+
pairs[key] += 1
|
|
172
|
+
examples.setdefault(key, []).append((float(s2[i]), int(i)))
|
|
173
|
+
return [{"a": a, "b": b, "n": n, "ex": [i for _, i in sorted(examples[(a, b)], reverse=True)[:3]]}
|
|
174
|
+
for (a, b), n in pairs.most_common(top)]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --------------------------------------------------------------------------- #
|
|
178
|
+
# numbers
|
|
179
|
+
# --------------------------------------------------------------------------- #
|
|
180
|
+
def _longest_streak(days: set) -> int:
|
|
181
|
+
best = cur = 0
|
|
182
|
+
prev = None
|
|
183
|
+
for d in sorted(days):
|
|
184
|
+
cur = cur + 1 if prev is not None and (d - prev).days == 1 else 1
|
|
185
|
+
best = max(best, cur)
|
|
186
|
+
prev = d
|
|
187
|
+
return best
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def summary(items: list[Item], dated: np.ndarray, tz: tzinfo, private: bool) -> dict:
|
|
191
|
+
timed = [it for it, ok in zip(items, dated) if ok]
|
|
192
|
+
stamps = [s.astimezone(tz) for it in timed for s in _stamps(it)]
|
|
193
|
+
heat = np.zeros((7, 24), dtype=int)
|
|
194
|
+
for s in stamps:
|
|
195
|
+
heat[s.weekday(), s.hour] += 1
|
|
196
|
+
months = Counter(it.created.astimezone(tz).strftime("%Y-%m") for it in timed)
|
|
197
|
+
busiest = max(months.items(), key=lambda kv: kv[1])
|
|
198
|
+
days = {s.date() for s in stamps}
|
|
199
|
+
longest = max(items, key=lambda it: it.words)
|
|
200
|
+
by_source = Counter(it.source for it in items)
|
|
201
|
+
words_by_source: Counter = Counter()
|
|
202
|
+
for it in items:
|
|
203
|
+
words_by_source[it.source] += it.words
|
|
204
|
+
return {
|
|
205
|
+
"items": len(items),
|
|
206
|
+
"kinds": {k: sum(it.kind == k for it in items) for k in KINDS},
|
|
207
|
+
"writes": len(stamps),
|
|
208
|
+
"words": sum(it.words for it in items),
|
|
209
|
+
"first": min(it.created for it in timed).astimezone(tz).strftime("%Y-%m-%d"),
|
|
210
|
+
"last": max(it.created for it in timed).astimezone(tz).strftime("%Y-%m-%d"),
|
|
211
|
+
"active_days": len(days),
|
|
212
|
+
"streak": _longest_streak(days),
|
|
213
|
+
"busiest_month": {"month": busiest[0], "count": int(busiest[1])},
|
|
214
|
+
"busiest_weekday": int(np.argmax(heat.sum(axis=1))),
|
|
215
|
+
"busiest_hour": int(np.argmax(heat.sum(axis=0))),
|
|
216
|
+
"night_share": round(int(heat[:, 0:6].sum()) / max(1, len(stamps)), 3),
|
|
217
|
+
"longest": {"title": None if private else longest.title, "words": longest.words},
|
|
218
|
+
"sources": [
|
|
219
|
+
{"id": s, "name": SOURCE_NAMES.get(s, s.title()), "count": c,
|
|
220
|
+
"words": int(words_by_source[s])}
|
|
221
|
+
for s, c in by_source.most_common()
|
|
222
|
+
],
|
|
223
|
+
"undated": int((~dated).sum()),
|
|
224
|
+
"heat": heat.tolist(),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def month_range(first: datetime, last: datetime) -> list[str]:
|
|
229
|
+
out, y, m = [], first.year, first.month
|
|
230
|
+
while (y, m) <= (last.year, last.month):
|
|
231
|
+
out.append(f"{y:04d}-{m:02d}")
|
|
232
|
+
y, m = (y + 1, 1) if m == 12 else (y, m + 1)
|
|
233
|
+
return out
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# --------------------------------------------------------------------------- #
|
|
237
|
+
# review: what changed, what you dropped, what connects
|
|
238
|
+
# --------------------------------------------------------------------------- #
|
|
239
|
+
def review(items: list[Item], labels: np.ndarray, dated: np.ndarray, k: int,
|
|
240
|
+
edges: list[list], bridges: list[dict], tz: tzinfo, window_days: int = 60) -> dict:
|
|
241
|
+
idx = [i for i in range(len(items)) if dated[i]]
|
|
242
|
+
last = max(items[i].created for i in idx)
|
|
243
|
+
win = timedelta(days=window_days)
|
|
244
|
+
recent, before = np.zeros(k, dtype=int), np.zeros(k, dtype=int)
|
|
245
|
+
first_seen: list = [None] * k
|
|
246
|
+
last_seen: list = [None] * k
|
|
247
|
+
for i in idx:
|
|
248
|
+
c, t = items[i].created, int(labels[i])
|
|
249
|
+
if c > last - win:
|
|
250
|
+
recent[t] += 1
|
|
251
|
+
elif c > last - 2 * win:
|
|
252
|
+
before[t] += 1
|
|
253
|
+
first_seen[t] = c if first_seen[t] is None else min(first_seen[t], c)
|
|
254
|
+
last_seen[t] = c if last_seen[t] is None else max(last_seen[t], c)
|
|
255
|
+
delta = recent - before
|
|
256
|
+
sizes = np.bincount(labels, minlength=k)
|
|
257
|
+
|
|
258
|
+
def row(t):
|
|
259
|
+
return {"k": int(t), "now": int(recent[t]), "before": int(before[t])}
|
|
260
|
+
|
|
261
|
+
rising = [row(t) for t in np.argsort(-delta, kind="stable") if delta[t] > 0][:3]
|
|
262
|
+
fading = [row(t) for t in np.argsort(delta, kind="stable") if delta[t] < 0][:3]
|
|
263
|
+
dormant = sorted(
|
|
264
|
+
({"k": t, "days": (last - last_seen[t]).days,
|
|
265
|
+
"last": last_seen[t].astimezone(tz).strftime("%Y-%m-%d")}
|
|
266
|
+
for t in range(k) if last_seen[t] is not None and (last - last_seen[t]).days >= 90),
|
|
267
|
+
key=lambda d: -sizes[d["k"]],
|
|
268
|
+
)[:3]
|
|
269
|
+
seen = [t for t in range(k) if first_seen[t] is not None]
|
|
270
|
+
newest = None
|
|
271
|
+
if seen:
|
|
272
|
+
t = max(seen, key=lambda t: first_seen[t])
|
|
273
|
+
if (last - first_seen[t]).days <= 150 and first_seen[t] > min(first_seen[s] for s in seen):
|
|
274
|
+
newest = {"k": t, "since": first_seen[t].astimezone(tz).strftime("%Y-%m")}
|
|
275
|
+
|
|
276
|
+
# connections between different apps; the most telling ones join different kinds
|
|
277
|
+
# of writing (a note and a chat, say), and each item is shown only once
|
|
278
|
+
cross = [(items[a].kind != items[b].kind, w, a, b) for a, b, w, _kind in edges
|
|
279
|
+
if items[a].source != items[b].source]
|
|
280
|
+
cross.sort(reverse=True)
|
|
281
|
+
picked, shown = [], set()
|
|
282
|
+
for _, _, a, b in cross:
|
|
283
|
+
if a not in shown and b not in shown:
|
|
284
|
+
picked.append([a, b])
|
|
285
|
+
shown.update((a, b))
|
|
286
|
+
if len(picked) == 6:
|
|
287
|
+
break
|
|
288
|
+
target = (last.astimezone(tz).year - 1, last.astimezone(tz).month)
|
|
289
|
+
year_ago = [i for i in idx
|
|
290
|
+
if (items[i].created.astimezone(tz).year, items[i].created.astimezone(tz).month) == target]
|
|
291
|
+
return {
|
|
292
|
+
"window": window_days,
|
|
293
|
+
"rising": rising, "fading": fading, "dormant": dormant, "newest": newest,
|
|
294
|
+
"bridges": bridges,
|
|
295
|
+
"cross": picked,
|
|
296
|
+
"cross_total": len(cross),
|
|
297
|
+
"deep": [int(i) for i in np.argsort([-it.words for it in items], kind="stable")[:5]],
|
|
298
|
+
"year_ago": year_ago[:8],
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
SIGNATURE = 12 # words kept with a topic name, so it is recognised after the topic shifts a little
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def match_names(terms: list[list[str]], names: list, skip: set | None = None) -> dict[int, str]:
|
|
306
|
+
"""Give topics the names people saved (Brainplug/Topic names.md), matched by their words:
|
|
307
|
+
a name fits a topic when at least a third of its saved words are among the topic's words."""
|
|
308
|
+
pairs = []
|
|
309
|
+
for i, words in enumerate(terms):
|
|
310
|
+
for j, (name, saved) in enumerate(names):
|
|
311
|
+
shared = len(set(words) & set(saved))
|
|
312
|
+
if i not in (skip or set()) and saved and shared * 3 >= len(saved):
|
|
313
|
+
pairs.append((-shared / len(saved), -shared, i, j, name))
|
|
314
|
+
out: dict[int, str] = {}
|
|
315
|
+
used: set[int] = set()
|
|
316
|
+
for _score, _shared, i, j, name in sorted(pairs):
|
|
317
|
+
if i not in out and j not in used:
|
|
318
|
+
out[i] = name
|
|
319
|
+
used.add(j)
|
|
320
|
+
return out
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# --------------------------------------------------------------------------- #
|
|
324
|
+
# main entry
|
|
325
|
+
# --------------------------------------------------------------------------- #
|
|
326
|
+
def analyse(items: list[Item], tz: tzinfo | None = None, n_topics: int | None = None, private: bool = False,
|
|
327
|
+
rename: dict[int, str] | None = None, title: str = "My Brainplug",
|
|
328
|
+
seed: int = 0, keep: dict | None = None, names: list | None = None) -> dict:
|
|
329
|
+
"""Build everything the viewer needs. Pass a dict as ``keep`` to also get the
|
|
330
|
+
fitted model back (vectorizer, SVD, matrices, labels), e.g. for search."""
|
|
331
|
+
if len(items) < 3:
|
|
332
|
+
raise ValueError("Need at least 3 items (chats, notes, documents or messages) to build a brainplug.")
|
|
333
|
+
rng = np.random.default_rng(seed)
|
|
334
|
+
n = len(items)
|
|
335
|
+
dated = np.array([it.dated for it in items])
|
|
336
|
+
if not dated.any(): # nothing carries a real date: fall back to file dates
|
|
337
|
+
dated[:] = True
|
|
338
|
+
|
|
339
|
+
vec, X = vectorize(documents(items))
|
|
340
|
+
vocab = vec.get_feature_names_out()
|
|
341
|
+
model: dict = {}
|
|
342
|
+
Z = embed(X, seed, model)
|
|
343
|
+
k = max(1, min(n_topics or choose_k(Z, seed), len(PALETTE), n))
|
|
344
|
+
with warnings.catch_warnings():
|
|
345
|
+
warnings.simplefilter("ignore") # duplicates make k-means warn; the result is still fine
|
|
346
|
+
labels = KMeans(n_clusters=k, n_init=10, random_state=seed).fit(Z).labels_
|
|
347
|
+
labels = np.unique(labels, return_inverse=True)[1] # drop clusters that ended up empty
|
|
348
|
+
k = int(labels.max()) + 1
|
|
349
|
+
|
|
350
|
+
# biggest topic first, so topic 1 is always the main one
|
|
351
|
+
order = np.argsort(-np.bincount(labels, minlength=k), kind="stable")
|
|
352
|
+
remap = np.empty(k, dtype=int)
|
|
353
|
+
remap[order] = np.arange(k)
|
|
354
|
+
labels = remap[labels]
|
|
355
|
+
sizes = np.bincount(labels, minlength=k)
|
|
356
|
+
|
|
357
|
+
terms = label_topics(X, labels, vocab)
|
|
358
|
+
sig = label_topics(X, labels, vocab, top=SIGNATURE) # words a saved topic name is matched by; never in the file
|
|
359
|
+
rename = {**match_names(sig, names or [], skip=set(rename or {})), **(rename or {})}
|
|
360
|
+
topics = [{
|
|
361
|
+
"id": i,
|
|
362
|
+
"name": rename.get(i) or (" · ".join(terms[i][:2]) if terms[i] else f"Topic {i + 1}"),
|
|
363
|
+
# keywords are words taken from your text: a private file keeps none of them
|
|
364
|
+
"terms": [] if private else terms[i], "color": PALETTE[i], "count": int(sizes[i]),
|
|
365
|
+
"share": round(float(sizes[i]) / n, 3),
|
|
366
|
+
} for i in range(k)]
|
|
367
|
+
|
|
368
|
+
edges = connections(Z, items)
|
|
369
|
+
if keep is not None:
|
|
370
|
+
keep.update(vec=vec, svd=model["svd"], X=X, Z=Z, labels=labels, sig=sig)
|
|
371
|
+
|
|
372
|
+
# ---- time, normalised to 0..1 -------------------------------------- #
|
|
373
|
+
stamps = np.array([it.created.timestamp() for it in items])
|
|
374
|
+
t0, t1 = stamps[dated].min(), stamps[dated].max()
|
|
375
|
+
span = max(1.0, t1 - t0)
|
|
376
|
+
when = np.where(dated, np.clip((stamps - t0) / span, 0, 1), 0.0)
|
|
377
|
+
|
|
378
|
+
# ---- layout 1: brain ------------------------------------------------ #
|
|
379
|
+
pos = np.zeros((n, 3))
|
|
380
|
+
anchors = brain.anchors(k, seed=11 + seed)
|
|
381
|
+
for i in range(k):
|
|
382
|
+
idx = np.where(labels == i)[0]
|
|
383
|
+
if len(idx) >= 4:
|
|
384
|
+
local = PCA(n_components=3, random_state=seed).fit_transform(Z[idx])
|
|
385
|
+
local /= np.abs(local).max(axis=0) + 1e-9
|
|
386
|
+
else:
|
|
387
|
+
local = rng.normal(scale=0.5, size=(len(idx), 3))
|
|
388
|
+
radius = 0.16 + 0.22 * np.sqrt(len(idx) / sizes.max())
|
|
389
|
+
pos[idx] = brain.place_cluster(anchors[i], local, radius, rng)
|
|
390
|
+
|
|
391
|
+
# ---- layout 2: galaxy ----------------------------------------------- #
|
|
392
|
+
gal = brain.galaxy(labels, np.where(dated, when, rng.random(n)), k, rng)
|
|
393
|
+
|
|
394
|
+
words = np.array([it.words for it in items], dtype=float)
|
|
395
|
+
size = 0.7 + 1.6 * np.log1p(words) / np.log1p(words.max())
|
|
396
|
+
points = [
|
|
397
|
+
[*(round(float(v), 3) for v in pos[i]), *(round(float(v), 3) for v in gal[i]),
|
|
398
|
+
int(labels[i]), round(float(size[i]), 2), round(float(when[i]), 4),
|
|
399
|
+
KINDS.index(items[i].kind)]
|
|
400
|
+
for i in range(n)
|
|
401
|
+
]
|
|
402
|
+
|
|
403
|
+
own_terms = [None] * n if private else item_terms(X, vocab)
|
|
404
|
+
items_out = []
|
|
405
|
+
for i, it in enumerate(items):
|
|
406
|
+
local = it.created.astimezone(tz)
|
|
407
|
+
items_out.append({
|
|
408
|
+
"t": None if private else it.title,
|
|
409
|
+
"d": local.strftime("%Y-%m-%d"), "h": local.hour,
|
|
410
|
+
"n": it.size, "p": len(_stamps(it)), "w": it.words, "k": int(labels[i]), "s": it.source,
|
|
411
|
+
"x": None if private else " ".join(it.text.split())[:180],
|
|
412
|
+
"tm": own_terms[i], "u": 0 if dated[i] else 1,
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
# ---- spine / core: every message you wrote, oldest first ------------ #
|
|
416
|
+
rows = sorted((s.timestamp(), int(labels[i]))
|
|
417
|
+
for i, it in enumerate(items) if dated[i] for s in _stamps(it))
|
|
418
|
+
if len(rows) > 3000:
|
|
419
|
+
rows = [rows[int(j * len(rows) / 3000)] for j in range(3000)]
|
|
420
|
+
along = np.linspace(0.02, 0.98, len(rows))
|
|
421
|
+
spine_xyz = brain.spine_points(len(rows), rng, jitter=0.85, t=along)
|
|
422
|
+
spine_t = np.clip((np.array([r[0] for r in rows]) - t0) / span, 0, 1)
|
|
423
|
+
core_xyz = brain.galaxy_core(spine_t, rng)
|
|
424
|
+
spine = [
|
|
425
|
+
[*(round(float(v), 3) for v in spine_xyz[j]), *(round(float(v), 3) for v in core_xyz[j]),
|
|
426
|
+
rows[j][1], round(float(spine_t[j]), 4)]
|
|
427
|
+
for j in range(len(rows))
|
|
428
|
+
]
|
|
429
|
+
|
|
430
|
+
# ---- waves: items per month and topic -------------------------------- #
|
|
431
|
+
first = datetime.fromtimestamp(t0, tz)
|
|
432
|
+
last = datetime.fromtimestamp(t1, tz)
|
|
433
|
+
months = month_range(first, last)
|
|
434
|
+
midx = {m: i for i, m in enumerate(months)}
|
|
435
|
+
waves = np.zeros((k, len(months)), dtype=int)
|
|
436
|
+
for i, it in enumerate(items):
|
|
437
|
+
if dated[i]:
|
|
438
|
+
waves[labels[i], midx[it.created.astimezone(tz).strftime("%Y-%m")]] += 1
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
"meta": {"title": title, "private": private, "version": __version__,
|
|
442
|
+
"generated": datetime.now(tz).strftime("%Y-%m-%d")},
|
|
443
|
+
"numbers": summary(items, dated, tz, private),
|
|
444
|
+
"topics": topics,
|
|
445
|
+
"points": points,
|
|
446
|
+
"items": items_out,
|
|
447
|
+
"edges": edges,
|
|
448
|
+
"spine": spine,
|
|
449
|
+
"months": months,
|
|
450
|
+
"waves": waves.tolist(),
|
|
451
|
+
"review": review(items, labels, dated, k, edges, topic_bridges(Z, labels, k), tz),
|
|
452
|
+
"shell": [round(float(v), 3) for v in brain.shell(seed=3 + seed).ravel()],
|
|
453
|
+
}
|
brainplug/app.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""A tiny local web app: drop your files in the browser, get your brainplug.
|
|
2
|
+
|
|
3
|
+
It listens only on this computer (127.0.0.1) and needs a secret token that is
|
|
4
|
+
part of the URL it opens, so no other website or device can talk to it.
|
|
5
|
+
Nothing is uploaded anywhere: "uploading" just copies files to a temp folder.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import atexit
|
|
11
|
+
import json
|
|
12
|
+
import secrets
|
|
13
|
+
import shutil
|
|
14
|
+
import signal
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import webbrowser
|
|
18
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
19
|
+
from pathlib import Path, PurePosixPath
|
|
20
|
+
from urllib.parse import parse_qs, urlparse
|
|
21
|
+
|
|
22
|
+
from . import analysis, sources
|
|
23
|
+
from .render import render_html
|
|
24
|
+
|
|
25
|
+
PAGE = Path(__file__).parent / "templates" / "app.html"
|
|
26
|
+
CHUNK = 1 << 20
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class State:
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self.token = secrets.token_urlsafe(18)
|
|
32
|
+
self.dir = Path(tempfile.mkdtemp(prefix="brainplug_app_"))
|
|
33
|
+
self.uploads = self.dir / "uploads"
|
|
34
|
+
self.uploads.mkdir()
|
|
35
|
+
self.result: str | None = None
|
|
36
|
+
|
|
37
|
+
def reset(self) -> None:
|
|
38
|
+
shutil.rmtree(self.uploads, ignore_errors=True)
|
|
39
|
+
self.uploads.mkdir()
|
|
40
|
+
|
|
41
|
+
def close(self) -> None:
|
|
42
|
+
shutil.rmtree(self.dir, ignore_errors=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def safe_relative(name: str) -> PurePosixPath | None:
|
|
46
|
+
"""Turn an uploaded file name into a path that cannot escape the upload folder."""
|
|
47
|
+
parts = [p for p in PurePosixPath(name.replace("\\", "/")).parts if p not in ("/", "..", ".", "")]
|
|
48
|
+
parts = [p.replace(":", "_") for p in parts]
|
|
49
|
+
return PurePosixPath(*parts) if parts else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build(state: State, options: dict) -> dict:
|
|
53
|
+
inputs = sorted(state.uploads.iterdir())
|
|
54
|
+
inputs += [Path(p.strip().strip('"')) for p in options.get("paths") or [] if p.strip()]
|
|
55
|
+
if not inputs:
|
|
56
|
+
raise ValueError("Add at least one file or folder first.")
|
|
57
|
+
me = [a.strip() for a in str(options.get("me") or "").split(",") if a.strip()]
|
|
58
|
+
notes: list[str] = []
|
|
59
|
+
items = sources.load(inputs, me=me or None, pdf=bool(options.get("pdf")), notes=notes)
|
|
60
|
+
from .pipeline import local_tz
|
|
61
|
+
payload = analysis.analyse(
|
|
62
|
+
items, tz=local_tz(), n_topics=int(options["topics"]) if options.get("topics") else None,
|
|
63
|
+
private=bool(options.get("private")), title=str(options.get("title") or "My Brainplug"),
|
|
64
|
+
names=sources.topic_names_in(inputs),
|
|
65
|
+
)
|
|
66
|
+
state.result = render_html(payload, lang=str(options.get("lang") or "en"))
|
|
67
|
+
return {
|
|
68
|
+
"ok": True, "items": len(items), "notes": notes,
|
|
69
|
+
"sources": payload["numbers"]["sources"],
|
|
70
|
+
"topics": [{"name": t["name"], "count": t["count"]} for t in payload["topics"]],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def make_handler(state: State):
|
|
75
|
+
class Handler(BaseHTTPRequestHandler):
|
|
76
|
+
server_version = "Brainplug"
|
|
77
|
+
|
|
78
|
+
def log_message(self, *args) -> None: # keep the terminal quiet
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
def _send(self, code: int, body: bytes | str, ctype: str = "application/json", extra: dict | None = None):
|
|
82
|
+
data = body.encode("utf-8") if isinstance(body, str) else body
|
|
83
|
+
self.send_response(code)
|
|
84
|
+
self.send_header("Content-Type", f"{ctype}; charset=utf-8")
|
|
85
|
+
self.send_header("Content-Length", str(len(data)))
|
|
86
|
+
self.send_header("Cache-Control", "no-store")
|
|
87
|
+
for key, value in (extra or {}).items():
|
|
88
|
+
self.send_header(key, value)
|
|
89
|
+
self.end_headers()
|
|
90
|
+
self.wfile.write(data)
|
|
91
|
+
|
|
92
|
+
def _allowed(self, query: dict) -> bool:
|
|
93
|
+
host = (self.headers.get("Host") or "").split(":")[0]
|
|
94
|
+
token = self.headers.get("X-Brainplug-Token") or (query.get("t") or [""])[0]
|
|
95
|
+
return host in ("127.0.0.1", "localhost") and secrets.compare_digest(token, state.token)
|
|
96
|
+
|
|
97
|
+
def _route(self):
|
|
98
|
+
url = urlparse(self.path)
|
|
99
|
+
query = parse_qs(url.query)
|
|
100
|
+
if not self._allowed(query):
|
|
101
|
+
self._send(403, json.dumps({"ok": False, "error": "Forbidden"}))
|
|
102
|
+
return None, None
|
|
103
|
+
return url.path, query
|
|
104
|
+
|
|
105
|
+
def do_GET(self):
|
|
106
|
+
path, query = self._route()
|
|
107
|
+
if path == "/":
|
|
108
|
+
self._send(200, PAGE.read_text(encoding="utf-8").replace("__TOKEN__", state.token), "text/html")
|
|
109
|
+
elif path == "/result" and state.result:
|
|
110
|
+
extra = {"Content-Disposition": 'attachment; filename="brainplug.html"'} if "download" in query else None
|
|
111
|
+
self._send(200, state.result, "text/html", extra)
|
|
112
|
+
elif path is not None:
|
|
113
|
+
self._send(404, json.dumps({"ok": False, "error": "Not found"}))
|
|
114
|
+
|
|
115
|
+
def do_PUT(self):
|
|
116
|
+
path, query = self._route()
|
|
117
|
+
if path != "/upload":
|
|
118
|
+
if path is not None:
|
|
119
|
+
self._send(404, json.dumps({"ok": False, "error": "Not found"}))
|
|
120
|
+
return
|
|
121
|
+
rel = safe_relative((query.get("name") or [""])[0])
|
|
122
|
+
length = self.headers.get("Content-Length")
|
|
123
|
+
if rel is None or length is None:
|
|
124
|
+
self._send(400, json.dumps({"ok": False, "error": "Bad upload"}))
|
|
125
|
+
return
|
|
126
|
+
target = state.uploads / rel
|
|
127
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
left = int(length)
|
|
129
|
+
with open(target, "wb") as fh:
|
|
130
|
+
while left > 0:
|
|
131
|
+
chunk = self.rfile.read(min(CHUNK, left))
|
|
132
|
+
if not chunk:
|
|
133
|
+
break
|
|
134
|
+
fh.write(chunk)
|
|
135
|
+
left -= len(chunk)
|
|
136
|
+
self._send(200, json.dumps({"ok": True}))
|
|
137
|
+
|
|
138
|
+
def do_POST(self):
|
|
139
|
+
path, _ = self._route()
|
|
140
|
+
if path is None:
|
|
141
|
+
return
|
|
142
|
+
raw = self.rfile.read(int(self.headers.get("Content-Length") or 0))
|
|
143
|
+
if path == "/reset":
|
|
144
|
+
state.reset()
|
|
145
|
+
self._send(200, json.dumps({"ok": True}))
|
|
146
|
+
elif path == "/build":
|
|
147
|
+
try:
|
|
148
|
+
self._send(200, json.dumps(build(state, json.loads(raw or b"{}"))))
|
|
149
|
+
except Exception as err: # show the reason in the page instead of a stack trace
|
|
150
|
+
self._send(200, json.dumps({"ok": False, "error": str(err) or type(err).__name__}))
|
|
151
|
+
else:
|
|
152
|
+
self._send(404, json.dumps({"ok": False, "error": "Not found"}))
|
|
153
|
+
|
|
154
|
+
return Handler
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def start(port: int = 8765) -> tuple[ThreadingHTTPServer, State]:
|
|
158
|
+
state = State()
|
|
159
|
+
last: Exception | None = None
|
|
160
|
+
for candidate in range(port, port + 20):
|
|
161
|
+
try:
|
|
162
|
+
return ThreadingHTTPServer(("127.0.0.1", candidate), make_handler(state)), state
|
|
163
|
+
except OSError as err:
|
|
164
|
+
last = err
|
|
165
|
+
state.close()
|
|
166
|
+
raise OSError(f"No free port between {port} and {port + 19}") from last
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def serve(port: int = 8765, open_browser: bool = True) -> int:
|
|
170
|
+
server, state = start(port)
|
|
171
|
+
atexit.register(state.close) # also clean up when the terminal window is simply closed
|
|
172
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
173
|
+
url = f"http://127.0.0.1:{server.server_address[1]}/?t={state.token}"
|
|
174
|
+
print(f"Brainplug is running on this computer only:\n\n {url}\n\nPress Ctrl+C to stop.")
|
|
175
|
+
if open_browser:
|
|
176
|
+
webbrowser.open(url)
|
|
177
|
+
try:
|
|
178
|
+
server.serve_forever()
|
|
179
|
+
except KeyboardInterrupt:
|
|
180
|
+
print("\nStopped. Temporary copies deleted.")
|
|
181
|
+
finally:
|
|
182
|
+
server.server_close()
|
|
183
|
+
state.close()
|
|
184
|
+
return 0
|