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.
@@ -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
+ }
@@ -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()