milindkamat0507 commited on
Commit
2fda64b
Β·
verified Β·
1 Parent(s): 530f665

Upload tools.py

Browse files
Files changed (1) hide show
  1. tools.py +1027 -0
tools.py ADDED
@@ -0,0 +1,1027 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ]