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/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()