milindkamat0507 commited on
Commit
56cf271
·
verified ·
1 Parent(s): fe5fe48

Delete tools.py

Browse files
Files changed (1) hide show
  1. tools.py +0 -623
tools.py DELETED
@@ -1,623 +0,0 @@
1
- """tools.py — Sentence-level BERTopic pipeline + Mistral LLM. Version 3.0.0 | 4 April 2026. ZERO for/while/if.
2
-
3
- PIPELINE:
4
- Paper → split into sentences → each sentence gets paper_id + sent_id + metadata
5
- → embed sentences (384d) → AgglomerativeClustering cosine → centroid nearest 5 sentences
6
- → Mistral labels topics from sentence evidence + paper metadata
7
- → one paper can span MULTIPLE topics
8
- """
9
- from langchain_core.tools import tool
10
- import os
11
- import json
12
- import re
13
- import numpy as np
14
- import pandas as pd
15
-
16
- # ═══════════════════════════════════════════════
17
- # DEBUG + STATE + CONSTANTS
18
- # ═══════════════════════════════════════════════
19
- DEBUG = True
20
- debug = {True: print, False: lambda *a, **k: None}[DEBUG]
21
-
22
- CHECKPOINT_DIR = "/tmp/checkpoints"
23
- os.makedirs(CHECKPOINT_DIR, exist_ok=True)
24
-
25
- NEAREST_K = 5
26
- SENT_SPLIT_RE = r'(?<=[.!?])\s+(?=[A-Z])'
27
- MIN_SENT_LEN = 30
28
-
29
- RUN_CONFIGS = {
30
- "abstract": ["Abstract"],
31
- "title": ["Title"],
32
- }
33
-
34
- _data = {}
35
-
36
-
37
- # ═══════════════════════════════════════════════
38
- # HELPER: Split text into sentences (regex, no nltk)
39
- # ═══════════════════════════════════════════════
40
- def _split_sentences(text):
41
- """Split text on sentence boundaries. Filters short fragments (<30 chars).
42
- Uses regex: split after .!? followed by uppercase letter."""
43
- raw = re.split(SENT_SPLIT_RE, str(text))
44
- return list(filter(lambda s: len(s.strip()) >= MIN_SENT_LEN, raw))
45
-
46
-
47
- # ═══════════════════════════════════════════════
48
- # TOOL 1: Load Scopus CSV
49
- # ═══════════════════════════════════════════════
50
- @tool
51
- def load_scopus_csv(filepath: str) -> str:
52
- """Load a Scopus CSV export and show preview. Call this first.
53
-
54
- Args:
55
- filepath: Path to the uploaded .csv file.
56
-
57
- Returns:
58
- Row count, column names, and sample data."""
59
- debug(f"\n>>> TOOL: load_scopus_csv(filepath='{filepath}')")
60
- df = pd.read_csv(filepath, encoding="utf-8-sig")
61
- _data["df"] = df
62
- debug(f">>> Loaded {len(df)} rows, {len(df.columns)} columns")
63
- target_cols = list(filter(lambda c: c in df.columns, ["Title", "Abstract", "Author Keywords"]))
64
- sample = df[target_cols].head(3).to_string(max_colwidth=80)
65
- null_counts = ", ".join(list(map(
66
- lambda c: f"{c}: {df[c].notna().sum()}/{len(df)}", target_cols)))
67
-
68
- # Estimate sentence counts
69
- sample_sents = df["Abstract"].head(5).apply(_split_sentences).apply(len)
70
- avg_abstract_sents = sample_sents.mean()
71
- est_abstract = int(avg_abstract_sents * len(df))
72
- title_count = int(df["Title"].notna().sum())
73
-
74
- return (f"📊 **Dataset Statistics:**\n"
75
- f"- **Papers:** {len(df)}\n"
76
- f"- **Abstract sentences:** ~{est_abstract} (~{avg_abstract_sents:.0f} per paper)\n"
77
- f"- **Title sentences:** {title_count} (1 per paper)\n"
78
- f"- **Non-null:** {null_counts}\n\n"
79
- f"Columns: {', '.join(list(df.columns)[:15])}\n\n"
80
- f"Sample:\n{sample}")
81
-
82
-
83
- # ═══════════════════════════════════════════════
84
- # TOOL 2: Sentence-Level BERTopic Pipeline
85
- # ═══════════════════════════════════════════════
86
- @tool
87
- def run_bertopic_discovery(run_key: str, threshold: float = 0.7) -> str:
88
- """Sentence-level BERTopic: split papers → embed sentences → cosine similarity clustering → centroid nearest 5 → Plotly charts.
89
- Each sentence keeps paper_id, sent_id, and metadata. One paper can span multiple topics.
90
- Uses AgglomerativeClustering with cosine distance — groups sentences by similarity threshold.
91
-
92
- Args:
93
- run_key: One of 'abstract' or 'title' — selects which columns to split into sentences.
94
- threshold: Cosine distance threshold (0.0-1.0). Lower = stricter = more topics.
95
- 0.5 = very strict (~2000 topics), 0.7 = recommended (~100 topics, default), 0.8 = loose (~30 topics), 0.9 = very loose (~10 topics).
96
-
97
- Returns:
98
- Topic summary with sentence counts, paper counts, and 5 nearest centroid sentences."""
99
- debug(f"\n>>> TOOL: run_bertopic_discovery(run_key='{run_key}', threshold={threshold})")
100
- from bertopic import BERTopic
101
- from sentence_transformers import SentenceTransformer
102
-
103
- df = _data["df"].copy()
104
- cols = RUN_CONFIGS[run_key]
105
- available = list(filter(lambda c: c in df.columns, cols))
106
- debug(f">>> Columns: {available}")
107
-
108
- # ── Step 1: Assemble text per paper ──
109
- df["_text"] = df[available].fillna("").agg(" ".join, axis=1)
110
- df["_paper_id"] = df.index
111
- debug(f">>> {len(df)} papers assembled")
112
-
113
- # ── Step 2: Split into sentences — regex, no nltk ──
114
- debug(">>> Splitting into sentences...")
115
- df["_sentences"] = df["_text"].apply(_split_sentences)
116
- debug(f">>> Sentence counts: min={df['_sentences'].apply(len).min()}, "
117
- f"max={df['_sentences'].apply(len).max()}, "
118
- f"mean={df['_sentences'].apply(len).mean():.1f}")
119
-
120
- # ── Step 3: Explode to sentence-level DataFrame ──
121
- meta_cols = ["_paper_id", "Title", "Author Keywords", "_sentences"]
122
- available_meta = list(filter(lambda c: c in df.columns, meta_cols))
123
- sent_df = df[available_meta].explode("_sentences").rename(
124
- columns={"_sentences": "text"}).reset_index(drop=True)
125
- sent_df = sent_df.dropna(subset=["text"]).reset_index(drop=True)
126
- sent_df["sent_id"] = sent_df.groupby("_paper_id").cumcount()
127
-
128
- # ── Step 3b: Filter out publisher boilerplate sentences ──
129
- # Scopus abstracts contain copyright/license noise that clustering picks up as topics.
130
- # These are NOT research content — remove before embedding.
131
- debug(">>> Filtering publisher boilerplate...")
132
- _n_before = len(sent_df)
133
- boilerplate_patterns = "|".join([
134
- r"Licensee MDPI",
135
- r"Published by Informa",
136
- r"Published by Elsevier",
137
- r"Taylor & Francis",
138
- r"Copyright ©",
139
- r"Creative Commons",
140
- r"open access article",
141
- r"Inderscience Enterprises",
142
- r"All rights reserved",
143
- r"This is an open access",
144
- r"distributed under the terms",
145
- r"The Author\(s\)",
146
- r"Springer Nature",
147
- r"Emerald Publishing",
148
- r"limitations and future",
149
- r"limitations and implications",
150
- r"limitations are discussed",
151
- r"limitations have been discussed",
152
- r"implications are discussed",
153
- r"implications were discussed",
154
- r"implications are presented",
155
- r"concludes with .* implications",
156
- ])
157
- clean_mask = ~sent_df["text"].str.contains(boilerplate_patterns, case=False, regex=True, na=False)
158
- sent_df = sent_df[clean_mask].reset_index(drop=True)
159
- sent_df["sent_id"] = sent_df.groupby("_paper_id").cumcount()
160
- debug(f">>> Filtered: {_n_before} → {len(sent_df)} sentences ({_n_before - len(sent_df)} boilerplate removed)")
161
- n_sentences = len(sent_df)
162
- n_papers = len(df)
163
- debug(f">>> {n_sentences} sentences from {n_papers} papers")
164
-
165
- # ── Step 4: Embed sentences (384d, L2-normalized) ──
166
- # BERTopic FAQ: "normalize them first to force a cosine-related distance metric"
167
- # Math: for L2-normalized vectors, euclidean²(a,b) = 2(1 - cos(a,b)) → same clusters as cosine
168
- debug(">>> Embedding sentences with all-MiniLM-L6-v2 (L2-normalized)...")
169
- docs = sent_df["text"].tolist()
170
- embedder = SentenceTransformer("all-MiniLM-L6-v2")
171
- embeddings = embedder.encode(docs, show_progress_bar=False, normalize_embeddings=True)
172
- debug(f">>> Embeddings: {embeddings.shape}, normalized: True")
173
-
174
- # Save checkpoint
175
- np.save(f"{CHECKPOINT_DIR}/rq4_{run_key}_emb.npy", embeddings)
176
-
177
- # ── Step 5: Agglomerative Clustering with COSINE similarity threshold ──
178
- # Groups sentences where cosine_distance < threshold → same cluster
179
- # No dimension reduction. No density estimation. Pure similarity grouping.
180
- debug(f">>> AgglomerativeClustering cosine threshold={threshold} on 384d embeddings...")
181
- from sklearn.preprocessing import FunctionTransformer
182
- from sklearn.cluster import AgglomerativeClustering
183
- no_umap = FunctionTransformer()
184
- cluster_model = AgglomerativeClustering(
185
- n_clusters=None,
186
- metric="cosine",
187
- linkage="average",
188
- distance_threshold=threshold,
189
- )
190
- topic_model = BERTopic(
191
- hdbscan_model=cluster_model,
192
- umap_model=no_umap,
193
- )
194
- topics, probs = topic_model.fit_transform(docs, embeddings)
195
- n_topics = len(set(topics)) - int(-1 in topics)
196
- n_outliers = int(np.sum(np.array(topics) == -1))
197
- debug(f">>> {n_topics} topics, {n_outliers} outlier sentences")
198
-
199
- # Store for later tools
200
- _data[f"{run_key}_model"] = topic_model
201
- _data[f"{run_key}_topics"] = np.array(topics)
202
- _data[f"{run_key}_embeddings"] = embeddings
203
- _data[f"{run_key}_sent_df"] = sent_df
204
-
205
- # ── Step 6: BERTopic Plotly visualizations (skip charts that need 3+ topics) ──
206
- debug(f">>> Generating visualizations ({n_topics} topics)...")
207
- # visualize_topics() uses UMAP internally → crashes with < 3 topics
208
- (n_topics >= 3) and topic_model.visualize_topics().write_html(
209
- f"/tmp/rq4_{run_key}_intertopic.html", include_plotlyjs="cdn")
210
- # barchart works with 1+ topics
211
- (n_topics >= 1) and topic_model.visualize_barchart(
212
- top_n_topics=min(10, max(1, n_topics))).write_html(
213
- f"/tmp/rq4_{run_key}_bars.html", include_plotlyjs="cdn")
214
- # hierarchy needs 2+ topics
215
- (n_topics >= 2) and topic_model.visualize_hierarchy().write_html(
216
- f"/tmp/rq4_{run_key}_hierarchy.html", include_plotlyjs="cdn")
217
- # heatmap needs 2+ topics
218
- (n_topics >= 2) and topic_model.visualize_heatmap().write_html(
219
- f"/tmp/rq4_{run_key}_heatmap.html", include_plotlyjs="cdn")
220
- debug(f">>> Visualizations saved (skipped charts needing more topics)")
221
-
222
- # ── Step 7: Centroid nearest 5 SENTENCES — COSINE similarity ──
223
- topics_arr = np.array(topics)
224
- topic_info = topic_model.get_topic_info()
225
- valid_rows = list(filter(lambda r: r["Topic"] != -1, topic_info.to_dict("records")))
226
-
227
- def _centroid_nearest(row):
228
- """Find 5 sentences nearest to topic centroid via cosine similarity."""
229
- mask = topics_arr == row["Topic"]
230
- member_idx = np.where(mask)[0]
231
- member_embs = embeddings[mask]
232
- centroid = member_embs.mean(axis=0)
233
- # Cosine distance: 1 - cos_sim. For normalized vectors: cos_sim = dot product
234
- norms = np.linalg.norm(member_embs, axis=1) * np.linalg.norm(centroid)
235
- cosine_sim = (member_embs @ centroid) / (norms + 1e-10)
236
- dists = 1 - cosine_sim
237
- nearest = np.argsort(dists)[:NEAREST_K]
238
-
239
- # 5 nearest sentences with paper metadata
240
- nearest_evidence = list(map(lambda i: {
241
- "sentence": str(sent_df.iloc[member_idx[i]]["text"])[:250],
242
- "paper_id": int(sent_df.iloc[member_idx[i]]["_paper_id"]),
243
- "title": str(sent_df.iloc[member_idx[i]].get("Title", ""))[:150],
244
- "keywords": str(sent_df.iloc[member_idx[i]].get("Author Keywords", ""))[:150],
245
- }, nearest))
246
-
247
- # Count unique papers in this topic + collect their titles
248
- topic_papers_df = sent_df.iloc[member_idx].drop_duplicates(subset=["_paper_id"])
249
- unique_papers = len(topic_papers_df)
250
- paper_titles = list(map(
251
- lambda idx: str(topic_papers_df.iloc[idx].get("Title", ""))[:200],
252
- range(min(50, unique_papers)))) # cap at 50 titles per topic
253
-
254
- return {"topic_id": int(row["Topic"]),
255
- "sentence_count": int(row["Count"]),
256
- "paper_count": int(unique_papers),
257
- "top_words": str(row.get("Name", ""))[:100],
258
- "nearest": nearest_evidence,
259
- "paper_titles": paper_titles}
260
-
261
- summaries = list(map(_centroid_nearest, valid_rows))
262
- json.dump(summaries, open(f"{CHECKPOINT_DIR}/rq4_{run_key}_summaries.json", "w"), indent=2, default=str)
263
- debug(f">>> {len(summaries)} topics saved ({NEAREST_K} nearest sentences each)")
264
-
265
- # ── Format output ──
266
- lines = list(map(
267
- lambda s: f" Topic {s['topic_id']} ({s['sentence_count']} sentences, {s['paper_count']} papers): {s['top_words']}",
268
- summaries))
269
- return (f"[{run_key}] {n_topics} topics from {n_sentences} sentences ({n_papers} papers, {n_outliers} outliers).\n\n"
270
- + "\n".join(lines)
271
- + f"\n\nVisualizations: /tmp/rq4_{run_key}_*.html (4 files)"
272
- + f"\nCheckpoints: {CHECKPOINT_DIR}/rq4_{run_key}_emb.npy + summaries.json")
273
-
274
-
275
- # ═══════════════════════════════════════════════
276
- # TOOL 3: Label Topics with Mistral (sentence evidence)
277
- # ═══════════════════════════════════════════════
278
- @tool
279
- def label_topics_with_llm(run_key: str) -> str:
280
- """Send 5 nearest centroid sentences + paper metadata to Mistral for labeling.
281
- Each sentence shows which paper it came from (title + keywords).
282
-
283
- Args:
284
- run_key: One of 'abstract' or 'title'.
285
-
286
- Returns:
287
- Labeled topics with sentence-level evidence."""
288
- debug(f"\n>>> TOOL: label_topics_with_llm(run_key='{run_key}')")
289
- from langchain_mistralai import ChatMistralAI
290
- from langchain_core.prompts import PromptTemplate
291
- from langchain_core.output_parsers import JsonOutputParser
292
-
293
- summaries = json.load(open(f"{CHECKPOINT_DIR}/rq4_{run_key}_summaries.json"))
294
- debug(f">>> Loaded {len(summaries)} topics ({NEAREST_K} sentences each)")
295
-
296
- # Limit to top 50 largest topics — prevents Mistral rate limit on 2000+ topics
297
- MAX_LABEL_TOPICS = 100
298
- sorted_summaries = sorted(summaries, key=lambda s: s.get("sentence_count", 0), reverse=True)
299
- summaries_to_label = sorted_summaries[:MAX_LABEL_TOPICS]
300
- skipped = max(0, len(summaries) - MAX_LABEL_TOPICS)
301
- debug(f">>> Labeling top {len(summaries_to_label)} topics (skipped {skipped} small clusters)")
302
-
303
- # Format all topics — show sentence + paper metadata as evidence
304
- topics_block = "\n\n".join(list(map(
305
- lambda s: (f"Topic {s['topic_id']} ({s['sentence_count']} sentences from {s['paper_count']} papers):\n"
306
- f" Top words: {s['top_words']}\n"
307
- f" {NEAREST_K} nearest centroid sentences:\n"
308
- + "\n".join(list(map(
309
- lambda e: (f" - \"{e['sentence'][:200]}\"\n"
310
- f" Paper: \"{e['title']}\"\n"
311
- f" Keywords: {e['keywords']}"),
312
- s["nearest"])))),
313
- summaries_to_label)))
314
-
315
- prompt = PromptTemplate.from_template(
316
- "You are a research topic classifier for academic papers about Technology and Tourism.\n\n"
317
- "For EACH topic below, you are given the 5 sentences nearest to the topic centroid,\n"
318
- "plus the paper title and author keywords each sentence came from.\n\n"
319
- "Return a JSON ARRAY with one object per topic:\n"
320
- "- topic_id: integer\n"
321
- "- label: short descriptive name (3-6 words, specific — NOT generic like 'tourism studies')\n"
322
- "- category: general research area (e.g., 'technology adoption', 'consumer behavior',\n"
323
- " 'virtual reality', 'social media marketing', 'sustainability', 'cultural heritage',\n"
324
- " 'AI and machine learning', 'online reviews', 'destination marketing',\n"
325
- " 'tourist psychology', 'hotel management', 'sharing economy',\n"
326
- " 'mobile applications', 'research methodology', 'data analytics')\n"
327
- " DO NOT use PACIS/ICIS categories — just plain descriptive research area.\n"
328
- "- confidence: high, medium, or low\n"
329
- "- reasoning: 1 sentence explaining WHY you chose this label based on the evidence sentences\n"
330
- "- niche: true or false (true = very specific sub-area with <20 sentences)\n\n"
331
- "CRITICAL: be SPECIFIC in labels. Do NOT use broad terms.\n"
332
- "Return ONLY valid JSON array, no markdown.\n\n"
333
- "Topics:\n{topics}")
334
-
335
- llm = ChatMistralAI(model="mistral-small-latest", temperature=0, timeout=300)
336
- chain = prompt | llm | JsonOutputParser()
337
- debug(">>> Calling Mistral (single call, all topics)...")
338
- labels = chain.invoke({"topics": topics_block})
339
- debug(f">>> Got {len(labels)} labels")
340
-
341
- # Merge labels with summaries
342
- labeled = list(map(lambda pair: {**pair[0], **pair[1]},
343
- zip(summaries, (labels + summaries)[:len(summaries)])))
344
- json.dump(labeled, open(f"{CHECKPOINT_DIR}/rq4_{run_key}_labels.json", "w"), indent=2, default=str)
345
- debug(f">>> Labels saved: {CHECKPOINT_DIR}/rq4_{run_key}_labels.json")
346
-
347
- # Format — show label + evidence sentences + paper source
348
- lines = list(map(
349
- lambda l: (f" **Topic {l.get('topic_id', '?')}: {l.get('label', '?')}** "
350
- f"[{l.get('category', '?')}] conf={l.get('confidence', '?')} "
351
- f"({l.get('sentence_count', 0)} sentences, {l.get('paper_count', 0)} papers)\n"
352
- + "\n".join(list(map(
353
- lambda e: f" → \"{e['sentence'][:120]}...\" — _{e['title'][:60]}_",
354
- l.get("nearest", []))))),
355
- labeled))
356
- return f"[{run_key}] {len(labeled)} topics labeled by Mistral:\n\n" + "\n\n".join(lines)
357
-
358
-
359
- # ═══════════════════════════════════════════════
360
- # TOOL 4: Generate Comparison Table
361
- # ═══════════════════════════════════════════════
362
- @tool
363
- def generate_comparison_csv() -> str:
364
- """Compare Mistral-labeled topics across completed runs. Includes sentence + paper counts.
365
-
366
- Returns:
367
- Comparison table + CSV path."""
368
- debug(f"\n>>> TOOL: generate_comparison_csv()")
369
- completed = list(filter(
370
- lambda k: os.path.exists(f"{CHECKPOINT_DIR}/rq4_{k}_labels.json"), RUN_CONFIGS.keys()))
371
- debug(f">>> Completed runs: {completed}")
372
-
373
- def _load_run(run_key):
374
- labels = json.load(open(f"{CHECKPOINT_DIR}/rq4_{run_key}_labels.json"))
375
- return list(map(lambda l: {
376
- "run": run_key, "topic_id": l.get("topic_id", ""),
377
- "label": l.get("label", ""), "category": l.get("category", ""),
378
- "confidence": l.get("confidence", ""), "niche": l.get("niche", ""),
379
- "sentences": l.get("sentence_count", 0),
380
- "papers": l.get("paper_count", 0),
381
- "top_words": l.get("top_words", ""),
382
- }, labels))
383
-
384
- all_rows = sum(list(map(_load_run, completed)), [])
385
- df = pd.DataFrame(all_rows)
386
- path = "/tmp/rq4_comparison.csv"
387
- df.to_csv(path, index=False)
388
- debug(f">>> Comparison CSV: {path} ({len(df)} rows)")
389
- return f"Comparison saved: {path} ({len(completed)} runs, {len(df)} topics)\n\n{df.to_string(index=False)}"
390
-
391
-
392
- # ═══════════════════════════════════════════════
393
- # TOOL 5: Export 500-Word Narrative
394
- # ═══════════════════════════════════════════════
395
- @tool
396
- def export_narrative(run_key: str) -> str:
397
- """Generate 500-word narrative for research paper Section 7 via Mistral.
398
-
399
- Args:
400
- run_key: One of 'abstract' or 'title'.
401
-
402
- Returns:
403
- 500-word narrative + save path."""
404
- debug(f"\n>>> TOOL: export_narrative(run_key='{run_key}')")
405
- from langchain_mistralai import ChatMistralAI
406
-
407
- labels = json.load(open(f"{CHECKPOINT_DIR}/rq4_{run_key}_labels.json"))
408
- topics_text = "\n".join(list(map(
409
- lambda l: f"- {l.get('label', '?')} ({l.get('sentence_count', 0)} sentences from "
410
- f"{l.get('paper_count', 0)} papers, category: {l.get('category', '?')}, "
411
- f"confidence: {l.get('confidence', '?')}, niche: {l.get('niche', '?')})",
412
- labels)))
413
-
414
- llm = ChatMistralAI(model="mistral-small-latest", temperature=0.3, timeout=300)
415
- result = llm.invoke(
416
- f"Write exactly 500 words for a research paper Section 7 titled "
417
- f"'Topic Modeling Results — BERTopic Discovery'.\n\n"
418
- f"Dataset: 1390 Scopus papers on Tourism and AI.\n"
419
- f"Method: Sentence-level BERTopic — each abstract split into sentences,\n"
420
- f"embedded with all-MiniLM-L6-v2 (384d), clustered with AgglomerativeClustering (cosine).\n"
421
- f"Note: One paper can contribute sentences to MULTIPLE topics.\n"
422
- f"Run config: '{run_key}' columns.\n\n"
423
- f"Topics discovered:\n{topics_text}\n\n"
424
- f"Include: methodology justification for sentence-level approach,\n"
425
- f"key themes, emerging niches, limitations, future work.")
426
-
427
- path = "/tmp/rq4_narrative.txt"
428
- open(path, "w", encoding="utf-8").write(result.content)
429
- debug(f">>> Narrative saved: {path} ({len(result.content)} chars)")
430
- return f"Narrative saved: {path}\n\n{result.content}"
431
-
432
-
433
- # ═══════════════════════════════════════════════
434
- # TOOL 6: Consolidate Round 1 Topics into Themes
435
- # ═══════════════════════════════════════════════
436
- @tool
437
- def consolidate_into_themes(run_key: str, theme_map: dict) -> str:
438
- """ROUND 2: Merge fine-grained Round 1 topics into broader themes.
439
- Researcher decides which topics to group. Recomputes centroids and evidence.
440
-
441
- Args:
442
- run_key: 'abstract' or 'title'.
443
- theme_map: Dict mapping theme names to topic ID lists.
444
- Example: {"AI in Tourism": [0, 1, 5], "VR Tourism": [2, 3]}
445
-
446
- Returns:
447
- Consolidated themes with new 5-nearest sentence evidence per theme."""
448
- debug(f"\n>>> TOOL: consolidate_into_themes(run_key='{run_key}', {len(theme_map)} themes)")
449
-
450
- topics_arr = _data[f"{run_key}_topics"]
451
- embeddings = _data[f"{run_key}_embeddings"]
452
- sent_df = _data[f"{run_key}_sent_df"]
453
-
454
- def _build_theme(item):
455
- """Merge listed topics into one theme. Recompute centroid + 5 nearest."""
456
- theme_name, topic_ids = item
457
- mask = np.isin(topics_arr, topic_ids)
458
- member_idx = np.where(mask)[0]
459
- member_embs = embeddings[mask]
460
- centroid = member_embs.mean(axis=0)
461
- norms = np.linalg.norm(member_embs, axis=1) * np.linalg.norm(centroid)
462
- cosine_sim = (member_embs @ centroid) / (norms + 1e-10)
463
- dists = 1 - cosine_sim
464
- nearest = np.argsort(dists)[:NEAREST_K]
465
-
466
- nearest_evidence = list(map(lambda i: {
467
- "sentence": str(sent_df.iloc[member_idx[i]]["text"])[:250],
468
- "paper_id": int(sent_df.iloc[member_idx[i]]["_paper_id"]),
469
- "title": str(sent_df.iloc[member_idx[i]].get("Title", ""))[:150],
470
- "keywords": str(sent_df.iloc[member_idx[i]].get("Author Keywords", ""))[:150],
471
- }, nearest))
472
-
473
- unique_papers = sent_df.iloc[member_idx]["_paper_id"].nunique()
474
-
475
- # Collect paper titles (up to 50)
476
- topic_papers_df = sent_df.iloc[member_idx].drop_duplicates(subset=["_paper_id"])
477
- paper_titles = list(map(
478
- lambda idx: str(topic_papers_df.iloc[idx].get("Title", ""))[:200],
479
- range(min(50, len(topic_papers_df)))))
480
-
481
- return {"label": theme_name, "merged_topics": list(topic_ids),
482
- "sentence_count": int(mask.sum()), "paper_count": int(unique_papers),
483
- "nearest": nearest_evidence, "paper_titles": paper_titles}
484
-
485
- # Add topic_id to each theme (sequential)
486
- themes_raw = list(map(_build_theme, theme_map.items()))
487
- themes = list(map(
488
- lambda pair: {**pair[1], "topic_id": pair[0]},
489
- enumerate(themes_raw)))
490
- json.dump(themes, open(f"{CHECKPOINT_DIR}/rq4_{run_key}_themes.json", "w"), indent=2, default=str)
491
- debug(f">>> {len(themes)} themes saved: {CHECKPOINT_DIR}/rq4_{run_key}_themes.json")
492
-
493
- # Format — show theme + merged topics + evidence
494
- lines = list(map(
495
- lambda t: (f" **{t['label']}** ({t['sentence_count']} sentences, {t['paper_count']} papers)\n"
496
- f" Merged from topics: {t['merged_topics']}\n"
497
- f" Evidence:\n"
498
- + "\n".join(list(map(
499
- lambda e: f" → \"{e['sentence'][:120]}...\" — _{e['title'][:60]}_",
500
- t["nearest"])))),
501
- themes))
502
- return f"[{run_key}] Round 2: {len(themes)} themes consolidated:\n\n" + "\n\n".join(lines)
503
-
504
-
505
- # ═══════════════════════════════════════════════
506
- # TOOL 7: Compare Themes with PAJAIS Taxonomy
507
- # ═══════════════════════════════════════════════
508
-
509
- # Established IS topic taxonomy from:
510
- # Jiang, Liang & Tsai (2019) "Knowledge Profile in PAJAIS"
511
- # Pacific Asia Journal of the AIS, 11(1), 1-24. doi:10.17705/1pais.11101
512
- PAJAIS_TAXONOMY = [
513
- "Electronic and Mobile Business / Social Commerce",
514
- "Human Behavior and IS / Human-Computer Interaction",
515
- "IS/IT Strategy, Leadership, Governance",
516
- "Business Intelligence and Data Analytics",
517
- "Design Science and IS",
518
- "Enterprise Systems and BPM",
519
- "IS Implementation, Adoption, and Diffusion",
520
- "Social Media and Business Impact",
521
- "Cultural and Global Issues in IS",
522
- "IS Security and Privacy",
523
- "IS Smart / IoT",
524
- "Knowledge Management",
525
- "ICT / Digital Platform / IT and Work",
526
- "IS Healthcare",
527
- "IT Project Management",
528
- "Service Science and IS",
529
- "Social and Organizational Aspects of IS",
530
- "Research Methods and Philosophy",
531
- "E-Finance / Economics of IS",
532
- "E-Government",
533
- "IS Education and Learning",
534
- "Green IT and Sustainability",
535
- ]
536
-
537
-
538
- @tool
539
- def compare_with_taxonomy(run_key: str) -> str:
540
- """Compare BERTopic themes against established PAJAIS/PACIS taxonomy
541
- (Jiang, Liang & Tsai, 2019). Identifies which themes map to known
542
- categories and which are NOVEL/EMERGING (not in existing taxonomy).
543
- Researcher reviews mapping and approves new theme consolidation.
544
-
545
- Args:
546
- run_key: 'abstract' or 'title'.
547
-
548
- Returns:
549
- Mapping table: BERTopic theme → PAJAIS category (or NOVEL)."""
550
- debug(f"\n>>> TOOL: compare_with_taxonomy(run_key='{run_key}')")
551
- from langchain_mistralai import ChatMistralAI
552
- from langchain_core.prompts import PromptTemplate
553
- from langchain_core.output_parsers import JsonOutputParser
554
-
555
- # Load themes (prefer consolidated themes, fall back to labels)
556
- themes_path = f"{CHECKPOINT_DIR}/rq4_{run_key}_themes.json"
557
- labels_path = f"{CHECKPOINT_DIR}/rq4_{run_key}_labels.json"
558
- source_path = (os.path.exists(themes_path) and themes_path) or labels_path
559
- themes = json.load(open(source_path))
560
- debug(f">>> Loaded {len(themes)} themes from {source_path}")
561
-
562
- # Format themes for Mistral
563
- themes_text = "\n".join(list(map(
564
- lambda t: f"- {t.get('label', '?')} "
565
- f"({t.get('paper_count', t.get('count', '?'))} papers)",
566
- themes)))
567
-
568
- taxonomy_text = "\n".join(list(map(lambda c: f"- {c}", PAJAIS_TAXONOMY)))
569
-
570
- prompt = PromptTemplate.from_template(
571
- "You are an IS research taxonomy expert.\n\n"
572
- "Compare each BERTopic theme against the established PAJAIS/PACIS taxonomy.\n"
573
- "For EACH theme, return a JSON ARRAY with:\n"
574
- "- label: the BERTopic theme name\n"
575
- "- pajais_match: closest PAJAIS category (or 'NOVEL' if no match)\n"
576
- "- match_confidence: high, medium, low, or none\n"
577
- "- reasoning: why this mapping (1 sentence)\n"
578
- "- is_novel: true if this theme represents an emerging area not in the taxonomy\n\n"
579
- "Return ONLY valid JSON array.\n\n"
580
- "BERTopic Themes:\n{themes}\n\n"
581
- "PAJAIS Taxonomy (Jiang et al., 2019):\n{taxonomy}")
582
-
583
- llm = ChatMistralAI(model="mistral-small-latest", temperature=0, timeout=300)
584
- chain = prompt | llm | JsonOutputParser()
585
- debug(">>> Calling Mistral for taxonomy comparison...")
586
- mappings = chain.invoke({"themes": themes_text, "taxonomy": taxonomy_text})
587
- debug(f">>> Got {len(mappings)} mappings")
588
-
589
- # Save mapping
590
- json.dump(mappings, open(f"{CHECKPOINT_DIR}/rq4_{run_key}_taxonomy_map.json", "w"), indent=2, default=str)
591
-
592
- # Count novel vs mapped
593
- novel = list(filter(lambda m: m.get("is_novel", False), mappings))
594
- mapped = list(filter(lambda m: not m.get("is_novel", False), mappings))
595
-
596
- # Format output
597
- mapped_lines = list(map(
598
- lambda m: f" ✅ {m.get('label', '?')} → **{m.get('pajais_match', '?')}** "
599
- f"(conf={m.get('match_confidence', '?')}) _{m.get('reasoning', '')}_",
600
- mapped))
601
- novel_lines = list(map(
602
- lambda m: f" 🆕 **{m.get('label', '?')}** → NOVEL "
603
- f"_{m.get('reasoning', '')}_",
604
- novel))
605
-
606
- return (f"[{run_key}] Taxonomy comparison (Jiang et al., 2019):\n\n"
607
- f"**Mapped to PAJAIS categories ({len(mapped)}):**\n" + "\n".join(mapped_lines) +
608
- f"\n\n**NOVEL / Emerging themes ({len(novel)}):**\n" + "\n".join(novel_lines) +
609
- f"\n\nSaved: {CHECKPOINT_DIR}/rq4_{run_key}_taxonomy_map.json")
610
-
611
-
612
- # ═══════════════════════════════════════════════
613
- # GET ALL TOOLS
614
- # ═══════════════════════════════════════════════
615
- def get_all_tools():
616
- """Return all 7 tools with error handling enabled."""
617
- tools = [load_scopus_csv, run_bertopic_discovery, label_topics_with_llm,
618
- consolidate_into_themes, compare_with_taxonomy,
619
- generate_comparison_csv, export_narrative]
620
- list(map(lambda t: setattr(t, 'handle_tool_error', True), tools))
621
- debug(f">>> tools.py: {len(tools)} tools ready (handle_tool_error=True)")
622
- list(map(lambda t: debug(f">>> - {t.name}"), tools))
623
- return tools