milindkamat0507 commited on
Commit
0e39187
·
verified ·
1 Parent(s): 2fda64b

Delete tools.py

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