folderlake 0.2.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.
- folderlake/__init__.py +3 -0
- folderlake/appserver.py +307 -0
- folderlake/assets/echarts.min.js +45 -0
- folderlake/catalog.py +184 -0
- folderlake/cli.py +209 -0
- folderlake/cypher.py +100 -0
- folderlake/extractors.py +360 -0
- folderlake/hierarchy.py +297 -0
- folderlake/home.html +305 -0
- folderlake/llm.py +210 -0
- folderlake/mcp_server.py +331 -0
- folderlake/preview.py +57 -0
- folderlake/registry.py +157 -0
- folderlake/relations.py +103 -0
- folderlake/server.py +329 -0
- folderlake/topics.py +591 -0
- folderlake/viewer.html +397 -0
- folderlake-0.2.0.dist-info/METADATA +278 -0
- folderlake-0.2.0.dist-info/RECORD +23 -0
- folderlake-0.2.0.dist-info/WHEEL +5 -0
- folderlake-0.2.0.dist-info/entry_points.txt +3 -0
- folderlake-0.2.0.dist-info/licenses/LICENSE +21 -0
- folderlake-0.2.0.dist-info/top_level.txt +1 -0
folderlake/extractors.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""One small extractor per file format.
|
|
2
|
+
|
|
3
|
+
Every extractor returns the same shape:
|
|
4
|
+
{"kind": str, "title": str|None, "author": str|None,
|
|
5
|
+
"extra": dict, "text": str}
|
|
6
|
+
|
|
7
|
+
kind is one of: document, text, data, other.
|
|
8
|
+
Adding a format means adding one function here and one registry entry —
|
|
9
|
+
nothing else in the system changes.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sqlite3
|
|
16
|
+
from html.parser import HTMLParser
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
MAX_TEXT_CHARS = 200_000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _clean(text: str) -> str:
|
|
23
|
+
lines = [ln.strip() for ln in text.splitlines()]
|
|
24
|
+
return "\n".join(ln for ln in lines if ln)[:MAX_TEXT_CHARS]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------- text files
|
|
28
|
+
def extract_plain(path: Path) -> dict:
|
|
29
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
30
|
+
title = None
|
|
31
|
+
for ln in raw.splitlines():
|
|
32
|
+
if ln.strip():
|
|
33
|
+
title = ln.strip().lstrip("# ").strip()[:200]
|
|
34
|
+
break
|
|
35
|
+
return {"kind": "text", "title": title, "author": None, "extra": {}, "text": _clean(raw)}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------- html
|
|
39
|
+
class _HTMLText(HTMLParser):
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
super().__init__()
|
|
42
|
+
self.title: str | None = None
|
|
43
|
+
self._in_title = False
|
|
44
|
+
self._skip = 0
|
|
45
|
+
self.parts: list[str] = []
|
|
46
|
+
|
|
47
|
+
def handle_starttag(self, tag, attrs):
|
|
48
|
+
if tag == "title":
|
|
49
|
+
self._in_title = True
|
|
50
|
+
if tag in ("script", "style"):
|
|
51
|
+
self._skip += 1
|
|
52
|
+
|
|
53
|
+
def handle_endtag(self, tag):
|
|
54
|
+
if tag == "title":
|
|
55
|
+
self._in_title = False
|
|
56
|
+
if tag in ("script", "style") and self._skip:
|
|
57
|
+
self._skip -= 1
|
|
58
|
+
|
|
59
|
+
def handle_data(self, data):
|
|
60
|
+
if self._skip:
|
|
61
|
+
return
|
|
62
|
+
if self._in_title:
|
|
63
|
+
self.title = (self.title or "") + data.strip()
|
|
64
|
+
text = data.strip()
|
|
65
|
+
if text:
|
|
66
|
+
self.parts.append(text)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def extract_html(path: Path) -> dict:
|
|
70
|
+
parser = _HTMLText()
|
|
71
|
+
parser.feed(path.read_text(encoding="utf-8", errors="replace"))
|
|
72
|
+
return {
|
|
73
|
+
"kind": "document",
|
|
74
|
+
"title": parser.title,
|
|
75
|
+
"author": None,
|
|
76
|
+
"extra": {},
|
|
77
|
+
"text": _clean("\n".join(parser.parts)),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------- docx
|
|
82
|
+
def extract_docx(path: Path) -> dict:
|
|
83
|
+
import docx # python-docx
|
|
84
|
+
|
|
85
|
+
doc = docx.Document(str(path))
|
|
86
|
+
text = "\n".join(p.text for p in doc.paragraphs)
|
|
87
|
+
props = doc.core_properties
|
|
88
|
+
return {
|
|
89
|
+
"kind": "document",
|
|
90
|
+
"title": props.title or None,
|
|
91
|
+
"author": props.author or None,
|
|
92
|
+
"extra": {"paragraphs": len(doc.paragraphs)},
|
|
93
|
+
"text": _clean(text),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------- pdf
|
|
98
|
+
def extract_pdf(path: Path) -> dict:
|
|
99
|
+
from pypdf import PdfReader
|
|
100
|
+
|
|
101
|
+
reader = PdfReader(str(path))
|
|
102
|
+
text = "\n".join((page.extract_text() or "") for page in reader.pages)
|
|
103
|
+
meta = reader.metadata or {}
|
|
104
|
+
return {
|
|
105
|
+
"kind": "document",
|
|
106
|
+
"title": (getattr(meta, "title", None) or None),
|
|
107
|
+
"author": (getattr(meta, "author", None) or None),
|
|
108
|
+
"extra": {"pages": len(reader.pages)},
|
|
109
|
+
"text": _clean(text),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------- data files
|
|
114
|
+
_SHARED_CON = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _shared_con():
|
|
118
|
+
global _SHARED_CON
|
|
119
|
+
if _SHARED_CON is None:
|
|
120
|
+
import duckdb
|
|
121
|
+
|
|
122
|
+
_SHARED_CON = duckdb.connect(":memory:")
|
|
123
|
+
return _SHARED_CON
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _duckdb_profile(path: Path, reader_sql: str) -> dict:
|
|
127
|
+
"""Schema + row count + per-column nulls, computed by DuckDB itself.
|
|
128
|
+
|
|
129
|
+
Uses one shared in-memory connection across files — opening a fresh
|
|
130
|
+
DuckDB connection per file costs ~200ms and would dominate scan time.
|
|
131
|
+
"""
|
|
132
|
+
con = _shared_con()
|
|
133
|
+
try:
|
|
134
|
+
cols = con.execute(
|
|
135
|
+
f"DESCRIBE SELECT * FROM {reader_sql}"
|
|
136
|
+
).fetchall() # (name, type, ...)
|
|
137
|
+
columns = [{"name": c[0], "type": c[1]} for c in cols]
|
|
138
|
+
row_count = con.execute(
|
|
139
|
+
f"SELECT count(*) FROM {reader_sql}"
|
|
140
|
+
).fetchone()[0]
|
|
141
|
+
nulls = {}
|
|
142
|
+
if columns and row_count:
|
|
143
|
+
exprs = ", ".join(
|
|
144
|
+
f'count(*) - count("{c["name"]}")' for c in columns
|
|
145
|
+
)
|
|
146
|
+
row = con.execute(
|
|
147
|
+
f"SELECT {exprs} FROM {reader_sql}"
|
|
148
|
+
).fetchone()
|
|
149
|
+
nulls = {c["name"]: row[i] for i, c in enumerate(columns)}
|
|
150
|
+
sample_rows = con.execute(
|
|
151
|
+
f"SELECT * FROM {reader_sql} LIMIT 5"
|
|
152
|
+
).fetchall()
|
|
153
|
+
except Exception:
|
|
154
|
+
raise
|
|
155
|
+
# SUMMARIZE: per-column statistics (min/max, approx_unique, avg,
|
|
156
|
+
# null %) — the profile data-file topics are built from, so sample
|
|
157
|
+
# values never have to leak into topic computation.
|
|
158
|
+
summarize = []
|
|
159
|
+
try:
|
|
160
|
+
for r in con.execute(f"SUMMARIZE SELECT * FROM {reader_sql}").fetchall():
|
|
161
|
+
summarize.append({
|
|
162
|
+
"name": r[0], "type": r[1],
|
|
163
|
+
"min": None if r[2] is None else str(r[2]),
|
|
164
|
+
"max": None if r[3] is None else str(r[3]),
|
|
165
|
+
"approx_unique": r[4],
|
|
166
|
+
"avg": None if r[5] is None else str(r[5]),
|
|
167
|
+
"null_percentage": None if r[11] is None else str(r[11]),
|
|
168
|
+
})
|
|
169
|
+
except Exception:
|
|
170
|
+
summarize = []
|
|
171
|
+
text_parts = [
|
|
172
|
+
f"columns: {', '.join(c['name'] for c in columns)}",
|
|
173
|
+
f"rows: {row_count}",
|
|
174
|
+
]
|
|
175
|
+
for r in sample_rows:
|
|
176
|
+
text_parts.append(" | ".join("" if v is None else str(v) for v in r))
|
|
177
|
+
return {
|
|
178
|
+
"kind": "data",
|
|
179
|
+
"title": None,
|
|
180
|
+
"author": None,
|
|
181
|
+
"extra": {
|
|
182
|
+
"columns": columns,
|
|
183
|
+
"row_count": row_count,
|
|
184
|
+
"nulls": nulls,
|
|
185
|
+
"summarize": summarize,
|
|
186
|
+
},
|
|
187
|
+
"text": _clean("\n".join(text_parts)),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _quoted(path: Path) -> str:
|
|
192
|
+
return "'" + str(path).replace("'", "''") + "'"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def extract_csv(path: Path) -> dict:
|
|
196
|
+
delim = "\t" if path.suffix.lower() == ".tsv" else ","
|
|
197
|
+
return _duckdb_profile(path, f"read_csv({_quoted(path)}, header=true, delim='{delim}')")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def extract_json_data(path: Path) -> dict:
|
|
201
|
+
try:
|
|
202
|
+
return _duckdb_profile(path, f"read_json_auto({_quoted(path)})")
|
|
203
|
+
except Exception:
|
|
204
|
+
# Not tabular JSON — treat as text.
|
|
205
|
+
result = extract_plain(path)
|
|
206
|
+
result["kind"] = "text"
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def extract_parquet(path: Path) -> dict:
|
|
211
|
+
return _duckdb_profile(path, f"read_parquet({_quoted(path)})")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def extract_xlsx(path: Path) -> dict:
|
|
215
|
+
import openpyxl
|
|
216
|
+
|
|
217
|
+
wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True)
|
|
218
|
+
sheets = {}
|
|
219
|
+
text_parts = []
|
|
220
|
+
total_rows = 0
|
|
221
|
+
summarize = []
|
|
222
|
+
for ws in wb.worksheets:
|
|
223
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
224
|
+
if not rows:
|
|
225
|
+
sheets[ws.title] = {"rows": 0, "columns": []}
|
|
226
|
+
continue
|
|
227
|
+
header = ["" if h is None else str(h) for h in rows[0]]
|
|
228
|
+
sheets[ws.title] = {"rows": len(rows) - 1, "columns": header}
|
|
229
|
+
total_rows += len(rows) - 1
|
|
230
|
+
text_parts.append(f"sheet: {ws.title}; columns: {', '.join(header)}")
|
|
231
|
+
for r in rows[1:6]:
|
|
232
|
+
text_parts.append(" | ".join("" if v is None else str(v) for v in r))
|
|
233
|
+
if not summarize and rows[1:]:
|
|
234
|
+
# SUMMARIZE-equivalent for the first sheet, computed from the
|
|
235
|
+
# rows already in memory: distinct count (capped set), min/max
|
|
236
|
+
# over comparable values, null percentage.
|
|
237
|
+
body = rows[1:]
|
|
238
|
+
for ci, cname in enumerate(header):
|
|
239
|
+
vals = [r[ci] for r in body if ci < len(r) and r[ci] is not None]
|
|
240
|
+
distinct = len({str(v) for v in vals[:5000]})
|
|
241
|
+
nums = [v for v in vals if isinstance(v, (int, float))]
|
|
242
|
+
pool = nums if nums else [str(v) for v in vals]
|
|
243
|
+
summarize.append({
|
|
244
|
+
"name": cname, "type": "unknown",
|
|
245
|
+
"min": None if not pool else str(min(pool)),
|
|
246
|
+
"max": None if not pool else str(max(pool)),
|
|
247
|
+
"approx_unique": distinct,
|
|
248
|
+
"avg": (str(round(sum(nums) / len(nums), 4)) if nums else None),
|
|
249
|
+
"null_percentage": str(round(
|
|
250
|
+
100.0 * (len(body) - len(vals)) / len(body), 2)),
|
|
251
|
+
})
|
|
252
|
+
wb.close()
|
|
253
|
+
first = next(iter(sheets.values()), {"columns": []})
|
|
254
|
+
return {
|
|
255
|
+
"kind": "data",
|
|
256
|
+
"title": None,
|
|
257
|
+
"author": None,
|
|
258
|
+
"extra": {
|
|
259
|
+
"sheets": sheets,
|
|
260
|
+
"row_count": total_rows,
|
|
261
|
+
"columns": [{"name": c, "type": "unknown"} for c in first["columns"]],
|
|
262
|
+
"nulls": {},
|
|
263
|
+
"summarize": summarize,
|
|
264
|
+
},
|
|
265
|
+
"text": _clean("\n".join(text_parts)),
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def extract_sqlite(path: Path) -> dict:
|
|
270
|
+
con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
271
|
+
try:
|
|
272
|
+
tables = [
|
|
273
|
+
r[0]
|
|
274
|
+
for r in con.execute(
|
|
275
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
276
|
+
)
|
|
277
|
+
]
|
|
278
|
+
text_parts = []
|
|
279
|
+
columns_all = []
|
|
280
|
+
total = 0
|
|
281
|
+
summarize = []
|
|
282
|
+
for t in tables:
|
|
283
|
+
cols = [r[1] for r in con.execute(f'PRAGMA table_info("{t}")')]
|
|
284
|
+
n = con.execute(f'SELECT count(*) FROM "{t}"').fetchone()[0]
|
|
285
|
+
total += n
|
|
286
|
+
columns_all += cols
|
|
287
|
+
text_parts.append(f"table: {t}; columns: {', '.join(cols)}; rows: {n}")
|
|
288
|
+
for c in cols:
|
|
289
|
+
try:
|
|
290
|
+
cnt, uniq, mn, mx, avg = con.execute(
|
|
291
|
+
f'SELECT count("{c}"), count(DISTINCT "{c}"), min("{c}"), '
|
|
292
|
+
f'max("{c}"), avg("{c}") FROM "{t}"'
|
|
293
|
+
).fetchone()
|
|
294
|
+
except Exception:
|
|
295
|
+
cnt, uniq, mn, mx, avg = con.execute(
|
|
296
|
+
f'SELECT count("{c}"), count(DISTINCT "{c}"), min("{c}"), '
|
|
297
|
+
f'max("{c}") , NULL FROM "{t}"'
|
|
298
|
+
).fetchone()
|
|
299
|
+
summarize.append({
|
|
300
|
+
"name": c, "type": "unknown", "table": t,
|
|
301
|
+
"min": None if mn is None else str(mn),
|
|
302
|
+
"max": None if mx is None else str(mx),
|
|
303
|
+
"approx_unique": uniq,
|
|
304
|
+
"avg": None if avg is None else str(avg),
|
|
305
|
+
"null_percentage": (
|
|
306
|
+
str(round(100.0 * (n - cnt) / n, 2)) if n else "0"
|
|
307
|
+
),
|
|
308
|
+
})
|
|
309
|
+
finally:
|
|
310
|
+
con.close()
|
|
311
|
+
return {
|
|
312
|
+
"kind": "data",
|
|
313
|
+
"title": None,
|
|
314
|
+
"author": None,
|
|
315
|
+
"extra": {
|
|
316
|
+
"tables": tables,
|
|
317
|
+
"row_count": total,
|
|
318
|
+
"columns": [{"name": c, "type": "unknown"} for c in columns_all],
|
|
319
|
+
"nulls": {},
|
|
320
|
+
"summarize": summarize,
|
|
321
|
+
},
|
|
322
|
+
"text": _clean("\n".join(text_parts)),
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def extract_other(path: Path) -> dict:
|
|
327
|
+
return {"kind": "other", "title": None, "author": None, "extra": {}, "text": ""}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
EXTRACTORS = {
|
|
331
|
+
".txt": extract_plain,
|
|
332
|
+
".md": extract_plain,
|
|
333
|
+
".html": extract_html,
|
|
334
|
+
".htm": extract_html,
|
|
335
|
+
".docx": extract_docx,
|
|
336
|
+
".pdf": extract_pdf,
|
|
337
|
+
".csv": extract_csv,
|
|
338
|
+
".tsv": extract_csv,
|
|
339
|
+
".json": extract_json_data,
|
|
340
|
+
".parquet": extract_parquet,
|
|
341
|
+
".xlsx": extract_xlsx,
|
|
342
|
+
".sqlite": extract_sqlite,
|
|
343
|
+
".db": extract_sqlite,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
SUPPORTED_EXTS = set(EXTRACTORS)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def extract(path: Path) -> dict:
|
|
350
|
+
fn = EXTRACTORS.get(path.suffix.lower(), extract_other)
|
|
351
|
+
try:
|
|
352
|
+
return fn(path)
|
|
353
|
+
except Exception as exc: # a broken file must never kill a scan
|
|
354
|
+
return {
|
|
355
|
+
"kind": "other",
|
|
356
|
+
"title": None,
|
|
357
|
+
"author": None,
|
|
358
|
+
"extra": {"extract_error": str(exc)[:300]},
|
|
359
|
+
"text": "",
|
|
360
|
+
}
|
folderlake/hierarchy.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Hierarchical topic tree — agglomerative clustering per file kind.
|
|
2
|
+
|
|
3
|
+
Flat per-file tags stay as metadata; the *organisation* of topics is
|
|
4
|
+
this tree. Files of each kind (document, data, text) are clustered
|
|
5
|
+
separately — the kinds are never mixed, per the product decision that
|
|
6
|
+
their browsing scenarios differ.
|
|
7
|
+
|
|
8
|
+
Method: corpus TF-IDF vectors from the stored per-file term counts
|
|
9
|
+
(log TF x IDF, L2-normalised, light DF hygiene), then agglomerative
|
|
10
|
+
clustering with centroid linkage. The merge tree becomes a display
|
|
11
|
+
tree of at most MAX_DEPTH levels: a sub-cluster earns its own node
|
|
12
|
+
when it holds at least MIN_GROUP files (smaller branches simply stay
|
|
13
|
+
covered by their parent's member list), and a single child that holds
|
|
14
|
+
essentially all of its parent's files replaces the parent instead of
|
|
15
|
+
adding an empty level. Node labels are the cluster's top phrase terms
|
|
16
|
+
(the same bonus/suppression rules as per-file tags, in topics.py).
|
|
17
|
+
|
|
18
|
+
topic_node_files stores each node's FULL subtree membership, so
|
|
19
|
+
filtering by any node is a single lookup.
|
|
20
|
+
|
|
21
|
+
Scale honesty: centroid agglomeration is O(N^2) pairwise work, so a
|
|
22
|
+
kind with more than MAX_FILES_PER_KIND files gets no tree (the viewer
|
|
23
|
+
falls back to the flat topic list) rather than a slow scan.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import math
|
|
29
|
+
from collections import Counter
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import duckdb
|
|
33
|
+
|
|
34
|
+
from .topics import _select_tags, _term_bonus
|
|
35
|
+
|
|
36
|
+
MIN_GROUP = 3
|
|
37
|
+
MAX_DEPTH = 4
|
|
38
|
+
# A cluster bigger than this whose halves both qualify as groups is
|
|
39
|
+
# split at the SAME display level instead of becoming one wide node —
|
|
40
|
+
# otherwise a binary merge tree gives every level at most 2 children
|
|
41
|
+
# and the tree stays sparse.
|
|
42
|
+
WIDE_SPLIT = 12
|
|
43
|
+
MAX_FILES_PER_KIND = 400
|
|
44
|
+
|
|
45
|
+
KIND_LABELS = {"document": "Documents", "data": "Data files", "text": "Texts"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ----------------------------------------------------------------------
|
|
49
|
+
# Vectors
|
|
50
|
+
# ----------------------------------------------------------------------
|
|
51
|
+
def _vectors(terms_by_file: dict[str, Counter]) -> dict[str, dict[str, float]]:
|
|
52
|
+
n = len(terms_by_file)
|
|
53
|
+
df: Counter = Counter()
|
|
54
|
+
for counts in terms_by_file.values():
|
|
55
|
+
df.update(counts.keys())
|
|
56
|
+
vectors: dict[str, dict[str, float]] = {}
|
|
57
|
+
for fid, counts in terms_by_file.items():
|
|
58
|
+
vec: dict[str, float] = {}
|
|
59
|
+
for term, cnt in counts.items():
|
|
60
|
+
d = df[term]
|
|
61
|
+
if n >= 10 and (d < 2 or d > 0.7 * n):
|
|
62
|
+
continue # DF hygiene: hapax noise + corpus wallpaper
|
|
63
|
+
idf = math.log((n + 1) / (d + 1)) + 1.0
|
|
64
|
+
vec[term] = math.log1p(cnt) * idf
|
|
65
|
+
norm = math.sqrt(sum(v * v for v in vec.values())) or 1.0
|
|
66
|
+
vectors[fid] = {t: v / norm for t, v in vec.items()}
|
|
67
|
+
return vectors
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cosine(a: dict[str, float], b: dict[str, float]) -> float:
|
|
71
|
+
if len(a) > len(b):
|
|
72
|
+
a, b = b, a
|
|
73
|
+
return sum(v * b.get(t, 0.0) for t, v in a.items())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ----------------------------------------------------------------------
|
|
77
|
+
# Agglomerative clustering (centroid linkage)
|
|
78
|
+
# ----------------------------------------------------------------------
|
|
79
|
+
class _Cluster:
|
|
80
|
+
__slots__ = ("members", "total", "left", "right", "sim")
|
|
81
|
+
|
|
82
|
+
def __init__(self, members, total, left=None, right=None, sim=0.0):
|
|
83
|
+
self.members = members # file ids in this subtree
|
|
84
|
+
self.total = total # summed member vectors (dict)
|
|
85
|
+
self.left, self.right, self.sim = left, right, sim
|
|
86
|
+
|
|
87
|
+
def centroid(self) -> dict[str, float]:
|
|
88
|
+
norm = math.sqrt(sum(v * v for v in self.total.values())) or 1.0
|
|
89
|
+
inv = 1.0 / norm
|
|
90
|
+
return {t: v * inv for t, v in self.total.items()}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _agglomerate(vectors: dict[str, dict[str, float]]) -> _Cluster:
|
|
94
|
+
active: dict[int, _Cluster] = {
|
|
95
|
+
i: _Cluster([fid], dict(vec))
|
|
96
|
+
for i, (fid, vec) in enumerate(vectors.items())
|
|
97
|
+
}
|
|
98
|
+
sims: dict[tuple[int, int], float] = {}
|
|
99
|
+
|
|
100
|
+
def sim(i: int, j: int) -> float:
|
|
101
|
+
key = (i, j) if i < j else (j, i)
|
|
102
|
+
if key not in sims:
|
|
103
|
+
sims[key] = _cosine(active[i].centroid(), active[j].centroid())
|
|
104
|
+
return sims[key]
|
|
105
|
+
|
|
106
|
+
next_id = len(active)
|
|
107
|
+
while len(active) > 1:
|
|
108
|
+
ids = list(active)
|
|
109
|
+
best, best_pair = -2.0, None
|
|
110
|
+
for x in range(len(ids)):
|
|
111
|
+
for y in range(x + 1, len(ids)):
|
|
112
|
+
s = sim(ids[x], ids[y])
|
|
113
|
+
if s > best:
|
|
114
|
+
best, best_pair = s, (ids[x], ids[y])
|
|
115
|
+
i, j = best_pair
|
|
116
|
+
a, b = active.pop(i), active.pop(j)
|
|
117
|
+
total = dict(a.total)
|
|
118
|
+
for t, v in b.total.items():
|
|
119
|
+
total[t] = total.get(t, 0.0) + v
|
|
120
|
+
active[next_id] = _Cluster(a.members + b.members, total,
|
|
121
|
+
left=a, right=b, sim=max(best, 0.0))
|
|
122
|
+
sims = {k: v for k, v in sims.items() if i not in k and j not in k}
|
|
123
|
+
next_id += 1
|
|
124
|
+
return next(iter(active.values()))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ----------------------------------------------------------------------
|
|
128
|
+
# Display tree
|
|
129
|
+
# ----------------------------------------------------------------------
|
|
130
|
+
def _label(cluster: _Cluster, corpus_total: dict[str, float],
|
|
131
|
+
corpus_size: float) -> str:
|
|
132
|
+
"""Label by lift: terms over-represented in this cluster versus the
|
|
133
|
+
whole kind corpus. Raw frequency crowns corpus wallpaper ('more',
|
|
134
|
+
header boilerplate) that appears everywhere at the same rate; lift
|
|
135
|
+
sends those to zero and surfaces what the group is actually about.
|
|
136
|
+
"""
|
|
137
|
+
cluster_size = sum(cluster.total.values()) or 1.0
|
|
138
|
+
ranked = []
|
|
139
|
+
for term, value in cluster.total.items():
|
|
140
|
+
share_in = value / cluster_size
|
|
141
|
+
share_all = corpus_total.get(term, 0.0) / (corpus_size or 1.0)
|
|
142
|
+
lift = math.log((share_in + 1e-9) / (share_all + 1e-9))
|
|
143
|
+
if lift > 0:
|
|
144
|
+
ranked.append((term, value * lift * _term_bonus(term)))
|
|
145
|
+
ranked.sort(key=lambda kv: -kv[1])
|
|
146
|
+
top = _select_tags(ranked, Counter(cluster.total), limit=3)
|
|
147
|
+
# Display polish: a bare "sex" (the dataset column) reads as a
|
|
148
|
+
# content warning in a topic label. If the other picked terms or
|
|
149
|
+
# the runners-up can carry the label, drop it; keep it only when
|
|
150
|
+
# nothing else is available.
|
|
151
|
+
picked = [t for t, _ in top]
|
|
152
|
+
if "sex" in picked and len(picked) > 1:
|
|
153
|
+
picked = [t for t in picked if t != "sex"]
|
|
154
|
+
for t, _ in ranked:
|
|
155
|
+
if len(picked) >= 3:
|
|
156
|
+
break
|
|
157
|
+
if t not in picked and t != "sex":
|
|
158
|
+
picked.append(t)
|
|
159
|
+
return " · ".join(picked) or "misc"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _should_flatten(cluster: _Cluster) -> bool:
|
|
163
|
+
"""A forced marriage: two internally-cohesive halves that barely
|
|
164
|
+
resemble each other. Such a node gets no display level — its
|
|
165
|
+
children are promoted to its parent instead. (A binary dendrogram
|
|
166
|
+
otherwise invents meaningless top groups, e.g. finance+garden
|
|
167
|
+
labelled 'finance'.)"""
|
|
168
|
+
if cluster.left is None or cluster.right is None:
|
|
169
|
+
return False
|
|
170
|
+
internal = min(cluster.left.sim, cluster.right.sim)
|
|
171
|
+
return internal > 0 and cluster.sim < 0.4 * internal
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _child_clusters(cluster: _Cluster, depth: int) -> list[_Cluster]:
|
|
175
|
+
"""The frontier of sub-clusters that become nodes at depth + 1.
|
|
176
|
+
|
|
177
|
+
Walk down the merge tree from this cluster's halves: a branch
|
|
178
|
+
becomes a node when it is a qualifying group; we keep descending
|
|
179
|
+
through branches that are forced marriages (see _should_flatten)
|
|
180
|
+
or simply too wide to be one topic (WIDE_SPLIT).
|
|
181
|
+
"""
|
|
182
|
+
if cluster.left is None or depth >= MAX_DEPTH:
|
|
183
|
+
return []
|
|
184
|
+
out: list[_Cluster] = []
|
|
185
|
+
stack = [cluster.left, cluster.right]
|
|
186
|
+
while stack:
|
|
187
|
+
c = stack.pop()
|
|
188
|
+
if len(c.members) < MIN_GROUP:
|
|
189
|
+
continue
|
|
190
|
+
if c.left is not None and (
|
|
191
|
+
_should_flatten(c) or len(c.members) > WIDE_SPLIT):
|
|
192
|
+
stack.extend([c.left, c.right])
|
|
193
|
+
else:
|
|
194
|
+
out.append(c)
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def build_tree_for_kind(root_cluster: _Cluster, kind: str,
|
|
199
|
+
corpus_total: dict[str, float] | None = None
|
|
200
|
+
) -> list[dict]:
|
|
201
|
+
"""Flatten a merge tree into display nodes.
|
|
202
|
+
|
|
203
|
+
Each node dict: prefixed id, parent id (or None), kind, label,
|
|
204
|
+
depth, members (FULL subtree file ids), cohesion.
|
|
205
|
+
"""
|
|
206
|
+
if corpus_total is None:
|
|
207
|
+
corpus_total = dict(root_cluster.total)
|
|
208
|
+
corpus_size = sum(corpus_total.values()) or 1.0
|
|
209
|
+
nodes: list[dict] = []
|
|
210
|
+
|
|
211
|
+
def emit(cluster: _Cluster, parent_id: str | None, depth: int,
|
|
212
|
+
label: str, cohesion: float) -> None:
|
|
213
|
+
kids = _child_clusters(cluster, depth)
|
|
214
|
+
# Chain collapse: one child holding (nearly) every file means
|
|
215
|
+
# this level adds nothing — emit the child in our place. The
|
|
216
|
+
# kind root is exempt: it carries the kind label.
|
|
217
|
+
if parent_id is not None and len(kids) == 1 and \
|
|
218
|
+
len(kids[0].members) >= len(cluster.members) - (MIN_GROUP - 1):
|
|
219
|
+
emit(kids[0], parent_id, depth,
|
|
220
|
+
_label(kids[0], corpus_total, corpus_size), kids[0].sim)
|
|
221
|
+
return
|
|
222
|
+
node_id = f"{kind}-{len(nodes) + 1}"
|
|
223
|
+
nodes.append({
|
|
224
|
+
"id": node_id, "parent": parent_id, "kind": kind,
|
|
225
|
+
"label": label, "depth": depth,
|
|
226
|
+
"members": list(cluster.members),
|
|
227
|
+
"cohesion": round(cohesion, 3),
|
|
228
|
+
})
|
|
229
|
+
for c in kids:
|
|
230
|
+
emit(c, node_id, depth + 1,
|
|
231
|
+
_label(c, corpus_total, corpus_size), c.sim)
|
|
232
|
+
|
|
233
|
+
emit(root_cluster, None, 0, KIND_LABELS.get(kind, kind), 1.0)
|
|
234
|
+
return nodes
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ----------------------------------------------------------------------
|
|
238
|
+
# Build + store
|
|
239
|
+
# ----------------------------------------------------------------------
|
|
240
|
+
def build_hierarchy(db_path: Path) -> dict:
|
|
241
|
+
con = duckdb.connect(str(db_path))
|
|
242
|
+
try:
|
|
243
|
+
con.execute(
|
|
244
|
+
"CREATE TABLE IF NOT EXISTS topic_nodes ("
|
|
245
|
+
"id VARCHAR PRIMARY KEY, parent_id VARCHAR, kind VARCHAR, "
|
|
246
|
+
"label VARCHAR, depth INTEGER, file_count INTEGER, cohesion DOUBLE)"
|
|
247
|
+
)
|
|
248
|
+
con.execute(
|
|
249
|
+
"CREATE TABLE IF NOT EXISTS topic_node_files ("
|
|
250
|
+
"node_id VARCHAR, file_id VARCHAR)"
|
|
251
|
+
)
|
|
252
|
+
terms: dict[str, dict[str, Counter]] = {}
|
|
253
|
+
for fid, kind, term, cnt in con.execute(
|
|
254
|
+
"SELECT f.id, f.kind, t.term, t.cnt FROM files f "
|
|
255
|
+
"JOIN file_terms t ON t.file_id = f.id"
|
|
256
|
+
).fetchall():
|
|
257
|
+
terms.setdefault(kind, {}).setdefault(fid, Counter())[term] = cnt
|
|
258
|
+
all_files: dict[str, list[str]] = {}
|
|
259
|
+
for fid, kind in con.execute("SELECT id, kind FROM files").fetchall():
|
|
260
|
+
all_files.setdefault(kind, []).append(fid)
|
|
261
|
+
|
|
262
|
+
node_rows, member_rows = [], []
|
|
263
|
+
skipped: dict[str, int] = {}
|
|
264
|
+
for kind, file_ids in sorted(all_files.items()):
|
|
265
|
+
if len(file_ids) > MAX_FILES_PER_KIND:
|
|
266
|
+
skipped[kind] = len(file_ids)
|
|
267
|
+
continue
|
|
268
|
+
terms_by_file = terms.get(kind, {})
|
|
269
|
+
vectors = _vectors({fid: terms_by_file.get(fid, Counter())
|
|
270
|
+
for fid in file_ids})
|
|
271
|
+
corpus_total: dict[str, float] = {}
|
|
272
|
+
for vec in vectors.values():
|
|
273
|
+
for t, v in vec.items():
|
|
274
|
+
corpus_total[t] = corpus_total.get(t, 0.0) + v
|
|
275
|
+
tree = build_tree_for_kind(_agglomerate(vectors), kind,
|
|
276
|
+
corpus_total)
|
|
277
|
+
for node in tree:
|
|
278
|
+
node_rows.append((
|
|
279
|
+
node["id"], node["parent"], kind, node["label"],
|
|
280
|
+
node["depth"], len(node["members"]), node["cohesion"],
|
|
281
|
+
))
|
|
282
|
+
member_rows.extend(
|
|
283
|
+
(node["id"], fid) for fid in node["members"])
|
|
284
|
+
con.execute("BEGIN TRANSACTION")
|
|
285
|
+
con.execute("DELETE FROM topic_nodes")
|
|
286
|
+
con.execute("DELETE FROM topic_node_files")
|
|
287
|
+
if node_rows:
|
|
288
|
+
con.executemany("INSERT INTO topic_nodes VALUES (?,?,?,?,?,?,?)",
|
|
289
|
+
node_rows)
|
|
290
|
+
if member_rows:
|
|
291
|
+
con.executemany("INSERT INTO topic_node_files VALUES (?,?)",
|
|
292
|
+
member_rows)
|
|
293
|
+
con.execute("COMMIT")
|
|
294
|
+
return {"nodes": len(node_rows), "kinds": sorted(all_files),
|
|
295
|
+
"skipped_too_large": skipped}
|
|
296
|
+
finally:
|
|
297
|
+
con.close()
|