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/topics.py
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
"""Topic extraction v2: corpus TF-IDF + hotspot segments + optional LLM.
|
|
2
|
+
|
|
3
|
+
Design (agreed 2026-11-13):
|
|
4
|
+
- Topics are a *post-scan* step, not per-file during extraction, because
|
|
5
|
+
TF-IDF needs corpus statistics (document frequency across the folder).
|
|
6
|
+
- Per-file term counts are stored in `file_terms` at scan time, so adding
|
|
7
|
+
a file never re-reads or re-extracts unchanged files: DF is recomputed
|
|
8
|
+
from the stored counts (a DuckDB GROUP BY), rule tags are recomputed
|
|
9
|
+
from those counts in memory, and the LLM is called only for
|
|
10
|
+
new/changed files (tracked in `file_topic_meta` by content hash).
|
|
11
|
+
- Incremental cost rule: an unchanged file only gets its tag weights
|
|
12
|
+
re-scored from stored counts — its summary and entities (pure content
|
|
13
|
+
derivations) are kept, and its text is never re-tokenized. Only
|
|
14
|
+
new/changed files pay the full segment/entity/LLM treatment.
|
|
15
|
+
- Documents: tags come from TF-IDF (distinctive terms) with title and
|
|
16
|
+
filename terms boosted (folder-wide theme words would otherwise be
|
|
17
|
+
suppressed by IDF alone). The LLM package, when --llm is on, is the
|
|
18
|
+
filename/title + top TF terms + top TF-IDF terms + hotspot segments
|
|
19
|
+
(best-scoring paragraph from each third of the document), capped at
|
|
20
|
+
~10k chars instead of a blind head slice.
|
|
21
|
+
- Data files take a separate path: their terms come only from schema
|
|
22
|
+
identifiers (file/table/sheet/column names, split on snake_case and
|
|
23
|
+
camelCase) and their summary/LLM input is the DESCRIBE + SUMMARIZE
|
|
24
|
+
profile card. Sample-row values never enter topic computation.
|
|
25
|
+
- Tokenizer: English words plus CJK character bigrams (zero-dependency
|
|
26
|
+
segmentation that works for Chinese text, which the v1 English-only
|
|
27
|
+
regex could not tag at all).
|
|
28
|
+
- v3 phrases: tags should read as concepts, not single words, so the
|
|
29
|
+
tokenizer also emits English word bigrams ("personal finance") and
|
|
30
|
+
CJK trigrams ("自行车"), and data files emit joined column-name phrases
|
|
31
|
+
("total bill"). At selection time phrases get a length bonus and
|
|
32
|
+
suppress their own components (no "finance" next to "personal
|
|
33
|
+
finance", no "自行" next to "自行车"). Catalogs built before v3 are
|
|
34
|
+
migrated in compute_topics by recomputing term counts from the stored
|
|
35
|
+
text — no file re-extraction (tracked by a terms_version marker).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import math
|
|
42
|
+
import re
|
|
43
|
+
from collections import Counter
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
from . import llm
|
|
47
|
+
|
|
48
|
+
STOPWORDS = set(
|
|
49
|
+
"""the and for are but not you all any can had her was one our out has have been
|
|
50
|
+
this that with they from were will would there their what when make like just over
|
|
51
|
+
such take year them well back than been good much some time very know see your come
|
|
52
|
+
could now than first into its who after how said each about many then she may these
|
|
53
|
+
file files data table columns rows column row name value values using use used
|
|
54
|
+
more quot amp http https www com org net html htm
|
|
55
|
+
xlsx parquet sqlite json csv tsv sheet sheets format formats""".split()
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
EN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_\-]*")
|
|
59
|
+
CJK_RE = re.compile(r"[\u3400-\u9fff]+")
|
|
60
|
+
CJK_STOP_CHARS = set("的了是在和与及对为以之也都而被把让从向于中上下不无有无其此该等")
|
|
61
|
+
ENTITY_RE = re.compile(r"\b([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+){0,2})\b")
|
|
62
|
+
TOPIC_PACKAGE_CHARS = 10_000
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --------------------------------------------------------------------------
|
|
66
|
+
# Tokenizing
|
|
67
|
+
# --------------------------------------------------------------------------
|
|
68
|
+
def _split_identifier(token: str) -> list[str]:
|
|
69
|
+
parts: list[str] = []
|
|
70
|
+
for chunk in re.split(r"[_\-]+", token):
|
|
71
|
+
parts.extend(
|
|
72
|
+
p.lower()
|
|
73
|
+
for p in re.findall(r"[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+", chunk)
|
|
74
|
+
)
|
|
75
|
+
return parts
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
PHRASE_STOP = set(
|
|
79
|
+
"""the and for are with from this that was were have has been will would
|
|
80
|
+
their there its it in on of to a an is as at by or not but you all any
|
|
81
|
+
can had her one our out more http https www quot amp""".split()
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def tokenize(text: str) -> list[str]:
|
|
86
|
+
"""English words + word bigrams, CJK bigrams + trigrams, filtered."""
|
|
87
|
+
terms: list[str] = []
|
|
88
|
+
seq: list[str] = [] # phrase-eligible word sequence (keeps short words)
|
|
89
|
+
for raw in EN_RE.findall(text or ""):
|
|
90
|
+
for part in _split_identifier(raw):
|
|
91
|
+
if part.isdigit():
|
|
92
|
+
continue
|
|
93
|
+
if len(part) >= 3 and part not in STOPWORDS:
|
|
94
|
+
terms.append(part)
|
|
95
|
+
if len(part) >= 2 and part not in PHRASE_STOP:
|
|
96
|
+
seq.append(part)
|
|
97
|
+
for a, b in zip(seq, seq[1:]):
|
|
98
|
+
if a != b:
|
|
99
|
+
terms.append(f"{a} {b}")
|
|
100
|
+
for run in CJK_RE.findall(text or ""):
|
|
101
|
+
if len(run) == 1:
|
|
102
|
+
continue
|
|
103
|
+
for i in range(len(run) - 1):
|
|
104
|
+
bg = run[i : i + 2]
|
|
105
|
+
if bg[0] in CJK_STOP_CHARS and bg[1] in CJK_STOP_CHARS:
|
|
106
|
+
continue
|
|
107
|
+
terms.append(bg)
|
|
108
|
+
for i in range(len(run) - 2):
|
|
109
|
+
tg = run[i : i + 3]
|
|
110
|
+
if tg[0] in CJK_STOP_CHARS and tg[2] in CJK_STOP_CHARS:
|
|
111
|
+
continue
|
|
112
|
+
terms.append(tg)
|
|
113
|
+
return terms
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def identifier_tokens(name: str) -> list[str]:
|
|
117
|
+
return [
|
|
118
|
+
p
|
|
119
|
+
for p in _split_identifier(name or "")
|
|
120
|
+
if len(p) >= 3 and p not in STOPWORDS and not p.isdigit()
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def term_counts_for(result: dict, filename: str) -> Counter:
|
|
125
|
+
"""Term counts stored per file at scan time (the incremental DF base).
|
|
126
|
+
|
|
127
|
+
Data files count schema identifiers only — never sample-row values.
|
|
128
|
+
Everything else counts tokens over the extracted text.
|
|
129
|
+
"""
|
|
130
|
+
if result.get("kind") == "data":
|
|
131
|
+
extra = result.get("extra") or {}
|
|
132
|
+
counts: Counter = Counter()
|
|
133
|
+
for tok in identifier_tokens(Path(filename).stem):
|
|
134
|
+
counts[tok] += 2
|
|
135
|
+
names: list[str] = []
|
|
136
|
+
for col in extra.get("columns", []):
|
|
137
|
+
names.append(col.get("name", "") if isinstance(col, dict) else str(col))
|
|
138
|
+
names.extend(extra.get("tables", []))
|
|
139
|
+
names.extend((extra.get("sheets") or {}).keys())
|
|
140
|
+
for n in names:
|
|
141
|
+
parts = identifier_tokens(n)
|
|
142
|
+
for tok in parts:
|
|
143
|
+
counts[tok] += 1
|
|
144
|
+
if len(parts) >= 2:
|
|
145
|
+
# A column name is a deliberate label, not prose: its
|
|
146
|
+
# phrase form gets the same ×2 weight as a filename.
|
|
147
|
+
counts[" ".join(parts)] += 2
|
|
148
|
+
stem_parts = identifier_tokens(Path(filename).stem)
|
|
149
|
+
if len(stem_parts) >= 2:
|
|
150
|
+
counts[" ".join(stem_parts)] += 2
|
|
151
|
+
return counts
|
|
152
|
+
return Counter(tokenize(result.get("text") or ""))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def extract_entities(text: str) -> list[str]:
|
|
156
|
+
freq = Counter(m.group(1) for m in ENTITY_RE.finditer(text or ""))
|
|
157
|
+
out = []
|
|
158
|
+
for ent, cnt in freq.most_common(8):
|
|
159
|
+
if cnt >= 2 and ent.lower() not in STOPWORDS:
|
|
160
|
+
out.append(ent)
|
|
161
|
+
return out[:5]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# --------------------------------------------------------------------------
|
|
165
|
+
# Hotspot segments
|
|
166
|
+
# --------------------------------------------------------------------------
|
|
167
|
+
def _paragraphs(text: str) -> list[str]:
|
|
168
|
+
paras = [p.strip() for p in re.split(r"\n\s*\n", text or "") if p.strip()]
|
|
169
|
+
if len(paras) >= 3:
|
|
170
|
+
return paras
|
|
171
|
+
lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
|
|
172
|
+
chunks, buf = [], ""
|
|
173
|
+
for ln in lines:
|
|
174
|
+
buf = (buf + "\n" + ln) if buf else ln
|
|
175
|
+
if len(buf) >= 600:
|
|
176
|
+
chunks.append(buf)
|
|
177
|
+
buf = ""
|
|
178
|
+
if buf:
|
|
179
|
+
chunks.append(buf)
|
|
180
|
+
return chunks
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def hotspot_segments(text: str, scores: dict[str, float], per_segment: int = 2200) -> list[str]:
|
|
184
|
+
"""Best TF-IDF paragraph from each third of the document, in order."""
|
|
185
|
+
paras = _paragraphs(text)
|
|
186
|
+
if not paras:
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
def score(p: str) -> float:
|
|
190
|
+
toks = tokenize(p)
|
|
191
|
+
if not toks:
|
|
192
|
+
return 0.0
|
|
193
|
+
return sum(scores.get(t, 0.0) for t in toks) / math.sqrt(len(toks))
|
|
194
|
+
|
|
195
|
+
picked: set[int] = {0}
|
|
196
|
+
n = len(paras)
|
|
197
|
+
for third in range(3):
|
|
198
|
+
lo, hi = third * n // 3, max((third + 1) * n // 3, third * n // 3 + 1)
|
|
199
|
+
best = max(range(lo, min(hi, n)), key=lambda i: score(paras[i]))
|
|
200
|
+
picked.add(best)
|
|
201
|
+
picked.add(n - 1)
|
|
202
|
+
return [paras[i][:per_segment] for i in sorted(picked)]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# --------------------------------------------------------------------------
|
|
206
|
+
# LLM tier
|
|
207
|
+
# --------------------------------------------------------------------------
|
|
208
|
+
def _llm_call(prompt: str) -> dict | None:
|
|
209
|
+
"""One LLM round-trip via the provider layer (see llm.py)."""
|
|
210
|
+
parsed = llm.chat_json(prompt)
|
|
211
|
+
if parsed is None:
|
|
212
|
+
return None
|
|
213
|
+
return {
|
|
214
|
+
"tags": [{"tag": str(t), "weight": 1.0} for t in parsed.get("tags", [])[:5]],
|
|
215
|
+
"summary": str(parsed.get("summary", ""))[:200],
|
|
216
|
+
"entities": [str(e) for e in parsed.get("entities", [])[:5]],
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _llm_package_doc(name, title, tf_top, tfidf_top, segments) -> str:
|
|
221
|
+
parts = [
|
|
222
|
+
f"Filename: {name}",
|
|
223
|
+
f"Title: {title or ''}",
|
|
224
|
+
"Frequent terms (theme): " + ", ".join(tf_top),
|
|
225
|
+
"Distinctive TF-IDF terms: " + ", ".join(tfidf_top),
|
|
226
|
+
"Key segments:",
|
|
227
|
+
"\n---\n".join(segments),
|
|
228
|
+
]
|
|
229
|
+
return "\n".join(parts)[:TOPIC_PACKAGE_CHARS]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _llm_package_data(name, profile_text) -> str:
|
|
233
|
+
return f"Filename: {name}\nTable profile (DESCRIBE + SUMMARIZE):\n{profile_text}"[
|
|
234
|
+
:TOPIC_PACKAGE_CHARS
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def profile_card_text(extra: dict, row_count) -> str:
|
|
239
|
+
"""Human/LLM-readable DESCRIBE + SUMMARIZE card for a data file."""
|
|
240
|
+
lines = [f"rows: {row_count}"]
|
|
241
|
+
summ = {s.get("name"): s for s in (extra.get("summarize") or [])}
|
|
242
|
+
for col in extra.get("columns", []):
|
|
243
|
+
cname = col.get("name") if isinstance(col, dict) else str(col)
|
|
244
|
+
ctype = col.get("type", "") if isinstance(col, dict) else ""
|
|
245
|
+
s = summ.get(cname) or {}
|
|
246
|
+
bits = [f"column {cname} ({ctype})"]
|
|
247
|
+
if s:
|
|
248
|
+
bits.append(f"unique≈{s.get('approx_unique')}")
|
|
249
|
+
bits.append(f"min={s.get('min')} max={s.get('max')}")
|
|
250
|
+
bits.append(f"null%={s.get('null_percentage')}")
|
|
251
|
+
if s.get("avg") not in (None, "None"):
|
|
252
|
+
bits.append(f"avg={s.get('avg')}")
|
|
253
|
+
lines.append("; ".join(bits))
|
|
254
|
+
if extra.get("tables"):
|
|
255
|
+
lines.append("tables: " + ", ".join(extra["tables"]))
|
|
256
|
+
if extra.get("sheets"):
|
|
257
|
+
lines.append("sheets: " + ", ".join(extra["sheets"].keys()))
|
|
258
|
+
return "\n".join(lines)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# --------------------------------------------------------------------------
|
|
262
|
+
# Post-scan computation (batched: bulk reads, in-memory scoring, bulk writes)
|
|
263
|
+
# --------------------------------------------------------------------------
|
|
264
|
+
TERMS_VERSION = 4
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _term_bonus(term: str) -> float:
|
|
268
|
+
"""Phrases outrank their own components at equal evidence."""
|
|
269
|
+
if " " in term:
|
|
270
|
+
return 1.5
|
|
271
|
+
if len(term) == 3 and CJK_RE.fullmatch(term):
|
|
272
|
+
return 1.3
|
|
273
|
+
return 1.0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _is_phrase(term: str) -> bool:
|
|
277
|
+
return " " in term or (len(term) == 3 and CJK_RE.fullmatch(term) is not None)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _overlaps(a: str, b: str) -> bool:
|
|
281
|
+
"""True when one term is a component/substring of the other."""
|
|
282
|
+
if " " in a or " " in b:
|
|
283
|
+
pa, pb = set(a.split(" ")), set(b.split(" "))
|
|
284
|
+
return (len(pa) == 1 and pa <= pb) or (len(pb) == 1 and pb <= pa)
|
|
285
|
+
if CJK_RE.fullmatch(a) and CJK_RE.fullmatch(b):
|
|
286
|
+
return a != b and (a in b or b in a)
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _select_tags(ranked, counts, limit: int = 5):
|
|
291
|
+
"""Pick top tags: phrases first-class, components suppressed.
|
|
292
|
+
|
|
293
|
+
A phrase needs at least 2 occurrences in the file to be trusted;
|
|
294
|
+
if that starves the list, a second pass relaxes the count rule.
|
|
295
|
+
"""
|
|
296
|
+
picked: list = []
|
|
297
|
+
for term, score in ranked:
|
|
298
|
+
if len(picked) >= limit:
|
|
299
|
+
break
|
|
300
|
+
if _is_phrase(term) and counts.get(term, 0) < 2:
|
|
301
|
+
continue
|
|
302
|
+
if any(_overlaps(term, p) for p, _ in picked):
|
|
303
|
+
continue
|
|
304
|
+
picked.append((term, score))
|
|
305
|
+
if len(picked) < limit:
|
|
306
|
+
for term, score in ranked:
|
|
307
|
+
if len(picked) >= limit:
|
|
308
|
+
break
|
|
309
|
+
if any(term == p or _overlaps(term, p) for p, _ in picked):
|
|
310
|
+
continue
|
|
311
|
+
picked.append((term, score))
|
|
312
|
+
return picked
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _chunks(seq, size=500):
|
|
316
|
+
for i in range(0, len(seq), size):
|
|
317
|
+
yield seq[i : i + size]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _delete_for(con, table: str, fids: list[str]) -> None:
|
|
321
|
+
for part in _chunks(fids):
|
|
322
|
+
marks = ",".join("?" * len(part))
|
|
323
|
+
con.execute(f"DELETE FROM {table} WHERE file_id IN ({marks})", part)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def compute_topics(db_path: Path | None = None, use_llm: bool = False,
|
|
327
|
+
llm_file_ids: set[str] | None = None, con=None) -> dict:
|
|
328
|
+
"""Recompute topics from stored term counts; no file I/O.
|
|
329
|
+
|
|
330
|
+
Unchanged files (content hash matches file_topic_meta) keep their
|
|
331
|
+
summary/entities and only get tag weights re-scored in memory.
|
|
332
|
+
New/changed files get the full treatment (hotspot segments,
|
|
333
|
+
entities, and — when enabled — an LLM call). LLM results are cached
|
|
334
|
+
by hash and never re-requested for unchanged files.
|
|
335
|
+
|
|
336
|
+
Pass an open `con` to reuse the caller's connection (scan does —
|
|
337
|
+
reopening a DuckDB file just to rescore tags costs a checkpoint
|
|
338
|
+
on close); otherwise a connection to db_path is opened and closed.
|
|
339
|
+
"""
|
|
340
|
+
import duckdb
|
|
341
|
+
|
|
342
|
+
own_con = con is None
|
|
343
|
+
if own_con:
|
|
344
|
+
con = duckdb.connect(str(db_path))
|
|
345
|
+
try:
|
|
346
|
+
con.execute(
|
|
347
|
+
"CREATE TABLE IF NOT EXISTS file_terms "
|
|
348
|
+
"(file_id VARCHAR, term VARCHAR, cnt BIGINT)"
|
|
349
|
+
)
|
|
350
|
+
con.execute(
|
|
351
|
+
"CREATE TABLE IF NOT EXISTS file_topic_meta "
|
|
352
|
+
"(file_id VARCHAR PRIMARY KEY, hash VARCHAR, method VARCHAR)"
|
|
353
|
+
)
|
|
354
|
+
try:
|
|
355
|
+
con.execute("ALTER TABLE file_topics ADD COLUMN IF NOT EXISTS source VARCHAR")
|
|
356
|
+
except Exception:
|
|
357
|
+
pass
|
|
358
|
+
|
|
359
|
+
files = con.execute(
|
|
360
|
+
"SELECT id, name, kind, title, hash, extra_json FROM files"
|
|
361
|
+
).fetchall()
|
|
362
|
+
if not files:
|
|
363
|
+
return {"files": 0, "llm": 0, "rule": 0}
|
|
364
|
+
file_ids = [f[0] for f in files]
|
|
365
|
+
|
|
366
|
+
terms_by_file: dict[str, Counter] = {fid: Counter() for fid in file_ids}
|
|
367
|
+
for fid, term, cnt in con.execute("SELECT file_id, term, cnt FROM file_terms").fetchall():
|
|
368
|
+
if fid in terms_by_file:
|
|
369
|
+
terms_by_file[fid][term] = cnt
|
|
370
|
+
|
|
371
|
+
# Terms-format migration: catalogs built before v3 have no
|
|
372
|
+
# phrase terms. Recompute counts from the stored text/extras —
|
|
373
|
+
# no file re-extraction — and rewrite file_terms wholesale.
|
|
374
|
+
con.execute(
|
|
375
|
+
"CREATE TABLE IF NOT EXISTS catalog_meta "
|
|
376
|
+
"(key VARCHAR PRIMARY KEY, value VARCHAR)"
|
|
377
|
+
)
|
|
378
|
+
vrow = con.execute(
|
|
379
|
+
"SELECT value FROM catalog_meta WHERE key = 'terms_version'"
|
|
380
|
+
).fetchone()
|
|
381
|
+
if (int(vrow[0]) if vrow else 0) < TERMS_VERSION:
|
|
382
|
+
texts_all = {
|
|
383
|
+
r[0]: r[1]
|
|
384
|
+
for r in con.execute("SELECT file_id, text FROM file_text").fetchall()
|
|
385
|
+
}
|
|
386
|
+
all_rows = []
|
|
387
|
+
for fid, name, kind, _t, _h, extra_json in files:
|
|
388
|
+
result = {
|
|
389
|
+
"kind": kind,
|
|
390
|
+
"extra": json.loads(extra_json or "{}"),
|
|
391
|
+
"text": texts_all.get(fid, ""),
|
|
392
|
+
}
|
|
393
|
+
terms_by_file[fid] = term_counts_for(result, name)
|
|
394
|
+
all_rows.extend(
|
|
395
|
+
(fid, term, cnt) for term, cnt in terms_by_file[fid].items()
|
|
396
|
+
)
|
|
397
|
+
# Bulk-load via a temp CSV inside one transaction:
|
|
398
|
+
# executemany in autocommit mode costs a disk sync per row
|
|
399
|
+
# and turns this migration into minutes on large folders.
|
|
400
|
+
con.execute("BEGIN TRANSACTION")
|
|
401
|
+
con.execute("DELETE FROM file_terms")
|
|
402
|
+
if all_rows:
|
|
403
|
+
import csv
|
|
404
|
+
import tempfile
|
|
405
|
+
|
|
406
|
+
with tempfile.NamedTemporaryFile(
|
|
407
|
+
"w", suffix=".csv", newline="", delete=False
|
|
408
|
+
) as fh:
|
|
409
|
+
csv.writer(fh).writerows(all_rows)
|
|
410
|
+
tmp_csv = fh.name
|
|
411
|
+
con.execute(
|
|
412
|
+
"INSERT INTO file_terms SELECT * FROM read_csv(?, header=false, "
|
|
413
|
+
"columns={'file_id': 'VARCHAR', 'term': 'VARCHAR', 'cnt': 'BIGINT'})",
|
|
414
|
+
[tmp_csv],
|
|
415
|
+
)
|
|
416
|
+
Path(tmp_csv).unlink(missing_ok=True)
|
|
417
|
+
con.execute(
|
|
418
|
+
"INSERT OR REPLACE INTO catalog_meta VALUES ('terms_version', ?)",
|
|
419
|
+
[str(TERMS_VERSION)],
|
|
420
|
+
)
|
|
421
|
+
con.execute("COMMIT")
|
|
422
|
+
|
|
423
|
+
meta = {
|
|
424
|
+
r[0]: (r[1], r[2])
|
|
425
|
+
for r in con.execute("SELECT file_id, hash, method FROM file_topic_meta").fetchall()
|
|
426
|
+
}
|
|
427
|
+
existing_topics: dict[str, list[str]] = {fid: [] for fid in file_ids}
|
|
428
|
+
for fid, tag in con.execute(
|
|
429
|
+
"SELECT file_id, tag FROM file_topics ORDER BY file_id, weight DESC"
|
|
430
|
+
).fetchall():
|
|
431
|
+
if fid in existing_topics:
|
|
432
|
+
existing_topics[fid].append(tag)
|
|
433
|
+
|
|
434
|
+
# Backfill term counts for catalogs built before file_terms
|
|
435
|
+
# existed (one bulk text read, only for files that lack terms).
|
|
436
|
+
missing = [f for f in files if not terms_by_file[f[0]]]
|
|
437
|
+
if missing:
|
|
438
|
+
texts = {
|
|
439
|
+
r[0]: r[1]
|
|
440
|
+
for r in con.execute("SELECT file_id, text FROM file_text").fetchall()
|
|
441
|
+
}
|
|
442
|
+
backfill_rows = []
|
|
443
|
+
for fid, name, kind, _t, _h, extra_json in missing:
|
|
444
|
+
result = {
|
|
445
|
+
"kind": kind,
|
|
446
|
+
"extra": json.loads(extra_json or "{}"),
|
|
447
|
+
"text": texts.get(fid, ""),
|
|
448
|
+
}
|
|
449
|
+
terms_by_file[fid] = term_counts_for(result, name)
|
|
450
|
+
backfill_rows.extend(
|
|
451
|
+
(fid, term, cnt) for term, cnt in terms_by_file[fid].items()
|
|
452
|
+
)
|
|
453
|
+
if backfill_rows:
|
|
454
|
+
con.executemany("INSERT INTO file_terms VALUES (?,?,?)", backfill_rows)
|
|
455
|
+
|
|
456
|
+
df: Counter = Counter()
|
|
457
|
+
for counts in terms_by_file.values():
|
|
458
|
+
for term in counts:
|
|
459
|
+
df[term] += 1
|
|
460
|
+
n_docs = len(files)
|
|
461
|
+
idf = {t: math.log((n_docs + 1) / (d + 1)) + 1.0 for t, d in df.items()}
|
|
462
|
+
|
|
463
|
+
# Classify: llm-cached (skip), fresh (full treatment), or
|
|
464
|
+
# unchanged (tag re-score only — summary/entities kept).
|
|
465
|
+
fresh_ids = []
|
|
466
|
+
for fid, _n, _k, _t, fhash, _e in files:
|
|
467
|
+
prev = meta.get(fid)
|
|
468
|
+
if prev and prev[0] == fhash:
|
|
469
|
+
continue
|
|
470
|
+
fresh_ids.append(fid)
|
|
471
|
+
fresh_set = set(fresh_ids)
|
|
472
|
+
fresh_texts: dict[str, str] = {}
|
|
473
|
+
if fresh_ids:
|
|
474
|
+
wanted = set(fresh_ids)
|
|
475
|
+
fresh_texts = {
|
|
476
|
+
r[0]: r[1]
|
|
477
|
+
for r in con.execute("SELECT file_id, text FROM file_text").fetchall()
|
|
478
|
+
if r[0] in wanted
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
topic_rows, entity_rows, summary_rows, meta_rows = [], [], [], []
|
|
482
|
+
stats = {"files": 0, "llm": 0, "rule": 0}
|
|
483
|
+
recomputed_ids: list[str] = []
|
|
484
|
+
|
|
485
|
+
for fid, name, kind, title, fhash, extra_json in files:
|
|
486
|
+
prev = meta.get(fid)
|
|
487
|
+
if prev and prev[1] == "llm" and prev[0] == fhash:
|
|
488
|
+
stats["files"] += 1
|
|
489
|
+
stats["llm"] += 1
|
|
490
|
+
continue
|
|
491
|
+
counts = terms_by_file[fid]
|
|
492
|
+
total = sum(counts.values()) or 1
|
|
493
|
+
scores = {t: (c / total) * idf.get(t, 1.0) for t, c in counts.items()}
|
|
494
|
+
if kind != "data":
|
|
495
|
+
for t in set(tokenize(f"{title or ''} {Path(name).stem}")):
|
|
496
|
+
if t in scores:
|
|
497
|
+
scores[t] *= 3.0
|
|
498
|
+
ranked = sorted(
|
|
499
|
+
((t, s * _term_bonus(t)) for t, s in scores.items()),
|
|
500
|
+
key=lambda kv: -kv[1],
|
|
501
|
+
)
|
|
502
|
+
top = _select_tags(ranked, counts)
|
|
503
|
+
max_score = top[0][1] if top else 1.0
|
|
504
|
+
tags = [{"tag": t, "weight": round(s / max_score, 3)} for t, s in top]
|
|
505
|
+
method = "rule"
|
|
506
|
+
stats["files"] += 1
|
|
507
|
+
|
|
508
|
+
if fid not in fresh_set:
|
|
509
|
+
# Unchanged: re-scored tags only; summary/entities stay.
|
|
510
|
+
# If the tag sequence is identical to what's stored
|
|
511
|
+
# (the usual case — IDF drift rarely reorders the top
|
|
512
|
+
# 5), skip the write entirely.
|
|
513
|
+
stats["rule"] += 1
|
|
514
|
+
if [t["tag"] for t in tags] == existing_topics.get(fid, []):
|
|
515
|
+
continue
|
|
516
|
+
recomputed_ids.append(fid)
|
|
517
|
+
topic_rows.extend(
|
|
518
|
+
(fid, t["tag"], t["weight"], method) for t in tags
|
|
519
|
+
)
|
|
520
|
+
meta_rows.append((fid, fhash, prev[1] if prev else method))
|
|
521
|
+
continue
|
|
522
|
+
recomputed_ids.append(fid)
|
|
523
|
+
|
|
524
|
+
extra = json.loads(extra_json or "{}")
|
|
525
|
+
text = fresh_texts.get(fid, "")
|
|
526
|
+
if kind == "data":
|
|
527
|
+
card = profile_card_text(extra, extra.get("row_count"))
|
|
528
|
+
summary = card.splitlines()[0] + "; " + ", ".join(
|
|
529
|
+
(c.get("name") if isinstance(c, dict) else str(c))
|
|
530
|
+
for c in extra.get("columns", [])[:6]
|
|
531
|
+
)
|
|
532
|
+
entities = list(extra.get("tables") or []) + list(
|
|
533
|
+
(extra.get("sheets") or {}).keys()
|
|
534
|
+
)
|
|
535
|
+
package = _llm_package_data(name, card)
|
|
536
|
+
else:
|
|
537
|
+
segments = hotspot_segments(text, scores)
|
|
538
|
+
if segments:
|
|
539
|
+
first = segments[0].splitlines()[0].strip()
|
|
540
|
+
summary = (
|
|
541
|
+
first if len(first) >= 20 else segments[0].replace("\n", " ")
|
|
542
|
+
)[:200]
|
|
543
|
+
else:
|
|
544
|
+
summary = ""
|
|
545
|
+
entities = extract_entities(text)
|
|
546
|
+
package = _llm_package_doc(
|
|
547
|
+
name, title,
|
|
548
|
+
[t for t, _ in counts.most_common(10)],
|
|
549
|
+
[t for t, _ in ranked[:15]],
|
|
550
|
+
segments,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
if use_llm and (llm_file_ids is None or fid in llm_file_ids):
|
|
554
|
+
prompt = (
|
|
555
|
+
"Read this file profile and reply with JSON only: "
|
|
556
|
+
'{"tags": [up to 5 short topic tags], "summary": "one sentence", '
|
|
557
|
+
'"entities": [up to 5 key people/places/projects/organizations]}.\n'
|
|
558
|
+
+ package
|
|
559
|
+
)
|
|
560
|
+
result = _llm_call(prompt)
|
|
561
|
+
if result is not None:
|
|
562
|
+
tags = result["tags"] or tags
|
|
563
|
+
summary = result["summary"] or summary
|
|
564
|
+
entities = result["entities"] or entities
|
|
565
|
+
method = "llm"
|
|
566
|
+
stats[method] += 1
|
|
567
|
+
topic_rows.extend((fid, t["tag"], t["weight"], method) for t in tags)
|
|
568
|
+
entity_rows.extend((fid, e) for e in entities[:5])
|
|
569
|
+
summary_rows.append((summary, fid))
|
|
570
|
+
meta_rows.append((fid, fhash, method))
|
|
571
|
+
|
|
572
|
+
con.execute("BEGIN TRANSACTION")
|
|
573
|
+
_delete_for(con, "file_topics", recomputed_ids)
|
|
574
|
+
if topic_rows:
|
|
575
|
+
con.executemany("INSERT INTO file_topics VALUES (?,?,?,?)", topic_rows)
|
|
576
|
+
_delete_for(con, "file_entities", fresh_ids)
|
|
577
|
+
if entity_rows:
|
|
578
|
+
con.executemany("INSERT INTO file_entities VALUES (?,?)", entity_rows)
|
|
579
|
+
if summary_rows:
|
|
580
|
+
con.executemany(
|
|
581
|
+
"UPDATE file_text SET summary = ? WHERE file_id = ?", summary_rows
|
|
582
|
+
)
|
|
583
|
+
if meta_rows:
|
|
584
|
+
con.executemany(
|
|
585
|
+
"INSERT OR REPLACE INTO file_topic_meta VALUES (?,?,?)", meta_rows
|
|
586
|
+
)
|
|
587
|
+
con.execute("COMMIT")
|
|
588
|
+
return stats
|
|
589
|
+
finally:
|
|
590
|
+
if own_con:
|
|
591
|
+
con.close()
|