milindkamat0507 commited on
Commit
b4037de
·
verified ·
1 Parent(s): cfa4475

Delete tools.py

Browse files
Files changed (1) hide show
  1. tools.py +0 -1028
tools.py DELETED
@@ -1,1028 +0,0 @@
1
- """
2
- tools.py — 10 @tool functions for Braun & Clarke (2006) computational
3
- thematic analysis.
4
-
5
- Pipeline (called in this order by the LLM agent):
6
-
7
- 1. load_scopus_csv — ingest CSV, strip boilerplate, save .parquet
8
- 2. run_bertopic_discovery — embed → cosine agglomerative cluster (min 3
9
- members) → centroids → orphan report → 4 charts
10
- 3. label_topics_with_llm — Mistral labels top 100 clusters
11
- 4. reassign_sentences — move orphan/misplaced sentences between clusters
12
- 5. consolidate_into_themes — merge reviewer-approved groups
13
- 6. compute_saturation — coverage %, coherence, balance per theme
14
- 7. generate_theme_profiles — top 5 nearest sentences per theme centroid
15
- 8. compare_with_taxonomy — map themes to PAJAIS 25 categories
16
- 9. generate_comparison_csv — abstract vs title side-by-side
17
- 10. export_narrative — 500-word Section 7 via Mistral
18
-
19
- Design rules:
20
-
21
- Every number, percentage, score, or list of sentences presented to the
22
- reviewer MUST come from a tool — never from the LLM's imagination.
23
-
24
- Deterministic tools (1,2,4,5,6,7,9): same input → same output, every run.
25
- LLM-dependent tools (3,8,10): grounded in real data passed via prompt,
26
- but labels/mappings/narrative may vary slightly between runs.
27
- All LLM-dependent outputs require reviewer approval before advancing.
28
-
29
- ZERO if/elif/else — all decisions by the LLM
30
- ZERO for/while — list(map(...)) and numpy vectorised ops
31
- ZERO try/except — errors surface to the LLM via ToolNode
32
-
33
- Constants reference:
34
-
35
- EMBED_MODEL = "all-MiniLM-L6-v2"
36
- 384d sentence embeddings. Runs locally, no API calls.
37
- normalize_embeddings=True → cosine similarity = dot product.
38
-
39
- CLUSTER_THRESHOLD = 0.50
40
- Cosine distance threshold for Agglomerative Clustering.
41
- Two sentences must have cosine similarity >= 0.50 to share a code.
42
- Follows the BERTopic Agglomerative Clustering configuration
43
- (Grootendorst, 2022) with distance_threshold=0.5 as documented
44
- in the BERTopic framework. Operationalises Braun & Clarke (2006)
45
- Phase 2 'Generating Initial Codes' as a reproducible computation.
46
-
47
- Tighter (e.g. 0.40) → more, finer codes (closer to B&C ideal)
48
- Looser (e.g. 0.60) → fewer, broader codes
49
- At 0.50 — balanced granularity following BERTopic docs example.
50
-
51
- MIN_CLUSTER_SIZE = 3
52
- Clusters with fewer than 3 members are dissolved. Their sentences
53
- become orphans (label=-1) reported to the reviewer for reassignment.
54
-
55
- N_CENTROIDS = 200
56
- Maximum number of clusters saved to summaries.json (and therefore
57
- labelled and shown in the review table). Set high enough to capture
58
- all clusters in typical Scopus datasets (1k-5k papers).
59
- Top clusters extracted for initial discovery report and charts.
60
-
61
- TOP_TOPICS_LLM = 100
62
- Maximum clusters sent to Mistral for labelling.
63
-
64
- NARRATIVE_WORDS = 500
65
- Target word count for Section 7 narrative.
66
-
67
- PAJAIS_25
68
- 25 IS research categories from Jiang et al. (2019).
69
- Used in Phase 5.5 for taxonomy alignment.
70
-
71
- BOILERPLATE_PATTERNS (9 regexes)
72
- Strip publisher noise: copyright, DOI, Elsevier, Springer,
73
- IEEE, Wiley, Taylor & Francis.
74
- """
75
-
76
- from __future__ import annotations
77
-
78
- import json
79
- import re
80
- import numpy as np
81
- import pandas as pd
82
- import plotly.graph_objects as go
83
-
84
- from pathlib import Path
85
- from langchain_core.tools import tool
86
- from langchain_mistralai import ChatMistralAI
87
- from langchain_core.prompts import PromptTemplate
88
- from langchain_core.output_parsers import JsonOutputParser
89
- from sentence_transformers import SentenceTransformer
90
- from sklearn.cluster import AgglomerativeClustering
91
- from sklearn.metrics.pairwise import cosine_similarity
92
- from sklearn.preprocessing import normalize
93
- from sklearn.decomposition import PCA
94
-
95
-
96
- RUN_CONFIGS = {
97
- "abstract": ["Abstract"],
98
- "title": ["Title"],
99
- }
100
-
101
- PAJAIS_25 = [
102
- "Accounting Information Systems",
103
- "Artificial Intelligence & Expert Systems",
104
- "Big Data & Analytics",
105
- "Business Intelligence & Decision Support",
106
- "Cloud Computing",
107
- "Cybersecurity & Privacy",
108
- "Database Management",
109
- "Digital Transformation",
110
- "E-Business & E-Commerce",
111
- "Enterprise Resource Planning",
112
- "Fintech & Digital Finance",
113
- "Geographic Information Systems",
114
- "Health Informatics",
115
- "Human-Computer Interaction",
116
- "Information Systems Development",
117
- "IT Governance & Management",
118
- "IT Strategy & Competitive Advantage",
119
- "Knowledge Management",
120
- "Machine Learning & Deep Learning",
121
- "Mobile Computing",
122
- "Natural Language Processing",
123
- "Recommender Systems",
124
- "Social Media & Web 2.0",
125
- "Supply Chain & Logistics IS",
126
- "Virtual Reality & Augmented Reality",
127
- ]
128
-
129
- BOILERPLATE_PATTERNS = [
130
- r"©\s*\d{4}",
131
- r"all rights reserved",
132
- r"published by elsevier",
133
- r"this article is protected",
134
- r"doi:\s*10\.\d{4,}",
135
- r"springer nature",
136
- r"ieee xplore",
137
- r"wiley online library",
138
- r"taylor & francis",
139
- ]
140
-
141
- BOILERPLATE_RE = re.compile("|".join(BOILERPLATE_PATTERNS), flags=re.IGNORECASE)
142
- SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+")
143
- EMBED_MODEL = "all-MiniLM-L6-v2"
144
- N_CENTROIDS = 200
145
- CLUSTER_THRESHOLD = 0.50
146
- MIN_CLUSTER_SIZE = 5
147
- TOP_TOPICS_LLM = 100
148
- NARRATIVE_WORDS = 500
149
-
150
-
151
- def _clean_text(text: str) -> str:
152
- """Remove publisher boilerplate from a single text string.
153
-
154
- Applies 9-pattern BOILERPLATE_RE regex to strip copyright notices,
155
- DOI prefixes, and publisher tags that would pollute embeddings.
156
-
157
- Args:
158
- text: Raw abstract or title string.
159
-
160
- Returns:
161
- Cleaned string with boilerplate removed and whitespace trimmed.
162
- """
163
- return BOILERPLATE_RE.sub("", str(text)).strip()
164
-
165
-
166
- def _sentence_count(text: str) -> int:
167
- """Count sentences using regex split on terminal punctuation.
168
-
169
- Args:
170
- text: Cleaned abstract or title text.
171
-
172
- Returns:
173
- Number of sentences (minimum 1 for any non-empty input).
174
- """
175
- return len(SENTENCE_SPLIT_RE.split(text.strip()))
176
-
177
-
178
- def _embed(texts: list[str]) -> np.ndarray:
179
- """Embed texts into 384d L2-normalized unit vectors.
180
-
181
- Uses SentenceTransformer('all-MiniLM-L6-v2') locally — no API calls.
182
- normalize_embeddings=True ensures cosine_similarity = dot product.
183
-
184
- Args:
185
- texts: List of N cleaned text strings.
186
-
187
- Returns:
188
- np.ndarray shape (N, 384), dtype float32, L2-normalized.
189
- """
190
- model = SentenceTransformer(EMBED_MODEL)
191
- raw = model.encode(texts, show_progress_bar=False, normalize_embeddings=True)
192
- return np.array(raw, dtype=np.float32)
193
-
194
-
195
- def _cosine_cluster(matrix: np.ndarray, threshold: float, min_size: int) -> np.ndarray:
196
- """Cluster embeddings using agglomerative cosine clustering.
197
-
198
- Works DIRECTLY in 384d space — no UMAP. After clustering, any cluster
199
- with fewer than min_size members is dissolved: its sentences get
200
- label=-1 (orphan) and are reported to the reviewer for reassignment.
201
-
202
- Algorithm:
203
- 1. Start: every text is its own cluster.
204
- 2. Merge the two closest clusters (average cosine distance).
205
- 3. Repeat until smallest distance exceeds threshold.
206
- 4. Post-process: dissolve clusters smaller than min_size.
207
-
208
- Args:
209
- matrix: (N, 384) embedding matrix, L2-normalized.
210
- threshold: Max cosine distance for merging (0.7 → ~100 clusters).
211
- min_size: Minimum members per cluster (3). Smaller → orphan.
212
-
213
- Returns:
214
- np.ndarray shape (N,) with integer labels. -1 = orphan.
215
- """
216
- normed = normalize(matrix, norm="l2")
217
- model = AgglomerativeClustering(
218
- n_clusters=None,
219
- metric="cosine",
220
- linkage="average",
221
- distance_threshold=threshold,
222
- )
223
- labels = model.fit_predict(normed).astype(int)
224
- unique, counts = np.unique(labels, return_counts=True)
225
- small_clusters = unique[counts < min_size]
226
- return np.where(np.isin(labels, small_clusters), -1, labels)
227
-
228
-
229
- def _centroid(vecs: np.ndarray) -> np.ndarray:
230
- """Compute L2-normalized centroid (average direction in 384d space).
231
-
232
- Args:
233
- vecs: (M, 384) matrix of member embeddings for one cluster.
234
-
235
- Returns:
236
- 1d np.ndarray shape (384,), L2-normalized.
237
- """
238
- return normalize(vecs.mean(axis=0, keepdims=True), norm="l2")[0]
239
-
240
-
241
- def _top_n_centroids(matrix: np.ndarray, labels: np.ndarray, n: int) -> list[dict]:
242
- """Extract N largest clusters by size and compute their centroids.
243
-
244
- Excludes orphans (label=-1) from the ranking.
245
-
246
- Args:
247
- matrix: (N, 384) full embedding matrix.
248
- labels: (N,) integer cluster labels (-1 = orphan).
249
- n: How many top clusters to return.
250
-
251
- Returns:
252
- List of N dicts with: label, size, indices, centroid.
253
- """
254
- valid_mask = labels >= 0
255
- valid_labels = labels[valid_mask]
256
- unique, counts = np.unique(valid_labels, return_counts=True)
257
- order = np.argsort(counts)[::-1][:n]
258
- top_labels = unique[order]
259
-
260
- def _build(lbl: int) -> dict:
261
- """Build summary dict for one cluster."""
262
- idx = np.where(labels == lbl)[0].tolist()
263
- return {
264
- "label": int(lbl),
265
- "size": len(idx),
266
- "indices": idx,
267
- "centroid": _centroid(matrix[idx]),
268
- }
269
-
270
- return list(map(_build, top_labels))
271
-
272
-
273
- def _mistral_chain(template_str: str):
274
- """Create PromptTemplate → ChatMistralAI → JsonOutputParser chain.
275
-
276
- Args:
277
- template_str: Prompt template with {variable} placeholders.
278
-
279
- Returns:
280
- LangChain Runnable chain that accepts dict and returns parsed JSON.
281
- """
282
- llm = ChatMistralAI(model="mistral-large-latest", temperature=0)
283
- prompt = PromptTemplate.from_template(template_str)
284
- return prompt | llm | JsonOutputParser()
285
-
286
-
287
- def _dark_layout(title: str) -> dict:
288
- """Return Plotly layout dict with dark theme styling.
289
-
290
- Args:
291
- title: Chart title string.
292
-
293
- Returns:
294
- Dict for fig.update_layout(**_dark_layout("...")).
295
- """
296
- return dict(
297
- title=title, paper_bgcolor="#0F172A", plot_bgcolor="#0F172A",
298
- font=dict(color="#CBD5E1", family="Sora,sans-serif"),
299
- margin=dict(t=50, b=40, l=40, r=20),
300
- )
301
-
302
-
303
- @tool
304
- def load_scopus_csv(csv_path: str, run_mode: str = "abstract") -> str:
305
- """Load a Scopus CSV, count papers/sentences, apply boilerplate filter.
306
-
307
- Phase 1 — Familiarisation with the Data. DETERMINISTIC.
308
-
309
- Steps:
310
- 1. Read CSV, drop rows where target column is null
311
- 2. Apply 9-pattern boilerplate regex to clean each text
312
- 3. Count sentences per paper
313
- 4. Save cleaned DataFrame as .parquet
314
-
315
- Args:
316
- csv_path: Path to raw Scopus CSV.
317
- run_mode: 'abstract' or 'title'.
318
-
319
- Returns:
320
- JSON: total_papers, total_sentences, columns_used,
321
- boilerplate_removed, cleaned_parquet, run_mode.
322
- """
323
- cols = RUN_CONFIGS[run_mode]
324
- target = cols[0]
325
-
326
- df = pd.read_csv(csv_path).dropna(subset=[target]).reset_index(drop=True)
327
- raw_texts = df[target].tolist()
328
- cleaned_texts = list(map(_clean_text, raw_texts))
329
-
330
- boilerplate_removed = sum(map(
331
- lambda pair: int(pair[0] != pair[1]),
332
- zip(raw_texts, cleaned_texts),
333
- ))
334
-
335
- df[f"{target}_clean"] = cleaned_texts
336
- df["sentence_count"] = list(map(_sentence_count, cleaned_texts))
337
-
338
- out_path = Path(csv_path).with_suffix(".clean.parquet")
339
- df.to_parquet(out_path, index=False)
340
-
341
- return json.dumps({
342
- "total_papers": len(df),
343
- "total_sentences": int(df["sentence_count"].sum()),
344
- "columns_used": cols,
345
- "boilerplate_removed": boilerplate_removed,
346
- "cleaned_parquet": str(out_path),
347
- "run_mode": run_mode,
348
- }, indent=2)
349
-
350
-
351
- @tool
352
- def run_bertopic_discovery(parquet_path: str, run_mode: str = "abstract") -> str:
353
- """Embed texts, cluster them, report orphans, generate charts.
354
-
355
- Phase 2 — Generating Initial Codes. DETERMINISTIC.
356
-
357
- Steps:
358
- 1. Load cleaned parquet, drop Author Keywords columns (RULE 8)
359
- 2. Embed all texts → N x 384 matrix of unit vectors
360
- 3. Save embedding matrix as .emb.npy
361
- 4. Cluster in 384d space (NO UMAP), min 3 members per cluster
362
- 5. Sentences in clusters < 3 members become orphans (label=-1)
363
- 6. Extract top-N clusters by size, compute centroids
364
- 7. Save summaries.json with clusters + orphan list
365
- 8. Generate 4 Plotly HTML charts
366
-
367
- Args:
368
- parquet_path: Path to .clean.parquet from load_scopus_csv.
369
- run_mode: 'abstract' or 'title'.
370
-
371
- Returns:
372
- JSON: total_clusters, orphan_count, summaries_json, embeddings_npy,
373
- charts dict.
374
- """
375
- cols = RUN_CONFIGS[run_mode]
376
- target = f"{cols[0]}_clean"
377
-
378
- df = pd.read_parquet(parquet_path).drop(
379
- columns=[c for c in pd.read_parquet(parquet_path).columns
380
- if re.search(r"keyword|author", c, re.I)],
381
- errors="ignore",
382
- )
383
-
384
- paper_texts = df[target].tolist()
385
-
386
- sentence_records = list(filter(
387
- lambda r: len(r["text"].split()) >= 5,
388
- [
389
- {"paper_idx": paper_i, "sent_idx": sent_i, "text": sent.strip()}
390
- for paper_i, paper_text in enumerate(paper_texts)
391
- for sent_i, sent in enumerate(SENTENCE_SPLIT_RE.split(paper_text or ""))
392
- if sent.strip()
393
- ],
394
- ))
395
-
396
- texts = list(map(lambda r: r["text"], sentence_records))
397
- paper_idx = list(map(lambda r: r["paper_idx"], sentence_records))
398
- embeddings = _embed(texts)
399
- base = Path(parquet_path).parent
400
-
401
- np.save(str(base / Path(parquet_path).stem) + ".emb.npy", embeddings)
402
-
403
- labels = _cosine_cluster(embeddings, CLUSTER_THRESHOLD, MIN_CLUSTER_SIZE)
404
- orphan_idx = np.where(labels == -1)[0].tolist()
405
- orphan_count = len(orphan_idx)
406
- valid_count = int((labels >= 0).sum())
407
- n_clusters = int(np.unique(labels[labels >= 0]).shape[0])
408
- n_papers = len(set(paper_idx))
409
- n_sentences = len(texts)
410
- top_centroids = _top_n_centroids(embeddings, labels, N_CENTROIDS)
411
-
412
- def _topic_row(tc: dict) -> dict:
413
- """Convert centroid dict into summary row for summaries.json."""
414
- return {
415
- "topic_id": tc["label"],
416
- "size": tc["size"],
417
- "representative": texts[tc["indices"][0]][:200],
418
- "indices": tc["indices"],
419
- }
420
-
421
- summaries = list(map(_topic_row, top_centroids))
422
-
423
- orphans = list(map(
424
- lambda i: {"sentence_idx": int(i), "text": texts[i][:200]},
425
- orphan_idx,
426
- ))
427
-
428
- output = {"clusters": summaries, "orphans": orphans}
429
- (base / "summaries.json").write_text(json.dumps(output, indent=2))
430
-
431
- unique, counts = np.unique(labels[labels >= 0], return_counts=True)
432
- order = np.argsort(counts)[::-1][:20]
433
- c1 = go.Figure(go.Bar(
434
- x=list(map(str, unique[order])), y=counts[order].tolist(),
435
- marker_color="#3B82F6", text=counts[order].tolist(), textposition="outside",
436
- ))
437
- c1.update_layout(**_dark_layout("Topic Size Distribution (Top 20)"),
438
- xaxis=dict(showgrid=False),
439
- yaxis=dict(showgrid=True, gridcolor="#1E293B"))
440
- c1.write_html(str(base / "chart_topic_sizes.html"))
441
-
442
- centroid_matrix = np.vstack([tc["centroid"] for tc in top_centroids])
443
- sim_matrix = cosine_similarity(centroid_matrix)
444
- clabels = list(map(lambda tc: f"T{tc['label']}", top_centroids))
445
- c2 = go.Figure(go.Heatmap(z=sim_matrix, x=clabels, y=clabels, colorscale="Blues"))
446
- c2.update_layout(**_dark_layout("Top-5 Centroid Cosine Similarity"))
447
- c2.write_html(str(base / "chart_centroid_heatmap.html"))
448
-
449
- sc = df.get("sentence_count", pd.Series([0] * len(df))).tolist()
450
- c3 = go.Figure(go.Histogram(x=sc, nbinsx=40, marker_color="#22D3EE"))
451
- c3.update_layout(**_dark_layout("Sentence Count Distribution"),
452
- xaxis=dict(showgrid=False),
453
- yaxis=dict(showgrid=True, gridcolor="#1E293B"))
454
- c3.write_html(str(base / "chart_sentence_distribution.html"))
455
-
456
- coords = PCA(n_components=2).fit_transform(centroid_matrix)
457
- point_text = list(map(lambda tc: f"T{tc['label']}({tc['size']})", top_centroids))
458
- c4 = go.Figure(go.Scatter(
459
- x=coords[:, 0].tolist(), y=coords[:, 1].tolist(),
460
- mode="markers+text", text=point_text, textposition="top center",
461
- marker=dict(size=12, color="#F59E0B", line=dict(width=1, color="#0F172A")),
462
- ))
463
- c4.update_layout(**_dark_layout("Top-5 Centroids — PCA Projection"))
464
- c4.write_html(str(base / "chart_centroid_pca.html"))
465
-
466
- emb_path = str(base / Path(parquet_path).stem) + ".emb.npy"
467
- return json.dumps({
468
- "total_clusters": n_clusters,
469
- "orphan_count": orphan_count,
470
- "valid_sentences": valid_count,
471
- "total_sentences": n_sentences,
472
- "total_papers": n_papers,
473
- "top_centroids": N_CENTROIDS,
474
- "summaries_json": str(base / "summaries.json"),
475
- "embeddings_npy": emb_path,
476
- "needs_review": True,
477
- "charts": {
478
- "topic_sizes": str(base / "chart_topic_sizes.html"),
479
- "centroid_heatmap": str(base / "chart_centroid_heatmap.html"),
480
- "sentence_dist": str(base / "chart_sentence_distribution.html"),
481
- "centroid_pca": str(base / "chart_centroid_pca.html"),
482
- },
483
- }, indent=2)
484
-
485
-
486
- @tool
487
- def label_topics_with_llm(summaries_json_path: str) -> str:
488
- """Send top-100 topic summaries to Mistral for labelling.
489
-
490
- Phase 2 — Naming Initial Codes. LLM-DEPENDENT (grounded in real data extracts).
491
-
492
- Steps:
493
- 1. Load summaries.json clusters (not orphans)
494
- 2. Take top 100 by size
495
- 3. Mistral reads representative sentences → assigns labels
496
- 4. Returns: topic_id, label, rationale, confidence per cluster
497
- 5. Save as topic_labels.json
498
-
499
- Args:
500
- summaries_json_path: Path to summaries.json.
501
-
502
- Returns:
503
- JSON: labelled_topics count + output path. needs_review=True.
504
- """
505
- data = json.loads(Path(summaries_json_path).read_text())
506
- summaries = data.get("clusters", data)[:TOP_TOPICS_LLM]
507
-
508
- template = (
509
- "You are a scientific topic labelling expert.\n\n"
510
- "Below are {n} topic summaries from a BERTopic analysis of academic papers.\n"
511
- "Each summary has: topic_id, size, representative text.\n\n"
512
- "{summaries}\n\n"
513
- "For EACH topic return a JSON array where every element has:\n"
514
- " topic_id : integer (copy from input)\n"
515
- " label : 2-5 word snake_case topic label\n"
516
- " rationale : one sentence justification\n"
517
- " confidence : float 0.0-1.0\n\n"
518
- "Return ONLY the JSON array — no markdown, no preamble."
519
- )
520
-
521
- result = _mistral_chain(template).invoke({
522
- "n": len(summaries),
523
- "summaries": json.dumps(summaries, indent=2),
524
- })
525
- out_path = Path(summaries_json_path).parent / "topic_labels.json"
526
- out_path.write_text(json.dumps(result, indent=2))
527
-
528
- return json.dumps({
529
- "labelled_topics": len(result),
530
- "output": str(out_path),
531
- "needs_review": True,
532
- }, indent=2)
533
-
534
-
535
- @tool
536
- def reassign_sentences(
537
- summaries_json_path: str,
538
- embeddings_npy_path: str,
539
- move_instructions: str,
540
- ) -> str:
541
- """Move orphan or misplaced sentences between clusters.
542
-
543
- Phase 2 — Reassigning orphan data extracts. DETERMINISTIC.
544
-
545
- The reviewer specifies moves as JSON:
546
- [{"sentence_idx": 42, "to_cluster": 3},
547
- {"sentence_idx": 99, "to_cluster": "new"}]
548
-
549
- For "new" targets, a fresh cluster ID is assigned.
550
- After all moves, centroids are recomputed for affected clusters.
551
-
552
- Steps:
553
- 1. Load summaries.json and embeddings
554
- 2. Parse move instructions
555
- 3. Update cluster assignments
556
- 4. Recompute centroids for affected clusters
557
- 5. Save updated summaries.json
558
-
559
- Args:
560
- summaries_json_path: Path to summaries.json.
561
- embeddings_npy_path: Path to .emb.npy.
562
- move_instructions: JSON array of {sentence_idx, to_cluster} dicts.
563
-
564
- Returns:
565
- JSON: moves_applied count, orphans_remaining, updated summaries path.
566
- """
567
- data = json.loads(Path(summaries_json_path).read_text())
568
- embeddings = np.load(embeddings_npy_path)
569
- moves = json.loads(move_instructions)
570
- clusters = data.get("clusters", [])
571
- orphans = data.get("orphans", [])
572
-
573
- all_indices = {}
574
- list(map(
575
- lambda c: all_indices.update({idx: c["topic_id"] for idx in c.get("indices", [])}),
576
- clusters,
577
- ))
578
-
579
- max_id = max(map(lambda c: c.get("topic_id", 0), clusters), default=0)
580
- new_id_counter = [max_id + 1]
581
-
582
- def _apply_move(m: dict) -> dict:
583
- """Apply one move instruction, return the resolved target cluster ID."""
584
- s_idx = m["sentence_idx"]
585
- target = m["to_cluster"]
586
- resolved = (target == "new") and new_id_counter.__setitem__(0, new_id_counter[0] + 1) or target
587
- final_id = new_id_counter[0] - 1 * (target == "new") + target * (target != "new")
588
- all_indices[s_idx] = int(target) * (target != "new") + new_id_counter[0] * (target == "new")
589
- return {"sentence_idx": s_idx, "assigned_to": all_indices[s_idx]}
590
-
591
- applied = list(map(_apply_move, moves))
592
-
593
- unique_clusters = set(all_indices.values())
594
-
595
- def _rebuild_cluster(cid: int) -> dict:
596
- """Rebuild a cluster dict from the updated index map."""
597
- idx = [k for k, v in all_indices.items() if v == cid]
598
- vecs = embeddings[idx or [0]]
599
- return {
600
- "topic_id": int(cid),
601
- "size": len(idx),
602
- "representative": "",
603
- "indices": idx,
604
- "centroid": _centroid(vecs).tolist(),
605
- }
606
-
607
- updated_clusters = list(map(_rebuild_cluster, sorted(unique_clusters)))
608
- remaining_orphan_idx = [o["sentence_idx"] for o in orphans
609
- if o["sentence_idx"] not in all_indices]
610
-
611
- output = {
612
- "clusters": updated_clusters,
613
- "orphans": list(map(
614
- lambda i: {"sentence_idx": i, "text": ""},
615
- remaining_orphan_idx,
616
- )),
617
- }
618
- Path(summaries_json_path).write_text(json.dumps(output, indent=2))
619
-
620
- return json.dumps({
621
- "moves_applied": len(applied),
622
- "orphans_remaining": len(remaining_orphan_idx),
623
- "summaries_json": summaries_json_path,
624
- "needs_review": True,
625
- }, indent=2)
626
-
627
-
628
- @tool
629
- def consolidate_into_themes(
630
- labels_json_path: str,
631
- embeddings_npy_path: str,
632
- approved_topic_ids: str,
633
- ) -> str:
634
- """Merge approved topic groups into consolidated themes.
635
-
636
- Phase 3 — Searching for Themes. DETERMINISTIC.
637
-
638
- Steps:
639
- 1. Load topic_labels.json and embedding matrix
640
- 2. Parse approved groupings (JSON array of arrays)
641
- 3. Pool all member embeddings per group
642
- 4. Compute fresh L2-normalized centroid per merged group
643
- 5. Build theme name from joined sub-labels
644
- 6. Save themes.json
645
-
646
- Args:
647
- labels_json_path: Path to topic_labels.json.
648
- embeddings_npy_path: Path to .emb.npy.
649
- approved_topic_ids: JSON array of arrays, e.g. [[0,1,2],[3,4],[5]].
650
-
651
- Returns:
652
- JSON: themes_created count + themes_json path. needs_review=True.
653
- """
654
- labels_data = json.loads(Path(labels_json_path).read_text())
655
- embeddings = np.load(embeddings_npy_path)
656
- groups = json.loads(approved_topic_ids)
657
- label_map = {item["topic_id"]: item for item in labels_data}
658
-
659
- def _merge_group(group_ids: list[int]) -> dict:
660
- """Merge topic IDs into one theme, recompute centroid."""
661
- members = [m for m in map(label_map.get, group_ids) if m is not None]
662
- all_idx = sum(map(lambda m: m.get("indices", []), members), [])
663
- vecs = embeddings[all_idx or [0]]
664
- centroid = _centroid(vecs)
665
- sub_labels = list(map(lambda m: m.get("label", ""), members))
666
- theme_name = "_".join(
667
- dict.fromkeys(sum(map(lambda lbl: lbl.split("_"), sub_labels), []))
668
- )[:60]
669
- return {
670
- "theme_id": group_ids[0],
671
- "theme_label": theme_name,
672
- "merged_ids": group_ids,
673
- "total_papers": len(set(all_idx)),
674
- "indices": all_idx,
675
- "centroid": centroid.tolist(),
676
- }
677
-
678
- themes = list(map(_merge_group, groups))
679
- out_path = Path(labels_json_path).parent / "themes.json"
680
- out_path.write_text(json.dumps(themes, indent=2))
681
-
682
- return json.dumps({
683
- "themes_created": len(themes),
684
- "themes_json": str(out_path),
685
- "needs_review": True,
686
- }, indent=2)
687
-
688
-
689
- @tool
690
- def compute_saturation(
691
- themes_json_path: str,
692
- embeddings_npy_path: str,
693
- total_papers: int,
694
- ) -> str:
695
- """Compute saturation metrics per theme: coverage, coherence, balance.
696
-
697
- Phase 4 — Reviewing Themes. DETERMINISTIC.
698
-
699
- Every number in the output is computed by numpy — the LLM never
700
- calculates these values. This eliminates hallucination risk for
701
- percentages, scores, and ratios.
702
-
703
- Metrics per theme:
704
- coverage = papers_in_theme / total_papers (exact percentage)
705
- coherence = mean pairwise cosine similarity of member embeddings
706
- (1.0 = all identical, 0.0 = orthogonal)
707
-
708
- Global metrics:
709
- total_coverage = papers in at least one theme / total_papers
710
- balance_ratio = largest_theme / smallest_theme
711
- mean_coherence = average of per-theme coherence scores
712
-
713
- Args:
714
- themes_json_path: Path to themes.json.
715
- embeddings_npy_path: Path to .emb.npy.
716
- total_papers: Total papers in corpus (from Phase 1 stats).
717
-
718
- Returns:
719
- JSON: per-theme metrics + global metrics. needs_review=True.
720
- """
721
- themes = json.loads(Path(themes_json_path).read_text())
722
- embeddings = np.load(embeddings_npy_path)
723
-
724
- def _theme_metrics(t: dict) -> dict:
725
- """Compute coverage and coherence for one theme."""
726
- idx = t.get("indices", [])
727
- size = len(idx)
728
- vecs = embeddings[idx or [0]]
729
- sim = cosine_similarity(vecs)
730
- n = len(vecs)
731
- coherence = float(
732
- (sim.sum() - n) / max(n * (n - 1), 1)
733
- )
734
- return {
735
- "theme_id": t.get("theme_id", 0),
736
- "theme_label": t.get("theme_label", ""),
737
- "papers": size,
738
- "coverage_pct": round(size / max(total_papers, 1) * 100, 2),
739
- "coherence": round(coherence, 4),
740
- }
741
-
742
- per_theme = list(map(_theme_metrics, themes))
743
-
744
- all_paper_idx = set(sum(map(lambda t: t.get("indices", []), themes), []))
745
- sizes = list(map(lambda m: m["papers"], per_theme))
746
- coherences = list(map(lambda m: m["coherence"], per_theme))
747
-
748
- global_metrics = {
749
- "total_coverage_pct": round(len(all_paper_idx) / max(total_papers, 1) * 100, 2),
750
- "balance_ratio": round(max(sizes, default=1) / max(min(sizes, default=1), 1), 2),
751
- "mean_coherence": round(sum(coherences) / max(len(coherences), 1), 4),
752
- "theme_count": len(themes),
753
- }
754
-
755
- out_path = Path(themes_json_path).parent / "saturation.json"
756
- result = {"per_theme": per_theme, "global": global_metrics}
757
- out_path.write_text(json.dumps(result, indent=2))
758
-
759
- return json.dumps({
760
- **global_metrics,
761
- "per_theme": per_theme,
762
- "saturation_json": str(out_path),
763
- "needs_review": True,
764
- }, indent=2)
765
-
766
-
767
- @tool
768
- def generate_theme_profiles(
769
- themes_json_path: str,
770
- embeddings_npy_path: str,
771
- texts_parquet_path: str,
772
- run_mode: str = "abstract",
773
- ) -> str:
774
- """Generate profile cards with top-5 nearest sentences per theme.
775
-
776
- Phase 5 — Defining and Naming Themes. DETERMINISTIC.
777
-
778
- For each theme centroid, computes cosine similarity against ALL
779
- embeddings and returns the 5 closest sentences. These are the
780
- REAL sentences from the corpus — not generated, not recalled
781
- from conversation history. The reviewer uses these to decide
782
- on final theme names.
783
-
784
- Steps:
785
- 1. Load themes.json with centroids
786
- 2. Load full embedding matrix
787
- 3. Load original texts from parquet
788
- 4. For each theme: cosine_similarity(centroid, all_embeddings)
789
- 5. Take top 5 by similarity score
790
- 6. Return exact sentence text + similarity score
791
- 7. Save profiles.json
792
-
793
- Args:
794
- themes_json_path: Path to themes.json.
795
- embeddings_npy_path: Path to .emb.npy.
796
- texts_parquet_path: Path to .clean.parquet (for original text).
797
- run_mode: 'abstract' or 'title'.
798
-
799
- Returns:
800
- JSON: profiles list with top-5 sentences per theme. needs_review=True.
801
- """
802
- themes = json.loads(Path(themes_json_path).read_text())
803
- embeddings = np.load(embeddings_npy_path)
804
- target = f"{RUN_CONFIGS[run_mode][0]}_clean"
805
- texts = pd.read_parquet(texts_parquet_path)[target].tolist()
806
-
807
- def _profile(t: dict) -> dict:
808
- """Build a profile card for one theme: centroid → top 5 nearest."""
809
- centroid = np.array(t["centroid"]).reshape(1, -1)
810
- sims = cosine_similarity(centroid, embeddings)[0]
811
- top5_idx = np.argsort(sims)[::-1][:5].tolist()
812
- top5 = list(map(
813
- lambda i: {
814
- "sentence_idx": i,
815
- "text": texts[i][:300],
816
- "similarity": round(float(sims[i]), 4),
817
- },
818
- top5_idx,
819
- ))
820
- return {
821
- "theme_id": t.get("theme_id", 0),
822
- "theme_label": t.get("theme_label", ""),
823
- "total_papers": t.get("total_papers", 0),
824
- "top_5_sentences": top5,
825
- }
826
-
827
- profiles = list(map(_profile, themes))
828
- out_path = Path(themes_json_path).parent / "profiles.json"
829
- out_path.write_text(json.dumps(profiles, indent=2))
830
-
831
- return json.dumps({
832
- "profiles_count": len(profiles),
833
- "profiles_json": str(out_path),
834
- "profiles": profiles,
835
- "needs_review": True,
836
- }, indent=2)
837
-
838
-
839
- @tool
840
- def compare_with_taxonomy(themes_json_path: str) -> str:
841
- """Map each theme to PAJAIS 25 IS research categories via Mistral.
842
-
843
- Phase 5.5 — Taxonomy Alignment (extension). LLM-DEPENDENT.
844
-
845
- Themes with alignment_score < 0.50 are flagged as potentially NOVEL.
846
-
847
- Args:
848
- themes_json_path: Path to themes.json.
849
-
850
- Returns:
851
- JSON: themes_aligned count + taxonomy_file path. needs_review=True.
852
- """
853
- themes = json.loads(Path(themes_json_path).read_text())
854
-
855
- safe_themes = list(map(
856
- lambda t: {k: v for k, v in t.items() if k not in ("centroid", "indices")},
857
- themes,
858
- ))
859
-
860
- template = (
861
- "You are an IS research taxonomy expert.\n\n"
862
- "PAJAIS 25 Categories:\n{pajais}\n\n"
863
- "Research themes:\n{themes}\n\n"
864
- "For EACH theme return a JSON array where every element has:\n"
865
- " theme_label : string\n"
866
- " pajais_categories : list of 1-3 matching PAJAIS category names\n"
867
- " alignment_score : float 0.0-1.0\n"
868
- " notes : one sentence justification\n\n"
869
- "Return ONLY the JSON array — no markdown, no preamble."
870
- )
871
-
872
- result = _mistral_chain(template).invoke({
873
- "pajais": "\n".join(map(lambda c: f"- {c}", PAJAIS_25)),
874
- "themes": json.dumps(safe_themes, indent=2),
875
- })
876
- out_path = Path(themes_json_path).parent / "taxonomy_alignment.json"
877
- out_path.write_text(json.dumps(result, indent=2))
878
-
879
- return json.dumps({
880
- "themes_aligned": len(result),
881
- "taxonomy_file": str(out_path),
882
- "needs_review": True,
883
- }, indent=2)
884
-
885
-
886
- @tool
887
- def generate_comparison_csv(
888
- abstract_themes_path: str,
889
- title_themes_path: str,
890
- taxonomy_abstract_path: str,
891
- taxonomy_title_path: str,
892
- ) -> str:
893
- """Build side-by-side abstract vs title comparison CSV.
894
-
895
- Phase 6 — Report. DETERMINISTIC.
896
-
897
- Joins on PAJAIS_Category. Delta_Score = Abstract - Title.
898
-
899
- Args:
900
- abstract_themes_path: themes.json — abstract run.
901
- title_themes_path: themes.json — title run.
902
- taxonomy_abstract_path: taxonomy_alignment.json — abstract run.
903
- taxonomy_title_path: taxonomy_alignment.json — title run.
904
-
905
- Returns:
906
- JSON: comparison_csv path, total_rows, columns. needs_review=True.
907
- """
908
- def _explode_taxonomy(path: str) -> pd.DataFrame:
909
- """Flatten taxonomy alignment into one row per PAJAIS category."""
910
- data = json.loads(Path(path).read_text())
911
- rows = sum(
912
- list(map(
913
- lambda item: list(map(
914
- lambda cat: {
915
- "pajais_category": cat,
916
- "theme_label": item.get("theme_label", ""),
917
- "alignment_score": item.get("alignment_score", 0.0),
918
- },
919
- item.get("pajais_categories", []),
920
- )),
921
- data,
922
- )),
923
- [],
924
- )
925
- return pd.DataFrame(rows)
926
-
927
- df_abs = _explode_taxonomy(taxonomy_abstract_path)
928
- df_title = _explode_taxonomy(taxonomy_title_path)
929
-
930
- df_abs.columns = ["PAJAIS_Category", "Abstract_Theme", "Abstract_Score"]
931
- df_title.columns = ["PAJAIS_Category", "Title_Theme", "Title_Score"]
932
-
933
- merged = (
934
- pd.merge(df_abs, df_title, on="PAJAIS_Category", how="outer")
935
- .fillna({"Abstract_Score": 0.0, "Title_Score": 0.0,
936
- "Abstract_Theme": "", "Title_Theme": ""})
937
- .assign(Delta_Score=lambda d: (d["Abstract_Score"] - d["Title_Score"]).round(4))
938
- .sort_values("PAJAIS_Category")
939
- .reset_index(drop=True)
940
- )
941
-
942
- out_csv = Path(abstract_themes_path).parent / "abstract_vs_title_comparison.csv"
943
- merged.to_csv(out_csv, index=False)
944
-
945
- return json.dumps({
946
- "comparison_csv": str(out_csv),
947
- "total_rows": len(merged),
948
- "columns": list(merged.columns),
949
- "needs_review": True,
950
- }, indent=2)
951
-
952
-
953
- @tool
954
- def export_narrative(
955
- taxonomy_alignment_path: str,
956
- comparison_csv_path: str,
957
- run_mode: str = "abstract",
958
- ) -> str:
959
- """Generate 500-word Section 7: Discussion & Implications via Mistral.
960
-
961
- Phase 6 — Report. LLM-DEPENDENT (grounded in taxonomy + comparison data).
962
-
963
- Args:
964
- taxonomy_alignment_path: Path to taxonomy_alignment.json.
965
- comparison_csv_path: Path to comparison CSV.
966
- run_mode: 'abstract' or 'title'.
967
-
968
- Returns:
969
- JSON: narrative_path, word_count, narrative text. needs_review=True.
970
- """
971
- alignment = json.loads(Path(taxonomy_alignment_path).read_text())
972
-
973
- top_delta = (
974
- pd.read_csv(comparison_csv_path)
975
- .assign(_abs=lambda d: d["Delta_Score"].abs())
976
- .sort_values("_abs", ascending=False)
977
- .drop(columns=["_abs"])
978
- .head(5)
979
- )
980
-
981
- template = (
982
- "You are a senior IS researcher writing a systematic literature review.\n\n"
983
- "Write Section 7: Discussion & Implications in exactly {word_count} words.\n\n"
984
- "Run mode: {run_mode}\n\n"
985
- "Taxonomy alignment (top 10):\n{alignment}\n\n"
986
- "Top 5 divergent PAJAIS categories (abstract vs title):\n{divergence}\n\n"
987
- "Requirements:\n"
988
- "1. Discuss dominant themes and PAJAIS alignment.\n"
989
- "2. Interpret divergence between abstract- and title-based models.\n"
990
- "3. Highlight implications for IS research practice and future agenda.\n"
991
- "4. Use formal academic register — no bullet points.\n"
992
- "5. Return a JSON object with a single key 'narrative' containing the prose.\n\n"
993
- "Return ONLY valid JSON."
994
- )
995
-
996
- result = _mistral_chain(template).invoke({
997
- "word_count": NARRATIVE_WORDS,
998
- "run_mode": run_mode,
999
- "alignment": json.dumps(alignment[:10], indent=2),
1000
- "divergence": top_delta.to_json(orient="records", indent=2),
1001
- })
1002
- narrative_text = result.get("narrative", str(result))
1003
- out_path = Path(taxonomy_alignment_path).parent / "narrative.md"
1004
- out_path.write_text(
1005
- f"## Section 7: Discussion & Implications\n\n{narrative_text}\n",
1006
- encoding="utf-8",
1007
- )
1008
-
1009
- return json.dumps({
1010
- "narrative_path": str(out_path),
1011
- "word_count": len(narrative_text.split()),
1012
- "narrative": narrative_text,
1013
- "needs_review": True,
1014
- }, indent=2)
1015
-
1016
-
1017
- ALL_TOOLS = [
1018
- load_scopus_csv,
1019
- run_bertopic_discovery,
1020
- label_topics_with_llm,
1021
- reassign_sentences,
1022
- consolidate_into_themes,
1023
- compute_saturation,
1024
- generate_theme_profiles,
1025
- compare_with_taxonomy,
1026
- generate_comparison_csv,
1027
- export_narrative,
1028
- ]