topiclayers 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,712 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import pickle
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ from topiclayers.support.cache import cache_key
14
+
15
+
16
+ class TopicModeler:
17
+ def __init__(
18
+ self,
19
+ df: pd.DataFrame,
20
+ *,
21
+ embedder_name: str = "all-MiniLM-L6-v2",
22
+ path_cache: Path = Path("./cache"),
23
+ name: str = "dataset",
24
+ ):
25
+ self.df = df.copy()
26
+ self.embedder_name = embedder_name
27
+ self.name = name
28
+ self.path_cache = Path(path_cache)
29
+
30
+ self.tm_path = self.path_cache / "tm" / self.embedder_name
31
+ self.df_labeled_path = self.tm_path / f"tweets_{self.name}_labeled.pkl"
32
+ self.model_path = self.tm_path / f"model_{self.name}"
33
+ self.embeddings_path = self.tm_path / f"embeddings_{self.name}.pkl"
34
+ self.topic_path = self.tm_path / f"topics_{self.name}.csv"
35
+
36
+ self.model = None
37
+ self.embeddings = None
38
+ self.docs = None
39
+
40
+ def get_topics(
41
+ self,
42
+ min_topic_size: int = 50,
43
+ nr_topics: str = "auto",
44
+ random_state: int = 42,
45
+ umap_model: Optional[object] = None,
46
+ hdbscan_model: Optional[object] = None,
47
+ reporter=None,
48
+ exact_probabilities: bool = False,
49
+ repro_mode: str = "strict",
50
+ # "pca" (seeded) is deterministic AND ~20x faster than "spectral";
51
+ # pass umap_init="spectral" explicitly to restore the old behaviour
52
+ umap_init: str = "pca",
53
+ ) -> pd.DataFrame:
54
+ from topiclayers.support.repro import set_global_seed
55
+
56
+ set_global_seed(random_state)
57
+
58
+ self.tm_path.mkdir(parents=True, exist_ok=True)
59
+
60
+ manifest_path = self.tm_path / f"manifest_{self.name}.json"
61
+ docs_hash = _docs_hash(self.df["text"].tolist())
62
+ expected_key = cache_key(
63
+ docs_hash, self.embedder_name, str(min_topic_size),
64
+ str(nr_topics), str(random_state),
65
+ f"exact_prob={exact_probabilities}", f"repro={repro_mode}",
66
+ f"umap_init={umap_init}",
67
+ )
68
+
69
+ if self.df_labeled_path.exists() and self.model_path.exists():
70
+ cache_valid = False
71
+ if manifest_path.exists():
72
+ try:
73
+ with open(manifest_path) as f:
74
+ manifest = json.load(f)
75
+ if manifest.get("key") == expected_key and manifest.get("status") == "success":
76
+ cache_valid = True
77
+ except Exception:
78
+ pass
79
+ if cache_valid:
80
+ print("using cached topics")
81
+ self.df = pd.read_pickle(self.df_labeled_path)
82
+ from bertopic import BERTopic
83
+ self.model = BERTopic.load(self.model_path)
84
+ if "new_text" in self.df.columns:
85
+ self.docs = self.df["new_text"].tolist()
86
+ return self.df
87
+
88
+ print("running topic modeling")
89
+ docs = self._preprocess()
90
+
91
+ if self.embeddings_path.exists():
92
+ embed_manifest = self.tm_path / f"embeddings_manifest_{self.name}.json"
93
+ embed_key = cache_key(_docs_hash(docs), self.embedder_name)
94
+ use_cached = False
95
+ if embed_manifest.exists():
96
+ try:
97
+ with open(embed_manifest) as f:
98
+ em = json.load(f)
99
+ if em.get("key") == embed_key:
100
+ use_cached = True
101
+ except Exception:
102
+ pass
103
+ if use_cached:
104
+ print(" loading embeddings from cache")
105
+ with open(self.embeddings_path, "rb") as f:
106
+ self.embeddings = pickle.load(f)
107
+ else:
108
+ self._get_embeddings(docs, reporter)
109
+ else:
110
+ self._get_embeddings(docs, reporter)
111
+
112
+ self._use_BERTopic(
113
+ docs, nr_topics, min_topic_size, random_state,
114
+ umap_model, hdbscan_model, reporter,
115
+ exact_probabilities=exact_probabilities, repro_mode=repro_mode,
116
+ umap_init=umap_init,
117
+ )
118
+
119
+ self._save_qdrant(docs, reporter)
120
+ self._save_files()
121
+
122
+ manifest = {
123
+ "key": expected_key,
124
+ "timestamp": datetime.now(timezone.utc).isoformat(),
125
+ "status": "success",
126
+ "min_topic_size": min_topic_size,
127
+ "nr_topics": nr_topics,
128
+ "random_state": random_state,
129
+ "embedder": self.embedder_name,
130
+ "n_rows": len(self.df),
131
+ }
132
+ with open(manifest_path, "w") as f:
133
+ json.dump(manifest, f, indent=2, default=str)
134
+
135
+ return self.df
136
+
137
+ def _preprocess(self) -> list[str]:
138
+ self.df["new_text"] = self.df["text"]
139
+ self.df["new_text"] = self.df["new_text"].str.replace(r"http\S+", "", regex=True)
140
+ self.df["new_text"] = self.df["new_text"].str.replace(r"@\S+", "", regex=True)
141
+ self.df["new_text"] = self.df["new_text"].str.replace(r"\n", "", regex=True)
142
+ self.df["new_text"] = self.df["new_text"].str.strip()
143
+ self.df = self.df[self.df["new_text"] != ""]
144
+ self.df.reset_index(drop=False, inplace=True)
145
+ docs = self.df["new_text"].tolist()
146
+ self.docs = docs
147
+ return docs
148
+
149
+ def _get_embeddings(self, docs: list[str], reporter=None) -> None:
150
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
151
+
152
+ if reporter is not None:
153
+ reporter.stage("embed", total=len(docs))
154
+
155
+ if self.embedder_name.startswith("text-embedding"):
156
+ api_key = os.getenv("OPENAI_API_KEY")
157
+ if not api_key:
158
+ raise RuntimeError(
159
+ "OpenAI embedder selected but OPENAI_API_KEY is not set. "
160
+ "Set it in a .env file or use a SentenceTransformer model like 'all-MiniLM-L6-v2' instead."
161
+ )
162
+
163
+ from openai import OpenAI
164
+
165
+ client = OpenAI(api_key=api_key)
166
+ batch_size = 1000
167
+ all_embeddings = []
168
+
169
+ for i in range(0, len(docs), batch_size):
170
+ batch = docs[i:i + batch_size]
171
+ response = client.embeddings.create(input=batch, model=self.embedder_name)
172
+ all_embeddings.extend([np.array(d.embedding) for d in response.data])
173
+ if reporter is not None:
174
+ reporter.update(min(i + batch_size, len(docs)))
175
+
176
+ self.embeddings = np.array(all_embeddings)
177
+ self.embedder = None
178
+ else:
179
+ from sentence_transformers import SentenceTransformer
180
+
181
+ model = SentenceTransformer(self.embedder_name)
182
+ batch_size = 256
183
+ all_embeddings = []
184
+
185
+ for i in range(0, len(docs), batch_size):
186
+ batch = docs[i:i + batch_size]
187
+ batch_emb = model.encode(batch, show_progress_bar=False)
188
+ all_embeddings.append(batch_emb)
189
+ if reporter is not None:
190
+ reporter.update(min(i + batch_size, len(docs)))
191
+
192
+ self.embeddings = np.vstack(all_embeddings) if all_embeddings else np.empty((0,))
193
+ self.embedder = model
194
+
195
+ self.embeddings_path.parent.mkdir(parents=True, exist_ok=True)
196
+ with open(self.embeddings_path, "wb") as f:
197
+ pickle.dump(self.embeddings, f)
198
+
199
+ embed_manifest = self.tm_path / f"embeddings_manifest_{self.name}.json"
200
+ embed_key = cache_key(_docs_hash(docs), self.embedder_name)
201
+ with open(embed_manifest, "w") as f:
202
+ json.dump({"key": embed_key, "embedder": self.embedder_name}, f)
203
+
204
+ def _use_BERTopic(
205
+ self,
206
+ docs: list[str],
207
+ nr_topics: str = "auto",
208
+ min_topic_size: int = 50,
209
+ random_state: int = 42,
210
+ umap_model: Optional[object] = None,
211
+ hdbscan_model: Optional[object] = None,
212
+ reporter=None,
213
+ exact_probabilities: bool = False,
214
+ repro_mode: str = "strict",
215
+ umap_init: str = "pca",
216
+ ) -> None:
217
+ import random
218
+
219
+ from umap import UMAP
220
+ from sklearn.cluster import HDBSCAN
221
+ from sklearn.feature_extraction.text import CountVectorizer
222
+ from bertopic import BERTopic
223
+ from bertopic.vectorizers import ClassTfidfTransformer
224
+
225
+ if reporter is not None:
226
+ reporter.stage("topic_model")
227
+
228
+ np.random.seed(random_state)
229
+ random.seed(random_state)
230
+ try:
231
+ import torch
232
+ torch.manual_seed(random_state)
233
+ except ImportError:
234
+ pass
235
+
236
+ if not docs or len(docs) == 0:
237
+ raise ValueError(
238
+ "No documents for topic modeling. "
239
+ "The DataFrame is empty after preprocessing. "
240
+ "This may happen if all posts have URLs/mentions as their only text. "
241
+ "Check the 'text' column of your input."
242
+ )
243
+
244
+ if len(docs) < 10:
245
+ vectorizer_model = CountVectorizer(stop_words="english", min_df=1, max_df=1.0)
246
+ else:
247
+ min_df_value = max(1, min(5, len(docs) // 100))
248
+ vectorizer_model = CountVectorizer(
249
+ stop_words="english", min_df=min_df_value, max_df=0.95
250
+ )
251
+ ctfidf_model = ClassTfidfTransformer(reduce_frequent_words=True)
252
+
253
+ fast = repro_mode == "fast"
254
+ if umap_model is None:
255
+ n_neighbors = min(15, max(2, len(docs) - 1))
256
+ n_components = min(5, len(docs) - 1)
257
+ if n_components + 1 >= len(docs):
258
+ n_components = max(1, len(docs) - 3)
259
+ umap_params = {
260
+ "n_components": n_components,
261
+ "n_neighbors": n_neighbors,
262
+ "min_dist": 0.0,
263
+ "metric": "cosine",
264
+ "init": umap_init,
265
+ }
266
+ if fast:
267
+ # a seeded UMAP forces single-threaded execution; fast mode
268
+ # trades reproducibility for parallelism
269
+ umap_params.update({"random_state": None, "n_jobs": -1})
270
+ else:
271
+ umap_params["random_state"] = random_state
272
+ umap_model = UMAP(**umap_params)
273
+ else:
274
+ fast = False
275
+
276
+ if hdbscan_model is None:
277
+ min_cluster_size = max(2, min_topic_size)
278
+ hdbscan_kwargs = {"n_jobs": -1} if fast else {}
279
+ hdbscan_model = HDBSCAN(
280
+ min_samples=max(1, min_topic_size // 5),
281
+ min_cluster_size=min_cluster_size,
282
+ **hdbscan_kwargs,
283
+ )
284
+
285
+ model = BERTopic(
286
+ vectorizer_model=vectorizer_model,
287
+ ctfidf_model=ctfidf_model,
288
+ nr_topics=nr_topics,
289
+ min_topic_size=min_topic_size,
290
+ umap_model=umap_model,
291
+ hdbscan_model=hdbscan_model,
292
+ # exact probabilities require the full membership matrix (slow on
293
+ # large corpora); the default computes the probability of the
294
+ # assigned topic only — topics themselves are identical
295
+ calculate_probabilities=exact_probabilities,
296
+ verbose=True,
297
+ )
298
+
299
+ try:
300
+ topics, probs = model.fit_transform(docs, embeddings=self.embeddings)
301
+ except Exception as e:
302
+ self._write_failed_manifest(min_topic_size, nr_topics, random_state, str(e))
303
+ raise RuntimeError(
304
+ f"Topic modeling failed with error: {e}. "
305
+ f"Try reducing min_topic_size below current val ue ({min_topic_size}). "
306
+ f"See docs at https://maartengr.github.io/BERTopic/faq.html"
307
+ ) from e
308
+
309
+ unique_topics = len(set(topics))
310
+ if unique_topics <= 1:
311
+ self._write_failed_manifest(min_topic_size, nr_topics, random_state, "All topics are -1 (outlier)")
312
+ raise RuntimeError(
313
+ f"All documents assigned to topic -1 (outlier). "
314
+ f"This means BERTopic found no clusters. "
315
+ f"Common causes: "
316
+ f"<1> min_topic_size too high (current: {min_topic_size}) — try half of it. "
317
+ f"<2> dataset too small — need at least a few hundred diverse posts. "
318
+ f"<3> all documents are too similar. "
319
+ f"See docs at https://maartengr.github.io/BERTopic/faq.html"
320
+ )
321
+
322
+ self.df["topic"] = topics
323
+ if hasattr(probs, "ndim") and probs.ndim == 2:
324
+ self.df["topic_prob"] = probs.max(axis=1)
325
+ else:
326
+ self.df["topic_prob"] = probs
327
+
328
+ topic_info = model.get_topic_info()
329
+ self.topic_path.parent.mkdir(parents=True, exist_ok=True)
330
+ topic_info.to_csv(self.topic_path)
331
+ self.model = model
332
+
333
+ def _write_failed_manifest(self, min_topic_size: int, nr_topics: str, random_state: int, error: str) -> None:
334
+ self.tm_path.mkdir(parents=True, exist_ok=True)
335
+ manifest_path = self.tm_path / f"manifest_{self.name}.json"
336
+ manifest = {
337
+ "timestamp": datetime.now(timezone.utc).isoformat(),
338
+ "status": "failed",
339
+ "error": error,
340
+ "params": {
341
+ "min_topic_size": min_topic_size,
342
+ "nr_topics": nr_topics,
343
+ "random_state": random_state,
344
+ "embedder": self.embedder_name,
345
+ },
346
+ }
347
+ with open(manifest_path, "w") as f:
348
+ json.dump(manifest, f, indent=2, default=str)
349
+
350
+ def _save_qdrant(self, docs: list[str], reporter=None) -> None:
351
+ qdrant_url = os.getenv("QDRANT_URL")
352
+ if not qdrant_url:
353
+ print("Qdrant not configured — skipping. Set QDRANT_URL in .env to enable vector search.")
354
+ return
355
+
356
+ try:
357
+ from qdrant_client import QdrantClient, models as qmodels
358
+
359
+ client = QdrantClient(url=qdrant_url)
360
+ ids = self.df.index.tolist()
361
+ vectors = self.embeddings.tolist()
362
+ topics = self.df["topic"].tolist()
363
+ probs = self.df["topic_prob"].tolist()
364
+ docs = self.docs
365
+
366
+ try:
367
+ client.create_collection(
368
+ collection_name=self.name,
369
+ vectors_config=qmodels.VectorParams(
370
+ size=len(self.embeddings[0]),
371
+ distance=qmodels.Distance.COSINE,
372
+ ),
373
+ )
374
+ except Exception:
375
+ pass
376
+
377
+ points = [
378
+ qmodels.PointStruct(
379
+ id=int(idx),
380
+ vector=vector,
381
+ payload={"text": text, "topic": topic, "prob": prob},
382
+ )
383
+ for idx, vector, text, topic, prob in zip(ids, vectors, docs, topics, probs)
384
+ ]
385
+ client.upload_points(self.name, points)
386
+ if reporter is not None:
387
+ reporter.update(len(points), total=len(points))
388
+ except Exception as e:
389
+ print(f"Qdrant upload failed (non-fatal): {type(e).__name__}: {e}")
390
+ print("The pipeline continues — networks and topic labels are unaffected.")
391
+
392
+ def _save_files(self) -> None:
393
+ self.df_labeled_path.parent.mkdir(parents=True, exist_ok=True)
394
+ self.df.to_pickle(self.df_labeled_path)
395
+ if self.model is not None:
396
+ self.model.save(
397
+ self.model_path,
398
+ serialization="safetensors",
399
+ save_ctfidf=True,
400
+ save_embedding_model=self.embedder_name,
401
+ )
402
+
403
+ def label_topics(
404
+ self,
405
+ model: str = "ollama/qwen2.5:7b",
406
+ n_representative_docs: int = 3,
407
+ overwrite: bool = False,
408
+ api_base: str = "",
409
+ batch_size: int = 40,
410
+ label_context: str = "",
411
+ max_topics: int = 0,
412
+ ) -> dict[int, str]:
413
+ """Label topics with an LLM, batching many topics per request.
414
+
415
+ Non-fatal by design: any failure (missing litellm, unreachable Ollama,
416
+ provider error) prints a warning and returns {} so the pipeline can
417
+ continue with the c-TF-IDF labels. Chunks whose request or JSON parse
418
+ fails keep their keyword labels; the rest are labeled.
419
+
420
+ Args:
421
+ model: LiteLLM model string, e.g. "ollama/qwen2.5:7b",
422
+ "gpt-4o-mini", "anthropic/claude-3-haiku-20240307". For a custom
423
+ OpenAI-compatible endpoint use "openai/<model-name>" together
424
+ with api_base.
425
+ n_representative_docs: docs per topic passed to the prompt.
426
+ overwrite: re-label even if labels were already generated.
427
+ api_base: base URL of an OpenAI-compatible endpoint (e.g.
428
+ "http://localhost:8888/v1"). API key resolution order:
429
+ LABEL_API_KEY -> OPENAI_LIKE_API_KEY -> OPENAI_API_KEY.
430
+ batch_size: topics per LLM call (~40 keeps prompts well within
431
+ small-context models while cutting request count ~40x).
432
+ label_context: dataset-level context sentence describing what all
433
+ documents have in common, so the LLM labels the *subtopic*
434
+ rather than the general domain. Derived from the dataset name
435
+ when it matches "cop<number>"; empty otherwise.
436
+ max_topics: label only the N largest topics (0 = all). Useful for
437
+ a cheap preview before running the full labeling.
438
+ """
439
+ import http.client
440
+ import json as _json
441
+ import socket
442
+
443
+ api_base = api_base or os.getenv("LABEL_API_BASE", "")
444
+
445
+ if self.model is None:
446
+ print(" No fitted topic model — skipping LLM labeling.")
447
+ return {}
448
+
449
+ topic_info = self.model.get_topic_info()
450
+ if "LLM_label" in topic_info.columns and not overwrite:
451
+ print(" LLM labels already present — skipping (use overwrite=True).")
452
+ return {}
453
+
454
+ try:
455
+ import litellm # noqa: F401
456
+ except ImportError:
457
+ print(
458
+ " LLM labeling skipped: 'litellm' is not installed.\n"
459
+ " Install it with: pip install 'topiclayers[labeling]'\n"
460
+ " Topics keep their c-TF-IDF keyword labels."
461
+ )
462
+ return {}
463
+
464
+ if model.startswith("ollama/"):
465
+ host = os.getenv("OLLAMA_HOST", "localhost")
466
+ port = int(os.getenv("OLLAMA_PORT", "11434"))
467
+ try:
468
+ conn = http.client.HTTPConnection(host, port, timeout=2)
469
+ conn.request("GET", "/api/tags")
470
+ conn.getresponse().read()
471
+ conn.close()
472
+ except (OSError, socket.timeout):
473
+ print(
474
+ f" LLM labeling skipped: Ollama not reachable at {host}:{port}.\n"
475
+ f" Start it with: ollama serve (then pull the model:\n"
476
+ f" ollama pull {model.split('/', 1)[1]})\n"
477
+ f" Or point TOPIC_LLM to another model (e.g. gpt-4o-mini)."
478
+ )
479
+ return {}
480
+
481
+ generator_kwargs = {}
482
+ if api_base:
483
+ api_key = (
484
+ os.getenv("LABEL_API_KEY")
485
+ or os.getenv("OPENAI_LIKE_API_KEY")
486
+ or os.getenv("OPENAI_API_KEY")
487
+ )
488
+ if not api_key:
489
+ print(
490
+ " LLM labeling skipped: custom api_base set but no API key found.\n"
491
+ " Set LABEL_API_KEY (or OPENAI_LIKE_API_KEY / OPENAI_API_KEY) in .env."
492
+ )
493
+ return {}
494
+ generator_kwargs = {"api_base": api_base, "api_key": api_key}
495
+
496
+ context = label_context or _derive_label_context(self.name)
497
+
498
+ # collect per-topic prompt material from the fitted model
499
+ entries = [] # (topic_id, keywords_str, docs_str)
500
+ for _, row in topic_info.iterrows():
501
+ tid = int(row["Topic"])
502
+ if tid == -1:
503
+ continue
504
+ keywords = ", ".join(list(row["Representation"])[:8])
505
+ docs_list = row.get("Representative_Docs")
506
+ if isinstance(docs_list, str):
507
+ try:
508
+ docs_list = eval(docs_list)
509
+ except Exception:
510
+ docs_list = [docs_list]
511
+ if not isinstance(docs_list, list):
512
+ docs_list = []
513
+ docs_list = [str(d)[:280] for d in docs_list[:n_representative_docs]]
514
+ docs_str = "\n".join(f" {i+1}. {d}" for i, d in enumerate(docs_list)) or " (none)"
515
+ entries.append((tid, keywords, docs_str))
516
+
517
+ if not entries:
518
+ print(" No non-outlier topics to label.")
519
+ return {}
520
+
521
+ if max_topics and max_topics < len(entries):
522
+ entries = entries[:max_topics] # topic_info is sorted by size
523
+ print(f" Preview mode: labeling only the {max_topics} largest topics.")
524
+
525
+ system_msg = (
526
+ "You annotate discussion topics from social media.\n"
527
+ + (f"All documents are {context}.\n" if context else "")
528
+ + (
529
+ "For each numbered topic below, generate ONE concise label "
530
+ "(max 5 words) that describes the SPECIFIC subtopic being "
531
+ "discussed WITHIN this general domain — never the domain itself.\n"
532
+ "Labels like \"climate change\", \"environment\", \"social media "
533
+ "discussion\" are WRONG because they describe the domain, not the "
534
+ "specific subtopic.\n"
535
+ "Write each label in the dominant language of that topic's "
536
+ "documents.\n"
537
+ "Reply ONLY with a single JSON object mapping every topic id to "
538
+ 'its label, e.g. {"0": "...", "7": "..."} — no other text.'
539
+ )
540
+ )
541
+
542
+ def call_chunk(chunk):
543
+ blocks = []
544
+ for tid, keywords, docs_str in chunk:
545
+ blocks.append(f"### Topic {tid}\nKeywords: {keywords}\nDocuments:\n{docs_str}")
546
+ user_msg = "\n\n".join(blocks)
547
+ resp = litellm.completion(
548
+ model=model,
549
+ messages=[
550
+ {"role": "system", "content": system_msg},
551
+ {"role": "user", "content": user_msg},
552
+ ],
553
+ **generator_kwargs,
554
+ )
555
+ text = resp.choices[0].message.content.strip()
556
+ # tolerate markdown fences around the JSON object
557
+ if text.startswith("```"):
558
+ text = text.split("```")[1]
559
+ if text.startswith("json"):
560
+ text = text[4:]
561
+ parsed = _json.loads(text)
562
+ return {int(k): str(v).strip() for k, v in parsed.items()}
563
+
564
+ llm_labels: dict[int, str] = {}
565
+ n_chunks = (len(entries) + batch_size - 1) // batch_size
566
+ failed = 0
567
+ for i in range(0, len(entries), batch_size):
568
+ chunk = entries[i : i + batch_size]
569
+ for attempt in (1, 2):
570
+ try:
571
+ llm_labels.update(call_chunk(chunk))
572
+ break
573
+ except Exception as e:
574
+ # Unsloth Studio-style servers require an explicit model
575
+ # load; resolve the local GGUF path and load once, then retry
576
+ if api_base and "no model loaded" in str(e).lower():
577
+ _load_openai_like_model(model, api_base, generator_kwargs.get("api_key", ""))
578
+ continue
579
+ if attempt == 1:
580
+ print(f" Batch {i // batch_size + 1}/{n_chunks}: {type(e).__name__}, retrying once.")
581
+ continue
582
+ failed += 1
583
+ print(
584
+ f" Batch {i // batch_size + 1}/{n_chunks} failed (non-fatal): "
585
+ f"{type(e).__name__}: {str(e)[:120]} — those topics keep keyword labels."
586
+ )
587
+
588
+ if not llm_labels:
589
+ print(" No labels generated — topics keep their c-TF-IDF keyword labels.")
590
+ return {}
591
+
592
+ # expose labels as an aspect column named LLM_label in get_topic_info()
593
+ self.model.topic_aspects_["LLM_label"] = {
594
+ tid: [label] for tid, label in llm_labels.items()
595
+ }
596
+
597
+ info = self.model.get_topic_info()
598
+ labels = {}
599
+ for _, row in info.iterrows():
600
+ tid = int(row["Topic"])
601
+ name = row.get("LLM_label")
602
+ if name is None or (isinstance(name, float) and pd.isna(name)):
603
+ name = row["Name"]
604
+ if isinstance(name, (list, tuple)):
605
+ name = " ".join(str(x) for x in name)
606
+ labels[tid] = str(name)
607
+
608
+ self.topic_path.parent.mkdir(parents=True, exist_ok=True)
609
+ info.to_csv(self.topic_path)
610
+ ok = len(llm_labels)
611
+ suffix = f", {failed} batch(es) failed" if failed else ""
612
+ print(f" Labeled {ok} topics with '{model}' in {n_chunks} batch(es){suffix}.")
613
+ return labels
614
+
615
+
616
+ def _derive_label_context(name: str) -> str:
617
+ """Dataset-level context sentence for LLM labeling.
618
+
619
+ Recognizes COP dataset names (cop21, cop22, ...) and describes the shared
620
+ domain, so the model labels the specific subtopic instead of it.
621
+ """
622
+ import re
623
+
624
+ m = re.fullmatch(r"cop(\d+)", str(name).strip().lower())
625
+ if m:
626
+ return (
627
+ f"social media posts about climate change and the "
628
+ f"COP{m.group(1)} UN Climate Change Conference"
629
+ )
630
+ return ""
631
+
632
+
633
+ def _find_gguf_file(model_dir: str) -> str | None:
634
+ """Find the main GGUF file under a model directory (largest one, skipping
635
+ mmproj/vision projections)."""
636
+ from pathlib import Path as _Path
637
+
638
+ candidates = [
639
+ p for p in _Path(model_dir).rglob("*.gguf")
640
+ if "mmproj" not in p.name.lower()
641
+ ]
642
+ if not candidates:
643
+ return None
644
+ return str(max(candidates, key=lambda p: p.stat().st_size))
645
+
646
+
647
+ def _load_openai_like_model(model: str, api_base: str, api_key: str) -> bool:
648
+ """Try to load a model on an Unsloth Studio-style OpenAI-compatible server.
649
+
650
+ Returns True if the model appears loaded afterwards. Best-effort only:
651
+ any failure returns False (labeling stays non-fatal).
652
+ """
653
+ import json
654
+ import urllib.error
655
+ import urllib.request
656
+ from urllib.parse import urlparse
657
+
658
+ bare = model.split("/", 1)[1] if model.startswith("openai/") else model
659
+ root = api_base.rstrip("/")
660
+ if root.endswith("/v1"):
661
+ root = root[: -len("/v1")]
662
+
663
+ def request(path: str, payload=None, timeout: int = 1800):
664
+ req = urllib.request.Request(
665
+ f"{root}{path}",
666
+ data=json.dumps(payload).encode() if payload is not None else None,
667
+ headers={
668
+ "Authorization": f"Bearer {api_key}",
669
+ "Content-Type": "application/json",
670
+ },
671
+ method="POST" if payload is not None else "GET",
672
+ )
673
+ with urllib.request.urlopen(req, timeout=timeout) as r:
674
+ body = r.read().decode()
675
+ return json.loads(body) if body.strip().startswith(("{", "[")) else body
676
+
677
+ try:
678
+ # locate the local model entry and its GGUF file
679
+ models = request("/api/models/local")
680
+ entry = next(
681
+ (
682
+ m for m in models.get("models", [])
683
+ if m.get("id") == bare or m.get("model_id") == bare
684
+ ),
685
+ None,
686
+ )
687
+ model_path = entry.get("path") if entry else None
688
+ gguf = _find_gguf_file(model_path) if model_path else None
689
+
690
+ load_payload = {"model": bare}
691
+ if gguf:
692
+ load_payload["model_path"] = gguf
693
+ elif not model_path:
694
+ print(f" Model '{bare}' not found on the server — cannot auto-load.")
695
+ return False
696
+
697
+ print(f" Loading model '{bare}' on the server...")
698
+ request("/v1/load", load_payload)
699
+ print(f" Model '{bare}' loaded.")
700
+ return True
701
+ except Exception as e:
702
+ print(f" Auto-load failed: {type(e).__name__}: {e}")
703
+ return False
704
+
705
+
706
+ def _docs_hash(docs: list[str]) -> str:
707
+ import hashlib
708
+
709
+ h = hashlib.sha256()
710
+ for doc in sorted(docs):
711
+ h.update(doc.encode("utf-8", errors="replace"))
712
+ return h.hexdigest()[:16]