milindkamat0507 commited on
Commit
d516687
Β·
verified Β·
1 Parent(s): 9c14a04

Upload 4 files

Browse files
Files changed (4) hide show
  1. agent.py +143 -0
  2. app.py +294 -0
  3. requirements.txt +10 -0
  4. tools.py +999 -0
agent.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agent.py β€” Braun & Clarke (2006) Thematic Analysis Agent.
3
+
4
+ 10 tools. 6 STOP gates. Reviewer approval after every interpretive output.
5
+ Every number comes from a tool β€” the LLM never computes values.
6
+ """
7
+
8
+ from langchain_mistralai import ChatMistralAI
9
+ from langchain.agents import create_agent
10
+ from langgraph.checkpoint.memory import InMemorySaver
11
+ from tools import ALL_TOOLS
12
+
13
+ SYSTEM_PROMPT = """
14
+ You are a Braun & Clarke (2006) Computational Thematic Analysis Agent.
15
+
16
+ RULES:
17
+ 1. ONE PHASE PER MESSAGE.
18
+ 2. ALL APPROVALS VIA REVIEW TABLE β€” never via chat. When review needed:
19
+ [WAITING FOR REVIEW TABLE]
20
+ Edit Approve / Rename To / Move To / Reasoning, then Submit Review.
21
+ 3. NEVER FABRICATE DATA β€” every number, percentage, score, sentence list
22
+ MUST come from a tool. You CANNOT do arithmetic. If you need a number,
23
+ call a tool. If no tool exists for what you need, say so.
24
+ 4. STOP GATES ARE ABSOLUTE β€” [FAILED] halts unconditionally.
25
+ 5. EMIT PHASE STATUS at top of every response:
26
+ "[Phase X/6 | STOP Gates Passed: N/6 | Pending Review: Yes/No]"
27
+ 6. TOOL ERRORS: log verbatim, identify cause, propose fix, wait.
28
+ 7. AUTHOR KEYWORDS EXCLUDED from all embedding and clustering.
29
+ 8. CHAT IS CONVERSATION, NOT DATA DUMP.
30
+ Your response in the chat window must be SHORT and CONVERSATIONAL:
31
+ - 3-5 sentences maximum summarising what you did
32
+ - State key numbers: "Found 45 clusters, 12 orphans"
33
+ - ALWAYS end with: "Results are loaded in the Review Table below."
34
+ - NEVER put markdown tables, JSON, raw data, or long lists in chat
35
+ - NEVER repeat the full tool output in chat
36
+ The Review Table (Section 3) auto-populates from your tool's
37
+ checkpoint files. The user sees the data THERE, not in chat.
38
+ Example good response:
39
+ "[Phase 2/6 | STOP Gates Passed: 0/6 | Pending Review: Yes]
40
+ I ran BERTopic discovery on 1,390 abstracts. Found 98 clusters
41
+ (min 3 members each) and 47 orphan sentences. Labelled the top
42
+ 100 clusters via Mistral. Results are loaded in the Review Table
43
+ below. Please review and Submit when ready."
44
+ Example BAD response:
45
+ "[Phase 2/6 ...] Here are all 98 clusters: | # | Label | ...
46
+ (50 rows of markdown table dumped into chat)"
47
+
48
+ 10 TOOLS:
49
+ DETERMINISTIC (same input β†’ same output):
50
+ 1. load_scopus_csv β€” Phase 1: clean CSV, count, save .parquet
51
+ 2. run_bertopic_discovery β€” Phase 2: embed + cluster (min 3 members)
52
+ + orphan report + 4 charts
53
+ 4. reassign_sentences β€” Phase 2: move orphans/sentences between clusters
54
+ 5. consolidate_into_themes β€” Phase 3: merge groups, recompute centroids
55
+ 6. compute_saturation β€” Phase 4: coverage %, coherence, balance
56
+ 7. generate_theme_profiles β€” Phase 5: top 5 nearest sentences per theme
57
+ 9. generate_comparison_csv β€” Phase 6: abstract vs title joined on PAJAIS
58
+
59
+ LLM-DEPENDENT (grounded in real data, reviewer must approve):
60
+ 3. label_topics_with_llm β€” Phase 2: Mistral names clusters
61
+ 8. compare_with_taxonomy β€” Phase 5.5: map themes to PAJAIS 25
62
+ 10. export_narrative β€” Phase 6: 500-word Section 7
63
+
64
+ B&C 6-PHASE METHODOLOGY:
65
+
66
+ PHASE 1 β€” FAMILIARISATION
67
+ The user message may contain a [CSV: /path/to/file.csv] prefix.
68
+ Extract the FULL path (everything between "CSV: " and "]") and pass
69
+ it as csv_path to load_scopus_csv. Do NOT modify or shorten the path.
70
+ Call load_scopus_csv. Show stats. STOP. Wait for "run abstract"/"run title".
71
+
72
+ PHASE 2 β€” INITIAL CODES
73
+ Call run_bertopic_discovery. Report: total clusters, orphan count.
74
+ If orphans > 0, tell reviewer: "N sentences did not fit any cluster
75
+ (minimum 3 members required). Review them and use Move To column."
76
+ Call label_topics_with_llm. Show top-20 labels.
77
+ STOP GATE 1: SG1-A (<5 topics), SG1-B (confidence <0.40),
78
+ SG1-C (>40% generic), SG1-D (duplicates).
79
+ [WAITING FOR REVIEW TABLE]. STOP.
80
+ On Submit Review: if moves exist, call reassign_sentences.
81
+
82
+ PHASE 3 β€” THEMES
83
+ Parse review. Call consolidate_into_themes.
84
+ STOP GATE 2: SG2-A (<3 themes), SG2-B (singleton),
85
+ SG2-C (duplicates), SG2-D (coverage <50%).
86
+ [WAITING FOR REVIEW TABLE]. STOP.
87
+
88
+ PHASE 4 β€” SATURATION
89
+ Call compute_saturation (NEVER compute these numbers yourself).
90
+ Present the EXACT numbers returned by the tool.
91
+ STOP GATE 3: SG3-A (coverage <60%), SG3-B (single theme >60%),
92
+ SG3-C (coherence <0.30), SG3-D (<3 themes).
93
+ [WAITING FOR REVIEW TABLE]. STOP.
94
+
95
+ PHASE 5 β€” NAMING
96
+ Call generate_theme_profiles (NEVER recall sentences from memory).
97
+ Present the EXACT top-5 sentences returned by the tool per theme.
98
+ Propose names based on these real sentences.
99
+ [WAITING FOR REVIEW TABLE]. STOP.
100
+
101
+ PHASE 5.5 β€” PAJAIS MAPPING
102
+ Call compare_with_taxonomy.
103
+ STOP GATE 4: SG4-A (zero categories), SG4-B (>30% score <0.40),
104
+ SG4-C (single category >50%), SG4-D (incomplete).
105
+ [WAITING FOR REVIEW TABLE]. STOP.
106
+
107
+ PHASE 6 β€” REPORT
108
+ Call generate_comparison_csv. Present convergence/divergence summary.
109
+ STOP GATE 5: Reviewer confirms comparison makes sense.
110
+ [WAITING FOR REVIEW TABLE]. STOP.
111
+ Call export_narrative. Present full 500-word draft.
112
+ STOP GATE 6: Reviewer approves final narrative.
113
+ [WAITING FOR REVIEW TABLE]. STOP.
114
+ DONE β€” all 6 gates passed.
115
+
116
+ 6 STOP GATES:
117
+ STOP-1 (Phase 2) : Initial Code Quality
118
+ STOP-2 (Phase 3) : Theme Coherence
119
+ STOP-3 (Phase 4) : Saturation Adequacy
120
+ STOP-4 (Phase 5.5) : Taxonomy Alignment Quality
121
+ STOP-5 (Phase 6) : Comparison Review [NEW]
122
+ STOP-6 (Phase 6) : Narrative Approval [NEW]
123
+ """
124
+
125
+ llm = ChatMistralAI(model="mistral-large-latest", temperature=0, max_tokens=8192)
126
+
127
+ memory = InMemorySaver()
128
+
129
+ agent = create_agent(
130
+ model=llm,
131
+ tools=ALL_TOOLS,
132
+ system_prompt=SYSTEM_PROMPT,
133
+ checkpointer=memory,
134
+ )
135
+
136
+
137
+ def run(user_message: str, thread_id: str = "default") -> str:
138
+ """Invoke the agent for one conversation turn."""
139
+ config = {"configurable": {"thread_id": thread_id}}
140
+ payload = {"messages": [{"role": "user", "content": user_message}]}
141
+ result = agent.invoke(payload, config=config)
142
+ msgs = result.get("messages", [])
143
+ return (msgs and msgs[-1].content) or ""
app.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py β€” BERTopic Topic Modelling Agent UI.
3
+
4
+ Three UX features:
5
+ 1. Phase banner β€” large prominent display of current B&C phase
6
+ 2. Dynamic prompts β€” phase-appropriate suggested next actions
7
+ 3. Auto-populated review table β€” loads from tool checkpoint files
8
+
9
+ 9-column review table: #, Topic Label, Top Evidence, Sentences, Papers,
10
+ Approve, Rename To, Move To, Reasoning.
11
+ """
12
+
13
+ import gradio as gr
14
+ import pandas as pd
15
+ import json
16
+ import os
17
+ import re
18
+ import tempfile
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+ from agent import run as agent_run
22
+
23
+ THREAD_ID = f"bertopic-{datetime.now().strftime('%Y%m%d%H%M%S')}"
24
+
25
+ REVIEW_COLS = [
26
+ "#", "Topic Label", "Top Evidence", "Sentences", "Papers",
27
+ "Approve", "Rename To", "Move To", "Reasoning",
28
+ ]
29
+
30
+ EMPTY_TABLE = pd.DataFrame(columns=REVIEW_COLS)
31
+
32
+ PHASE_INFO = {
33
+ 0: ("Getting started", "⬜⬜⬜⬜⬜⬜",
34
+ "Upload a CSV file, then click **Analyze my Scopus CSV** and press Send"),
35
+ 1: ("Phase 1 β€” Familiarisation", "🟦⬜⬜⬜⬜⬜",
36
+ "Click **Run abstract analysis** or **Run title analysis** and press Send"),
37
+ 2: ("Phase 2 β€” Initial Codes", "🟦🟦⬜⬜⬜⬜",
38
+ "Review clusters in the **Results table** below. Edit Approve / Rename / Move, "
39
+ "then click **Submit Review**"),
40
+ 3: ("Phase 3 β€” Themes", "🟦🟦🟦⬜⬜⬜",
41
+ "Review merged themes. Edit the table, then click **Submit Review**"),
42
+ 4: ("Phase 4 β€” Saturation", "🟦🟦🟦🟦⬜⬜",
43
+ "Review saturation metrics. Click **Submit Review** to confirm"),
44
+ 5: ("Phase 5 β€” Naming", "🟦🟦🟦🟦🟦⬜",
45
+ "Review theme profiles. Edit names, then **Submit Review**"),
46
+ 6: ("Phase 6 β€” Report", "🟦🟦🟦🟦🟦🟦",
47
+ "Review comparison and narrative. **Submit Review** to finalise"),
48
+ }
49
+
50
+ _path = lambda file: str(
51
+ (hasattr(file, "name") and file.name)
52
+ or (isinstance(file, str) and file)
53
+ or ""
54
+ )
55
+ _name = lambda file: os.path.basename(_path(file))
56
+
57
+
58
+ def _extract_phase(text: str) -> int:
59
+ """Extract phase number from agent response. Returns 0 if not found."""
60
+ found = re.findall(r"Phase (\d)", str(text))
61
+ return int((found or ["0"])[0])
62
+
63
+
64
+ def _phase_banner(num: int) -> str:
65
+ """Generate prominent phase banner with progress bar and next step."""
66
+ name, progress, instruction = PHASE_INFO.get(num, PHASE_INFO[0])
67
+ return (
68
+ f"## {progress} {name}\n\n"
69
+ f"**NEXT STEP β†’** {instruction}"
70
+ )
71
+
72
+
73
+ def _load_review_table(base_dir: str) -> pd.DataFrame:
74
+ """Load latest checkpoint file into the 9-column review table.
75
+
76
+ Scans base_dir for topic_labels.json, themes.json, taxonomy_alignment.json,
77
+ summaries.json. Loads the most recently modified one and formats it.
78
+ Returns EMPTY_TABLE if nothing found.
79
+ """
80
+ base = Path(str(base_dir or ""))
81
+ candidates = sorted(
82
+ (
83
+ list(base.glob("topic_labels.json"))
84
+ + list(base.glob("themes.json"))
85
+ + list(base.glob("taxonomy_alignment.json"))
86
+ + list(base.glob("summaries.json"))
87
+ ),
88
+ key=lambda p: p.stat().st_mtime,
89
+ reverse=True,
90
+ ) * base.exists() or []
91
+
92
+ latest = (candidates[:1] or [None])[0]
93
+ return [EMPTY_TABLE.copy(), _format_checkpoint(latest)][bool(latest)]
94
+
95
+
96
+ def _format_checkpoint(path) -> pd.DataFrame:
97
+ """Format a checkpoint JSON file into review table rows."""
98
+ raw = json.loads(Path(path).read_text())
99
+ data = (isinstance(raw, dict) and raw.get("clusters", raw.get("per_theme", []))) or \
100
+ (isinstance(raw, list) and raw) or []
101
+
102
+ def _row(item: dict) -> dict:
103
+ """Map one JSON item to review table columns."""
104
+ return {
105
+ "#": item.get("topic_id", item.get("theme_id", 0)),
106
+ "Topic Label": item.get("label", item.get("theme_label", "")),
107
+ "Top Evidence": str(item.get("representative",
108
+ item.get("notes", "")))[:150],
109
+ "Sentences": item.get("size", item.get("total_papers",
110
+ item.get("papers", 0))),
111
+ "Papers": item.get("size", item.get("total_papers",
112
+ item.get("papers", 0))),
113
+ "Approve": "",
114
+ "Rename To": "",
115
+ "Move To": "",
116
+ "Reasoning": str(item.get("rationale", item.get("notes", ""))),
117
+ }
118
+
119
+ rows = list(map(_row, data[:50]))
120
+ return [EMPTY_TABLE.copy(), pd.DataFrame(rows, columns=REVIEW_COLS)][bool(rows)]
121
+
122
+
123
+ def on_file_upload(file):
124
+ """Extract CSV stats and store base directory."""
125
+ path = _path(file)
126
+ result = (not path) and ("Upload a CSV to begin.", "", _phase_banner(0))
127
+ return result or _do_file_upload(path, file)
128
+
129
+
130
+ def _do_file_upload(path: str, file) -> tuple:
131
+ """Actual file processing after path validation."""
132
+ df = pd.read_csv(path)
133
+ rows, cols = df.shape
134
+ base = str(Path(path).parent)
135
+ info = (
136
+ f"**Loaded:** `{_name(file)}`\n\n"
137
+ f"**Shape:** {rows:,} rows x {cols} columns\n\n"
138
+ f"**Columns:** {', '.join(df.columns[:6].tolist())}\n\n"
139
+ f"*Click a prompt below and press Send to begin.*"
140
+ )
141
+ return info, base, _phase_banner(1)
142
+
143
+
144
+ def on_send(user_msg, history, file, base_dir):
145
+ """Pass user message to agent. Update phase banner and review table."""
146
+ msg = (user_msg or "").strip() or "help"
147
+ csv_tag = f"[CSV: {_path(file)}]\n" * bool(file)
148
+
149
+ history = list(history or [])
150
+ history.append({"role": "user", "content": msg})
151
+ history.append({"role": "assistant", "content": "Thinking..."})
152
+ yield history, "", gr.skip(), gr.skip(), gr.skip()
153
+
154
+ reply = agent_run(csv_tag + msg, thread_id=THREAD_ID)
155
+ history[-1] = {"role": "assistant", "content": reply}
156
+
157
+ phase = _extract_phase(reply)
158
+ banner = _phase_banner(phase)
159
+ table = _load_review_table(base_dir)
160
+
161
+ yield history, "", banner, table, base_dir
162
+
163
+
164
+ def on_submit_review(table_df, history, base_dir):
165
+ """Serialise review table edits to agent."""
166
+ history = list(history or [])
167
+ edits = table_df.to_json(orient="records", indent=2)
168
+
169
+ history.append({"role": "user", "content": "[REVIEW SUBMITTED]"})
170
+ history.append({"role": "assistant", "content": "Processing review..."})
171
+
172
+ reply = agent_run(
173
+ "Reviewer submitted table edits.\n\n"
174
+ f"```json\n{edits}\n```\n\n"
175
+ "Process: Approve/Reject decisions, Rename To values, "
176
+ "Move To reassignments (call reassign_sentences if moves exist), "
177
+ "Reasoning notes. Then check STOP gates and proceed.",
178
+ thread_id=THREAD_ID,
179
+ )
180
+ history[-1] = {"role": "assistant", "content": reply}
181
+
182
+ phase = _extract_phase(reply)
183
+ return history, _phase_banner(phase), _load_review_table(base_dir)
184
+
185
+
186
+ def on_download(table_df, history):
187
+ """Export review CSV and chat TXT."""
188
+ csv_tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv", prefix="review_")
189
+ table_df.to_csv(csv_tmp.name, index=False)
190
+
191
+ txt_tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".txt", prefix="chat_")
192
+ txt_tmp.write(
193
+ "\n\n".join(
194
+ list(map(
195
+ lambda m: f"{m.get('role', '').upper()}: {m.get('content', '')}",
196
+ history or [],
197
+ ))
198
+ ).encode("utf-8")
199
+ )
200
+ txt_tmp.close()
201
+ return [csv_tmp.name, txt_tmp.name]
202
+
203
+
204
+ with gr.Blocks(title="BERTopic Agent") as demo:
205
+
206
+ base_dir_state = gr.State(value="")
207
+
208
+ gr.Markdown("# BERTopic Modelling Agent")
209
+ gr.Markdown(
210
+ "**Braun & Clarke 6-Phase Thematic Analysis** "
211
+ "| 10 Tools | 6 STOP Gates | Cosine Agglomerative Clustering"
212
+ )
213
+
214
+ phase_banner = gr.Markdown(value=_phase_banner(0))
215
+
216
+ gr.Markdown("---\n### Section 1 β€” Data input")
217
+ with gr.Row():
218
+ with gr.Column(scale=3):
219
+ file_input = gr.File(
220
+ label="Upload Scopus CSV",
221
+ file_types=[".csv"],
222
+ file_count="single",
223
+ )
224
+ with gr.Column(scale=5):
225
+ file_info = gr.Markdown("Upload a CSV to begin.")
226
+
227
+ gr.Markdown("---\n### Section 2 β€” Agent conversation")
228
+ chatbot = gr.Chatbot(label="BERTopic Agent", height=200)
229
+ with gr.Row():
230
+ msg_box = gr.Textbox(
231
+ placeholder="Type a message or click a prompt below, then press Send",
232
+ show_label=False, scale=7, lines=1,
233
+ )
234
+ send_btn = gr.Button("Send", variant="primary", scale=1)
235
+
236
+ gr.Examples(
237
+ examples=[
238
+ "Analyze my Scopus CSV",
239
+ "Run abstract analysis",
240
+ "Run title analysis",
241
+ "Proceed to next phase",
242
+ "Show corpus statistics",
243
+ ],
244
+ inputs=msg_box,
245
+ label="Quick prompts (click to fill, then press Send)",
246
+ )
247
+
248
+ gr.Markdown("---\n### Section 3 β€” Results (auto-populated from tool outputs)")
249
+ gr.Markdown(
250
+ "This table fills automatically when the agent runs tools. "
251
+ "Edit **Approve**, **Rename To**, **Move To**, **Reasoning** columns, "
252
+ "then click **Submit Review**."
253
+ )
254
+ review_table = gr.Dataframe(
255
+ value=EMPTY_TABLE,
256
+ headers=REVIEW_COLS,
257
+ datatype=["number", "str", "str", "number", "number",
258
+ "str", "str", "str", "str"],
259
+ column_count=(9, "fixed"),
260
+ interactive=True, wrap=True,
261
+ )
262
+ with gr.Row():
263
+ clear_btn = gr.Button("Clear table", variant="secondary", scale=2)
264
+ sub_btn = gr.Button("Submit Review", variant="primary", scale=4)
265
+
266
+ with gr.Accordion("Download", open=False):
267
+ dl_btn = gr.Button("Generate downloads", variant="primary")
268
+ dl_files = gr.File(label="Downloads", file_count="multiple",
269
+ interactive=False)
270
+
271
+ file_input.change(
272
+ on_file_upload,
273
+ inputs=[file_input],
274
+ outputs=[file_info, base_dir_state, phase_banner],
275
+ )
276
+ send_btn.click(
277
+ on_send,
278
+ inputs=[msg_box, chatbot, file_input, base_dir_state],
279
+ outputs=[chatbot, msg_box, phase_banner, review_table, base_dir_state],
280
+ )
281
+ msg_box.submit(
282
+ on_send,
283
+ inputs=[msg_box, chatbot, file_input, base_dir_state],
284
+ outputs=[chatbot, msg_box, phase_banner, review_table, base_dir_state],
285
+ )
286
+ clear_btn.click(lambda: EMPTY_TABLE.copy(), outputs=[review_table])
287
+ sub_btn.click(
288
+ on_submit_review,
289
+ inputs=[review_table, chatbot, base_dir_state],
290
+ outputs=[chatbot, phase_banner, review_table],
291
+ )
292
+ dl_btn.click(on_download, inputs=[review_table, chatbot], outputs=[dl_files])
293
+
294
+ demo.launch(ssr_mode=False, theme=gr.themes.Soft())
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=6.0.0
2
+ langchain>=1.0.0
3
+ langchain-mistralai>=1.0.0
4
+ langgraph>=1.0.0
5
+ sentence-transformers>=3.0.0
6
+ scikit-learn>=1.4.0
7
+ numpy>=1.26.0
8
+ pandas>=2.1.0
9
+ plotly>=5.20.0
10
+ pyarrow>=15.0.0
tools.py ADDED
@@ -0,0 +1,999 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.7
39
+ Agglomerative stops merging at cosine distance > 0.7.
40
+ 0.5 β†’ ~2,102 clusters. 0.7 β†’ ~100 clusters. 0.8 β†’ ~30 clusters.
41
+
42
+ MIN_CLUSTER_SIZE = 3
43
+ Clusters with fewer than 3 members are dissolved. Their sentences
44
+ become orphans (label=-1) reported to the reviewer for reassignment.
45
+
46
+ N_CENTROIDS = 5
47
+ Top clusters extracted for initial discovery report and charts.
48
+
49
+ TOP_TOPICS_LLM = 100
50
+ Maximum clusters sent to Mistral for labelling.
51
+
52
+ NARRATIVE_WORDS = 500
53
+ Target word count for Section 7 narrative.
54
+
55
+ PAJAIS_25
56
+ 25 IS research categories from Jiang et al. (2019).
57
+ Used in Phase 5.5 for taxonomy alignment.
58
+
59
+ BOILERPLATE_PATTERNS (9 regexes)
60
+ Strip publisher noise: copyright, DOI, Elsevier, Springer,
61
+ IEEE, Wiley, Taylor & Francis.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import json
67
+ import re
68
+ import numpy as np
69
+ import pandas as pd
70
+ import plotly.graph_objects as go
71
+
72
+ from pathlib import Path
73
+ from langchain_core.tools import tool
74
+ from langchain_mistralai import ChatMistralAI
75
+ from langchain_core.prompts import PromptTemplate
76
+ from langchain_core.output_parsers import JsonOutputParser
77
+ from sentence_transformers import SentenceTransformer
78
+ from sklearn.cluster import AgglomerativeClustering
79
+ from sklearn.metrics.pairwise import cosine_similarity
80
+ from sklearn.preprocessing import normalize
81
+ from sklearn.decomposition import PCA
82
+
83
+
84
+ RUN_CONFIGS = {
85
+ "abstract": ["Abstract"],
86
+ "title": ["Title"],
87
+ }
88
+
89
+ PAJAIS_25 = [
90
+ "Accounting Information Systems",
91
+ "Artificial Intelligence & Expert Systems",
92
+ "Big Data & Analytics",
93
+ "Business Intelligence & Decision Support",
94
+ "Cloud Computing",
95
+ "Cybersecurity & Privacy",
96
+ "Database Management",
97
+ "Digital Transformation",
98
+ "E-Business & E-Commerce",
99
+ "Enterprise Resource Planning",
100
+ "Fintech & Digital Finance",
101
+ "Geographic Information Systems",
102
+ "Health Informatics",
103
+ "Human-Computer Interaction",
104
+ "Information Systems Development",
105
+ "IT Governance & Management",
106
+ "IT Strategy & Competitive Advantage",
107
+ "Knowledge Management",
108
+ "Machine Learning & Deep Learning",
109
+ "Mobile Computing",
110
+ "Natural Language Processing",
111
+ "Recommender Systems",
112
+ "Social Media & Web 2.0",
113
+ "Supply Chain & Logistics IS",
114
+ "Virtual Reality & Augmented Reality",
115
+ ]
116
+
117
+ BOILERPLATE_PATTERNS = [
118
+ r"Β©\s*\d{4}",
119
+ r"all rights reserved",
120
+ r"published by elsevier",
121
+ r"this article is protected",
122
+ r"doi:\s*10\.\d{4,}",
123
+ r"springer nature",
124
+ r"ieee xplore",
125
+ r"wiley online library",
126
+ r"taylor & francis",
127
+ ]
128
+
129
+ BOILERPLATE_RE = re.compile("|".join(BOILERPLATE_PATTERNS), flags=re.IGNORECASE)
130
+ SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+")
131
+ EMBED_MODEL = "all-MiniLM-L6-v2"
132
+ N_CENTROIDS = 5
133
+ CLUSTER_THRESHOLD = 0.7
134
+ MIN_CLUSTER_SIZE = 3
135
+ TOP_TOPICS_LLM = 100
136
+ NARRATIVE_WORDS = 500
137
+
138
+
139
+ def _clean_text(text: str) -> str:
140
+ """Remove publisher boilerplate from a single text string.
141
+
142
+ Applies 9-pattern BOILERPLATE_RE regex to strip copyright notices,
143
+ DOI prefixes, and publisher tags that would pollute embeddings.
144
+
145
+ Args:
146
+ text: Raw abstract or title string.
147
+
148
+ Returns:
149
+ Cleaned string with boilerplate removed and whitespace trimmed.
150
+ """
151
+ return BOILERPLATE_RE.sub("", str(text)).strip()
152
+
153
+
154
+ def _sentence_count(text: str) -> int:
155
+ """Count sentences using regex split on terminal punctuation.
156
+
157
+ Args:
158
+ text: Cleaned abstract or title text.
159
+
160
+ Returns:
161
+ Number of sentences (minimum 1 for any non-empty input).
162
+ """
163
+ return len(SENTENCE_SPLIT_RE.split(text.strip()))
164
+
165
+
166
+ def _embed(texts: list[str]) -> np.ndarray:
167
+ """Embed texts into 384d L2-normalized unit vectors.
168
+
169
+ Uses SentenceTransformer('all-MiniLM-L6-v2') locally β€” no API calls.
170
+ normalize_embeddings=True ensures cosine_similarity = dot product.
171
+
172
+ Args:
173
+ texts: List of N cleaned text strings.
174
+
175
+ Returns:
176
+ np.ndarray shape (N, 384), dtype float32, L2-normalized.
177
+ """
178
+ model = SentenceTransformer(EMBED_MODEL)
179
+ raw = model.encode(texts, show_progress_bar=False, normalize_embeddings=True)
180
+ return np.array(raw, dtype=np.float32)
181
+
182
+
183
+ def _cosine_cluster(matrix: np.ndarray, threshold: float, min_size: int) -> np.ndarray:
184
+ """Cluster embeddings using agglomerative cosine clustering.
185
+
186
+ Works DIRECTLY in 384d space β€” no UMAP. After clustering, any cluster
187
+ with fewer than min_size members is dissolved: its sentences get
188
+ label=-1 (orphan) and are reported to the reviewer for reassignment.
189
+
190
+ Algorithm:
191
+ 1. Start: every text is its own cluster.
192
+ 2. Merge the two closest clusters (average cosine distance).
193
+ 3. Repeat until smallest distance exceeds threshold.
194
+ 4. Post-process: dissolve clusters smaller than min_size.
195
+
196
+ Args:
197
+ matrix: (N, 384) embedding matrix, L2-normalized.
198
+ threshold: Max cosine distance for merging (0.7 β†’ ~100 clusters).
199
+ min_size: Minimum members per cluster (3). Smaller β†’ orphan.
200
+
201
+ Returns:
202
+ np.ndarray shape (N,) with integer labels. -1 = orphan.
203
+ """
204
+ normed = normalize(matrix, norm="l2")
205
+ model = AgglomerativeClustering(
206
+ n_clusters=None,
207
+ metric="cosine",
208
+ linkage="average",
209
+ distance_threshold=threshold,
210
+ )
211
+ labels = model.fit_predict(normed).astype(int)
212
+ unique, counts = np.unique(labels, return_counts=True)
213
+ small_clusters = unique[counts < min_size]
214
+ return np.where(np.isin(labels, small_clusters), -1, labels)
215
+
216
+
217
+ def _centroid(vecs: np.ndarray) -> np.ndarray:
218
+ """Compute L2-normalized centroid (average direction in 384d space).
219
+
220
+ Args:
221
+ vecs: (M, 384) matrix of member embeddings for one cluster.
222
+
223
+ Returns:
224
+ 1d np.ndarray shape (384,), L2-normalized.
225
+ """
226
+ return normalize(vecs.mean(axis=0, keepdims=True), norm="l2")[0]
227
+
228
+
229
+ def _top_n_centroids(matrix: np.ndarray, labels: np.ndarray, n: int) -> list[dict]:
230
+ """Extract N largest clusters by size and compute their centroids.
231
+
232
+ Excludes orphans (label=-1) from the ranking.
233
+
234
+ Args:
235
+ matrix: (N, 384) full embedding matrix.
236
+ labels: (N,) integer cluster labels (-1 = orphan).
237
+ n: How many top clusters to return.
238
+
239
+ Returns:
240
+ List of N dicts with: label, size, indices, centroid.
241
+ """
242
+ valid_mask = labels >= 0
243
+ valid_labels = labels[valid_mask]
244
+ unique, counts = np.unique(valid_labels, return_counts=True)
245
+ order = np.argsort(counts)[::-1][:n]
246
+ top_labels = unique[order]
247
+
248
+ def _build(lbl: int) -> dict:
249
+ """Build summary dict for one cluster."""
250
+ idx = np.where(labels == lbl)[0].tolist()
251
+ return {
252
+ "label": int(lbl),
253
+ "size": len(idx),
254
+ "indices": idx,
255
+ "centroid": _centroid(matrix[idx]),
256
+ }
257
+
258
+ return list(map(_build, top_labels))
259
+
260
+
261
+ def _mistral_chain(template_str: str):
262
+ """Create PromptTemplate β†’ ChatMistralAI β†’ JsonOutputParser chain.
263
+
264
+ Args:
265
+ template_str: Prompt template with {variable} placeholders.
266
+
267
+ Returns:
268
+ LangChain Runnable chain that accepts dict and returns parsed JSON.
269
+ """
270
+ llm = ChatMistralAI(model="mistral-large-latest", temperature=0)
271
+ prompt = PromptTemplate.from_template(template_str)
272
+ return prompt | llm | JsonOutputParser()
273
+
274
+
275
+ def _dark_layout(title: str) -> dict:
276
+ """Return Plotly layout dict with dark theme styling.
277
+
278
+ Args:
279
+ title: Chart title string.
280
+
281
+ Returns:
282
+ Dict for fig.update_layout(**_dark_layout("...")).
283
+ """
284
+ return dict(
285
+ title=title, paper_bgcolor="#0F172A", plot_bgcolor="#0F172A",
286
+ font=dict(color="#CBD5E1", family="Sora,sans-serif"),
287
+ margin=dict(t=50, b=40, l=40, r=20),
288
+ )
289
+
290
+
291
+ @tool
292
+ def load_scopus_csv(csv_path: str, run_mode: str = "abstract") -> str:
293
+ """Load a Scopus CSV, count papers/sentences, apply boilerplate filter.
294
+
295
+ Phase 1 β€” Familiarisation. DETERMINISTIC.
296
+
297
+ Steps:
298
+ 1. Read CSV, drop rows where target column is null
299
+ 2. Apply 9-pattern boilerplate regex to clean each text
300
+ 3. Count sentences per paper
301
+ 4. Save cleaned DataFrame as .parquet
302
+
303
+ Args:
304
+ csv_path: Path to raw Scopus CSV.
305
+ run_mode: 'abstract' or 'title'.
306
+
307
+ Returns:
308
+ JSON: total_papers, total_sentences, columns_used,
309
+ boilerplate_removed, cleaned_parquet, run_mode.
310
+ """
311
+ cols = RUN_CONFIGS[run_mode]
312
+ target = cols[0]
313
+
314
+ df = pd.read_csv(csv_path).dropna(subset=[target]).reset_index(drop=True)
315
+ raw_texts = df[target].tolist()
316
+ cleaned_texts = list(map(_clean_text, raw_texts))
317
+
318
+ boilerplate_removed = sum(map(
319
+ lambda pair: int(pair[0] != pair[1]),
320
+ zip(raw_texts, cleaned_texts),
321
+ ))
322
+
323
+ df[f"{target}_clean"] = cleaned_texts
324
+ df["sentence_count"] = list(map(_sentence_count, cleaned_texts))
325
+
326
+ out_path = Path(csv_path).with_suffix(".clean.parquet")
327
+ df.to_parquet(out_path, index=False)
328
+
329
+ return json.dumps({
330
+ "total_papers": len(df),
331
+ "total_sentences": int(df["sentence_count"].sum()),
332
+ "columns_used": cols,
333
+ "boilerplate_removed": boilerplate_removed,
334
+ "cleaned_parquet": str(out_path),
335
+ "run_mode": run_mode,
336
+ }, indent=2)
337
+
338
+
339
+ @tool
340
+ def run_bertopic_discovery(parquet_path: str, run_mode: str = "abstract") -> str:
341
+ """Embed texts, cluster them, report orphans, generate charts.
342
+
343
+ Phase 2 β€” Initial Codes. DETERMINISTIC.
344
+
345
+ Steps:
346
+ 1. Load cleaned parquet, drop Author Keywords columns (RULE 8)
347
+ 2. Embed all texts β†’ N x 384 matrix of unit vectors
348
+ 3. Save embedding matrix as .emb.npy
349
+ 4. Cluster in 384d space (NO UMAP), min 3 members per cluster
350
+ 5. Sentences in clusters < 3 members become orphans (label=-1)
351
+ 6. Extract top-5 clusters, compute centroids
352
+ 7. Save summaries.json with clusters + orphan list
353
+ 8. Generate 4 Plotly HTML charts
354
+
355
+ Args:
356
+ parquet_path: Path to .clean.parquet from load_scopus_csv.
357
+ run_mode: 'abstract' or 'title'.
358
+
359
+ Returns:
360
+ JSON: total_clusters, orphan_count, summaries_json, embeddings_npy,
361
+ charts dict.
362
+ """
363
+ cols = RUN_CONFIGS[run_mode]
364
+ target = f"{cols[0]}_clean"
365
+
366
+ df = pd.read_parquet(parquet_path).drop(
367
+ columns=[c for c in pd.read_parquet(parquet_path).columns
368
+ if re.search(r"keyword|author", c, re.I)],
369
+ errors="ignore",
370
+ )
371
+
372
+ texts = df[target].tolist()
373
+ embeddings = _embed(texts)
374
+ base = Path(parquet_path).parent
375
+
376
+ np.save(str(base / Path(parquet_path).stem) + ".emb.npy", embeddings)
377
+
378
+ labels = _cosine_cluster(embeddings, CLUSTER_THRESHOLD, MIN_CLUSTER_SIZE)
379
+ orphan_idx = np.where(labels == -1)[0].tolist()
380
+ orphan_count = len(orphan_idx)
381
+ valid_count = int((labels >= 0).sum())
382
+ n_clusters = int(np.unique(labels[labels >= 0]).shape[0])
383
+ top_centroids = _top_n_centroids(embeddings, labels, N_CENTROIDS)
384
+
385
+ def _topic_row(tc: dict) -> dict:
386
+ """Convert centroid dict into summary row for summaries.json."""
387
+ return {
388
+ "topic_id": tc["label"],
389
+ "size": tc["size"],
390
+ "representative": texts[tc["indices"][0]][:200],
391
+ "indices": tc["indices"],
392
+ }
393
+
394
+ summaries = list(map(_topic_row, top_centroids))
395
+
396
+ orphans = list(map(
397
+ lambda i: {"sentence_idx": int(i), "text": texts[i][:200]},
398
+ orphan_idx,
399
+ ))
400
+
401
+ output = {"clusters": summaries, "orphans": orphans}
402
+ (base / "summaries.json").write_text(json.dumps(output, indent=2))
403
+
404
+ unique, counts = np.unique(labels[labels >= 0], return_counts=True)
405
+ order = np.argsort(counts)[::-1][:20]
406
+ c1 = go.Figure(go.Bar(
407
+ x=list(map(str, unique[order])), y=counts[order].tolist(),
408
+ marker_color="#3B82F6", text=counts[order].tolist(), textposition="outside",
409
+ ))
410
+ c1.update_layout(**_dark_layout("Topic Size Distribution (Top 20)"),
411
+ xaxis=dict(showgrid=False),
412
+ yaxis=dict(showgrid=True, gridcolor="#1E293B"))
413
+ c1.write_html(str(base / "chart_topic_sizes.html"))
414
+
415
+ centroid_matrix = np.vstack([tc["centroid"] for tc in top_centroids])
416
+ sim_matrix = cosine_similarity(centroid_matrix)
417
+ clabels = list(map(lambda tc: f"T{tc['label']}", top_centroids))
418
+ c2 = go.Figure(go.Heatmap(z=sim_matrix, x=clabels, y=clabels, colorscale="Blues"))
419
+ c2.update_layout(**_dark_layout("Top-5 Centroid Cosine Similarity"))
420
+ c2.write_html(str(base / "chart_centroid_heatmap.html"))
421
+
422
+ sc = df.get("sentence_count", pd.Series([0] * len(df))).tolist()
423
+ c3 = go.Figure(go.Histogram(x=sc, nbinsx=40, marker_color="#22D3EE"))
424
+ c3.update_layout(**_dark_layout("Sentence Count Distribution"),
425
+ xaxis=dict(showgrid=False),
426
+ yaxis=dict(showgrid=True, gridcolor="#1E293B"))
427
+ c3.write_html(str(base / "chart_sentence_distribution.html"))
428
+
429
+ coords = PCA(n_components=2).fit_transform(centroid_matrix)
430
+ point_text = list(map(lambda tc: f"T{tc['label']}({tc['size']})", top_centroids))
431
+ c4 = go.Figure(go.Scatter(
432
+ x=coords[:, 0].tolist(), y=coords[:, 1].tolist(),
433
+ mode="markers+text", text=point_text, textposition="top center",
434
+ marker=dict(size=12, color="#F59E0B", line=dict(width=1, color="#0F172A")),
435
+ ))
436
+ c4.update_layout(**_dark_layout("Top-5 Centroids β€” PCA Projection"))
437
+ c4.write_html(str(base / "chart_centroid_pca.html"))
438
+
439
+ emb_path = str(base / Path(parquet_path).stem) + ".emb.npy"
440
+ return json.dumps({
441
+ "total_clusters": n_clusters,
442
+ "orphan_count": orphan_count,
443
+ "valid_sentences": valid_count,
444
+ "top_centroids": N_CENTROIDS,
445
+ "summaries_json": str(base / "summaries.json"),
446
+ "embeddings_npy": emb_path,
447
+ "needs_review": True,
448
+ "charts": {
449
+ "topic_sizes": str(base / "chart_topic_sizes.html"),
450
+ "centroid_heatmap": str(base / "chart_centroid_heatmap.html"),
451
+ "sentence_dist": str(base / "chart_sentence_distribution.html"),
452
+ "centroid_pca": str(base / "chart_centroid_pca.html"),
453
+ },
454
+ }, indent=2)
455
+
456
+
457
+ @tool
458
+ def label_topics_with_llm(summaries_json_path: str) -> str:
459
+ """Send top-100 topic summaries to Mistral for labelling.
460
+
461
+ Phase 2 β€” Labelling. LLM-DEPENDENT (grounded in real sentences).
462
+
463
+ Steps:
464
+ 1. Load summaries.json clusters (not orphans)
465
+ 2. Take top 100 by size
466
+ 3. Mistral reads representative sentences β†’ assigns labels
467
+ 4. Returns: topic_id, label, rationale, confidence per cluster
468
+ 5. Save as topic_labels.json
469
+
470
+ Args:
471
+ summaries_json_path: Path to summaries.json.
472
+
473
+ Returns:
474
+ JSON: labelled_topics count + output path. needs_review=True.
475
+ """
476
+ data = json.loads(Path(summaries_json_path).read_text())
477
+ summaries = data.get("clusters", data)[:TOP_TOPICS_LLM]
478
+
479
+ template = (
480
+ "You are a scientific topic labelling expert.\n\n"
481
+ "Below are {n} topic summaries from a BERTopic analysis of academic papers.\n"
482
+ "Each summary has: topic_id, size, representative text.\n\n"
483
+ "{summaries}\n\n"
484
+ "For EACH topic return a JSON array where every element has:\n"
485
+ " topic_id : integer (copy from input)\n"
486
+ " label : 2-5 word snake_case topic label\n"
487
+ " rationale : one sentence justification\n"
488
+ " confidence : float 0.0-1.0\n\n"
489
+ "Return ONLY the JSON array β€” no markdown, no preamble."
490
+ )
491
+
492
+ result = _mistral_chain(template).invoke({
493
+ "n": len(summaries),
494
+ "summaries": json.dumps(summaries, indent=2),
495
+ })
496
+ out_path = Path(summaries_json_path).parent / "topic_labels.json"
497
+ out_path.write_text(json.dumps(result, indent=2))
498
+
499
+ return json.dumps({
500
+ "labelled_topics": len(result),
501
+ "output": str(out_path),
502
+ "needs_review": True,
503
+ }, indent=2)
504
+
505
+
506
+ @tool
507
+ def reassign_sentences(
508
+ summaries_json_path: str,
509
+ embeddings_npy_path: str,
510
+ move_instructions: str,
511
+ ) -> str:
512
+ """Move orphan or misplaced sentences between clusters.
513
+
514
+ Phase 2 β€” Orphan handling. DETERMINISTIC.
515
+
516
+ The reviewer specifies moves as JSON:
517
+ [{"sentence_idx": 42, "to_cluster": 3},
518
+ {"sentence_idx": 99, "to_cluster": "new"}]
519
+
520
+ For "new" targets, a fresh cluster ID is assigned.
521
+ After all moves, centroids are recomputed for affected clusters.
522
+
523
+ Steps:
524
+ 1. Load summaries.json and embeddings
525
+ 2. Parse move instructions
526
+ 3. Update cluster assignments
527
+ 4. Recompute centroids for affected clusters
528
+ 5. Save updated summaries.json
529
+
530
+ Args:
531
+ summaries_json_path: Path to summaries.json.
532
+ embeddings_npy_path: Path to .emb.npy.
533
+ move_instructions: JSON array of {sentence_idx, to_cluster} dicts.
534
+
535
+ Returns:
536
+ JSON: moves_applied count, orphans_remaining, updated summaries path.
537
+ """
538
+ data = json.loads(Path(summaries_json_path).read_text())
539
+ embeddings = np.load(embeddings_npy_path)
540
+ moves = json.loads(move_instructions)
541
+ clusters = data.get("clusters", [])
542
+ orphans = data.get("orphans", [])
543
+
544
+ all_indices = {}
545
+ list(map(
546
+ lambda c: all_indices.update({idx: c["topic_id"] for idx in c.get("indices", [])}),
547
+ clusters,
548
+ ))
549
+
550
+ max_id = max(map(lambda c: c.get("topic_id", 0), clusters), default=0)
551
+ new_id_counter = [max_id + 1]
552
+
553
+ def _apply_move(m: dict) -> dict:
554
+ """Apply one move instruction, return the resolved target cluster ID."""
555
+ s_idx = m["sentence_idx"]
556
+ target = m["to_cluster"]
557
+ resolved = (target == "new") and new_id_counter.__setitem__(0, new_id_counter[0] + 1) or target
558
+ final_id = new_id_counter[0] - 1 * (target == "new") + target * (target != "new")
559
+ all_indices[s_idx] = int(target) * (target != "new") + new_id_counter[0] * (target == "new")
560
+ return {"sentence_idx": s_idx, "assigned_to": all_indices[s_idx]}
561
+
562
+ applied = list(map(_apply_move, moves))
563
+
564
+ unique_clusters = set(all_indices.values())
565
+
566
+ def _rebuild_cluster(cid: int) -> dict:
567
+ """Rebuild a cluster dict from the updated index map."""
568
+ idx = [k for k, v in all_indices.items() if v == cid]
569
+ vecs = embeddings[idx or [0]]
570
+ return {
571
+ "topic_id": int(cid),
572
+ "size": len(idx),
573
+ "representative": "",
574
+ "indices": idx,
575
+ "centroid": _centroid(vecs).tolist(),
576
+ }
577
+
578
+ updated_clusters = list(map(_rebuild_cluster, sorted(unique_clusters)))
579
+ remaining_orphan_idx = [o["sentence_idx"] for o in orphans
580
+ if o["sentence_idx"] not in all_indices]
581
+
582
+ output = {
583
+ "clusters": updated_clusters,
584
+ "orphans": list(map(
585
+ lambda i: {"sentence_idx": i, "text": ""},
586
+ remaining_orphan_idx,
587
+ )),
588
+ }
589
+ Path(summaries_json_path).write_text(json.dumps(output, indent=2))
590
+
591
+ return json.dumps({
592
+ "moves_applied": len(applied),
593
+ "orphans_remaining": len(remaining_orphan_idx),
594
+ "summaries_json": summaries_json_path,
595
+ "needs_review": True,
596
+ }, indent=2)
597
+
598
+
599
+ @tool
600
+ def consolidate_into_themes(
601
+ labels_json_path: str,
602
+ embeddings_npy_path: str,
603
+ approved_topic_ids: str,
604
+ ) -> str:
605
+ """Merge approved topic groups into consolidated themes.
606
+
607
+ Phase 3 β€” Theme Search. DETERMINISTIC.
608
+
609
+ Steps:
610
+ 1. Load topic_labels.json and embedding matrix
611
+ 2. Parse approved groupings (JSON array of arrays)
612
+ 3. Pool all member embeddings per group
613
+ 4. Compute fresh L2-normalized centroid per merged group
614
+ 5. Build theme name from joined sub-labels
615
+ 6. Save themes.json
616
+
617
+ Args:
618
+ labels_json_path: Path to topic_labels.json.
619
+ embeddings_npy_path: Path to .emb.npy.
620
+ approved_topic_ids: JSON array of arrays, e.g. [[0,1,2],[3,4],[5]].
621
+
622
+ Returns:
623
+ JSON: themes_created count + themes_json path. needs_review=True.
624
+ """
625
+ labels_data = json.loads(Path(labels_json_path).read_text())
626
+ embeddings = np.load(embeddings_npy_path)
627
+ groups = json.loads(approved_topic_ids)
628
+ label_map = {item["topic_id"]: item for item in labels_data}
629
+
630
+ def _merge_group(group_ids: list[int]) -> dict:
631
+ """Merge topic IDs into one theme, recompute centroid."""
632
+ members = [m for m in map(label_map.get, group_ids) if m is not None]
633
+ all_idx = sum(map(lambda m: m.get("indices", []), members), [])
634
+ vecs = embeddings[all_idx or [0]]
635
+ centroid = _centroid(vecs)
636
+ sub_labels = list(map(lambda m: m.get("label", ""), members))
637
+ theme_name = "_".join(
638
+ dict.fromkeys(sum(map(lambda lbl: lbl.split("_"), sub_labels), []))
639
+ )[:60]
640
+ return {
641
+ "theme_id": group_ids[0],
642
+ "theme_label": theme_name,
643
+ "merged_ids": group_ids,
644
+ "total_papers": len(set(all_idx)),
645
+ "indices": all_idx,
646
+ "centroid": centroid.tolist(),
647
+ }
648
+
649
+ themes = list(map(_merge_group, groups))
650
+ out_path = Path(labels_json_path).parent / "themes.json"
651
+ out_path.write_text(json.dumps(themes, indent=2))
652
+
653
+ return json.dumps({
654
+ "themes_created": len(themes),
655
+ "themes_json": str(out_path),
656
+ "needs_review": True,
657
+ }, indent=2)
658
+
659
+
660
+ @tool
661
+ def compute_saturation(
662
+ themes_json_path: str,
663
+ embeddings_npy_path: str,
664
+ total_papers: int,
665
+ ) -> str:
666
+ """Compute saturation metrics per theme: coverage, coherence, balance.
667
+
668
+ Phase 4 β€” Saturation Review. DETERMINISTIC.
669
+
670
+ Every number in the output is computed by numpy β€” the LLM never
671
+ calculates these values. This eliminates hallucination risk for
672
+ percentages, scores, and ratios.
673
+
674
+ Metrics per theme:
675
+ coverage = papers_in_theme / total_papers (exact percentage)
676
+ coherence = mean pairwise cosine similarity of member embeddings
677
+ (1.0 = all identical, 0.0 = orthogonal)
678
+
679
+ Global metrics:
680
+ total_coverage = papers in at least one theme / total_papers
681
+ balance_ratio = largest_theme / smallest_theme
682
+ mean_coherence = average of per-theme coherence scores
683
+
684
+ Args:
685
+ themes_json_path: Path to themes.json.
686
+ embeddings_npy_path: Path to .emb.npy.
687
+ total_papers: Total papers in corpus (from Phase 1 stats).
688
+
689
+ Returns:
690
+ JSON: per-theme metrics + global metrics. needs_review=True.
691
+ """
692
+ themes = json.loads(Path(themes_json_path).read_text())
693
+ embeddings = np.load(embeddings_npy_path)
694
+
695
+ def _theme_metrics(t: dict) -> dict:
696
+ """Compute coverage and coherence for one theme."""
697
+ idx = t.get("indices", [])
698
+ size = len(idx)
699
+ vecs = embeddings[idx or [0]]
700
+ sim = cosine_similarity(vecs)
701
+ n = len(vecs)
702
+ coherence = float(
703
+ (sim.sum() - n) / max(n * (n - 1), 1)
704
+ )
705
+ return {
706
+ "theme_id": t.get("theme_id", 0),
707
+ "theme_label": t.get("theme_label", ""),
708
+ "papers": size,
709
+ "coverage_pct": round(size / max(total_papers, 1) * 100, 2),
710
+ "coherence": round(coherence, 4),
711
+ }
712
+
713
+ per_theme = list(map(_theme_metrics, themes))
714
+
715
+ all_paper_idx = set(sum(map(lambda t: t.get("indices", []), themes), []))
716
+ sizes = list(map(lambda m: m["papers"], per_theme))
717
+ coherences = list(map(lambda m: m["coherence"], per_theme))
718
+
719
+ global_metrics = {
720
+ "total_coverage_pct": round(len(all_paper_idx) / max(total_papers, 1) * 100, 2),
721
+ "balance_ratio": round(max(sizes, default=1) / max(min(sizes, default=1), 1), 2),
722
+ "mean_coherence": round(sum(coherences) / max(len(coherences), 1), 4),
723
+ "theme_count": len(themes),
724
+ }
725
+
726
+ out_path = Path(themes_json_path).parent / "saturation.json"
727
+ result = {"per_theme": per_theme, "global": global_metrics}
728
+ out_path.write_text(json.dumps(result, indent=2))
729
+
730
+ return json.dumps({
731
+ **global_metrics,
732
+ "per_theme": per_theme,
733
+ "saturation_json": str(out_path),
734
+ "needs_review": True,
735
+ }, indent=2)
736
+
737
+
738
+ @tool
739
+ def generate_theme_profiles(
740
+ themes_json_path: str,
741
+ embeddings_npy_path: str,
742
+ texts_parquet_path: str,
743
+ run_mode: str = "abstract",
744
+ ) -> str:
745
+ """Generate profile cards with top-5 nearest sentences per theme.
746
+
747
+ Phase 5 β€” Naming. DETERMINISTIC.
748
+
749
+ For each theme centroid, computes cosine similarity against ALL
750
+ embeddings and returns the 5 closest sentences. These are the
751
+ REAL sentences from the corpus β€” not generated, not recalled
752
+ from conversation history. The reviewer uses these to decide
753
+ on final theme names.
754
+
755
+ Steps:
756
+ 1. Load themes.json with centroids
757
+ 2. Load full embedding matrix
758
+ 3. Load original texts from parquet
759
+ 4. For each theme: cosine_similarity(centroid, all_embeddings)
760
+ 5. Take top 5 by similarity score
761
+ 6. Return exact sentence text + similarity score
762
+ 7. Save profiles.json
763
+
764
+ Args:
765
+ themes_json_path: Path to themes.json.
766
+ embeddings_npy_path: Path to .emb.npy.
767
+ texts_parquet_path: Path to .clean.parquet (for original text).
768
+ run_mode: 'abstract' or 'title'.
769
+
770
+ Returns:
771
+ JSON: profiles list with top-5 sentences per theme. needs_review=True.
772
+ """
773
+ themes = json.loads(Path(themes_json_path).read_text())
774
+ embeddings = np.load(embeddings_npy_path)
775
+ target = f"{RUN_CONFIGS[run_mode][0]}_clean"
776
+ texts = pd.read_parquet(texts_parquet_path)[target].tolist()
777
+
778
+ def _profile(t: dict) -> dict:
779
+ """Build a profile card for one theme: centroid β†’ top 5 nearest."""
780
+ centroid = np.array(t["centroid"]).reshape(1, -1)
781
+ sims = cosine_similarity(centroid, embeddings)[0]
782
+ top5_idx = np.argsort(sims)[::-1][:5].tolist()
783
+ top5 = list(map(
784
+ lambda i: {
785
+ "sentence_idx": i,
786
+ "text": texts[i][:300],
787
+ "similarity": round(float(sims[i]), 4),
788
+ },
789
+ top5_idx,
790
+ ))
791
+ return {
792
+ "theme_id": t.get("theme_id", 0),
793
+ "theme_label": t.get("theme_label", ""),
794
+ "total_papers": t.get("total_papers", 0),
795
+ "top_5_sentences": top5,
796
+ }
797
+
798
+ profiles = list(map(_profile, themes))
799
+ out_path = Path(themes_json_path).parent / "profiles.json"
800
+ out_path.write_text(json.dumps(profiles, indent=2))
801
+
802
+ return json.dumps({
803
+ "profiles_count": len(profiles),
804
+ "profiles_json": str(out_path),
805
+ "profiles": profiles,
806
+ "needs_review": True,
807
+ }, indent=2)
808
+
809
+
810
+ @tool
811
+ def compare_with_taxonomy(themes_json_path: str) -> str:
812
+ """Map each theme to PAJAIS 25 IS research categories via Mistral.
813
+
814
+ Phase 5.5 β€” PAJAIS Alignment. LLM-DEPENDENT (grounded in real labels).
815
+
816
+ Themes with alignment_score < 0.50 are flagged as potentially NOVEL.
817
+
818
+ Args:
819
+ themes_json_path: Path to themes.json.
820
+
821
+ Returns:
822
+ JSON: themes_aligned count + taxonomy_file path. needs_review=True.
823
+ """
824
+ themes = json.loads(Path(themes_json_path).read_text())
825
+
826
+ safe_themes = list(map(
827
+ lambda t: {k: v for k, v in t.items() if k not in ("centroid", "indices")},
828
+ themes,
829
+ ))
830
+
831
+ template = (
832
+ "You are an IS research taxonomy expert.\n\n"
833
+ "PAJAIS 25 Categories:\n{pajais}\n\n"
834
+ "Research themes:\n{themes}\n\n"
835
+ "For EACH theme return a JSON array where every element has:\n"
836
+ " theme_label : string\n"
837
+ " pajais_categories : list of 1-3 matching PAJAIS category names\n"
838
+ " alignment_score : float 0.0-1.0\n"
839
+ " notes : one sentence justification\n\n"
840
+ "Return ONLY the JSON array β€” no markdown, no preamble."
841
+ )
842
+
843
+ result = _mistral_chain(template).invoke({
844
+ "pajais": "\n".join(map(lambda c: f"- {c}", PAJAIS_25)),
845
+ "themes": json.dumps(safe_themes, indent=2),
846
+ })
847
+ out_path = Path(themes_json_path).parent / "taxonomy_alignment.json"
848
+ out_path.write_text(json.dumps(result, indent=2))
849
+
850
+ return json.dumps({
851
+ "themes_aligned": len(result),
852
+ "taxonomy_file": str(out_path),
853
+ "needs_review": True,
854
+ }, indent=2)
855
+
856
+
857
+ @tool
858
+ def generate_comparison_csv(
859
+ abstract_themes_path: str,
860
+ title_themes_path: str,
861
+ taxonomy_abstract_path: str,
862
+ taxonomy_title_path: str,
863
+ ) -> str:
864
+ """Build side-by-side abstract vs title comparison CSV.
865
+
866
+ Phase 6 β€” Report. DETERMINISTIC.
867
+
868
+ Joins on PAJAIS_Category. Delta_Score = Abstract - Title.
869
+
870
+ Args:
871
+ abstract_themes_path: themes.json β€” abstract run.
872
+ title_themes_path: themes.json β€” title run.
873
+ taxonomy_abstract_path: taxonomy_alignment.json β€” abstract run.
874
+ taxonomy_title_path: taxonomy_alignment.json β€” title run.
875
+
876
+ Returns:
877
+ JSON: comparison_csv path, total_rows, columns. needs_review=True.
878
+ """
879
+ def _explode_taxonomy(path: str) -> pd.DataFrame:
880
+ """Flatten taxonomy alignment into one row per PAJAIS category."""
881
+ data = json.loads(Path(path).read_text())
882
+ rows = sum(
883
+ list(map(
884
+ lambda item: list(map(
885
+ lambda cat: {
886
+ "pajais_category": cat,
887
+ "theme_label": item.get("theme_label", ""),
888
+ "alignment_score": item.get("alignment_score", 0.0),
889
+ },
890
+ item.get("pajais_categories", []),
891
+ )),
892
+ data,
893
+ )),
894
+ [],
895
+ )
896
+ return pd.DataFrame(rows)
897
+
898
+ df_abs = _explode_taxonomy(taxonomy_abstract_path)
899
+ df_title = _explode_taxonomy(taxonomy_title_path)
900
+
901
+ df_abs.columns = ["PAJAIS_Category", "Abstract_Theme", "Abstract_Score"]
902
+ df_title.columns = ["PAJAIS_Category", "Title_Theme", "Title_Score"]
903
+
904
+ merged = (
905
+ pd.merge(df_abs, df_title, on="PAJAIS_Category", how="outer")
906
+ .fillna({"Abstract_Score": 0.0, "Title_Score": 0.0,
907
+ "Abstract_Theme": "", "Title_Theme": ""})
908
+ .assign(Delta_Score=lambda d: (d["Abstract_Score"] - d["Title_Score"]).round(4))
909
+ .sort_values("PAJAIS_Category")
910
+ .reset_index(drop=True)
911
+ )
912
+
913
+ out_csv = Path(abstract_themes_path).parent / "abstract_vs_title_comparison.csv"
914
+ merged.to_csv(out_csv, index=False)
915
+
916
+ return json.dumps({
917
+ "comparison_csv": str(out_csv),
918
+ "total_rows": len(merged),
919
+ "columns": list(merged.columns),
920
+ "needs_review": True,
921
+ }, indent=2)
922
+
923
+
924
+ @tool
925
+ def export_narrative(
926
+ taxonomy_alignment_path: str,
927
+ comparison_csv_path: str,
928
+ run_mode: str = "abstract",
929
+ ) -> str:
930
+ """Generate 500-word Section 7: Discussion & Implications via Mistral.
931
+
932
+ Phase 6 β€” Report. LLM-DEPENDENT (grounded in taxonomy + comparison data).
933
+
934
+ Args:
935
+ taxonomy_alignment_path: Path to taxonomy_alignment.json.
936
+ comparison_csv_path: Path to comparison CSV.
937
+ run_mode: 'abstract' or 'title'.
938
+
939
+ Returns:
940
+ JSON: narrative_path, word_count, narrative text. needs_review=True.
941
+ """
942
+ alignment = json.loads(Path(taxonomy_alignment_path).read_text())
943
+
944
+ top_delta = (
945
+ pd.read_csv(comparison_csv_path)
946
+ .assign(_abs=lambda d: d["Delta_Score"].abs())
947
+ .sort_values("_abs", ascending=False)
948
+ .drop(columns=["_abs"])
949
+ .head(5)
950
+ )
951
+
952
+ template = (
953
+ "You are a senior IS researcher writing a systematic literature review.\n\n"
954
+ "Write Section 7: Discussion & Implications in exactly {word_count} words.\n\n"
955
+ "Run mode: {run_mode}\n\n"
956
+ "Taxonomy alignment (top 10):\n{alignment}\n\n"
957
+ "Top 5 divergent PAJAIS categories (abstract vs title):\n{divergence}\n\n"
958
+ "Requirements:\n"
959
+ "1. Discuss dominant themes and PAJAIS alignment.\n"
960
+ "2. Interpret divergence between abstract- and title-based models.\n"
961
+ "3. Highlight implications for IS research practice and future agenda.\n"
962
+ "4. Use formal academic register β€” no bullet points.\n"
963
+ "5. Return a JSON object with a single key 'narrative' containing the prose.\n\n"
964
+ "Return ONLY valid JSON."
965
+ )
966
+
967
+ result = _mistral_chain(template).invoke({
968
+ "word_count": NARRATIVE_WORDS,
969
+ "run_mode": run_mode,
970
+ "alignment": json.dumps(alignment[:10], indent=2),
971
+ "divergence": top_delta.to_json(orient="records", indent=2),
972
+ })
973
+ narrative_text = result.get("narrative", str(result))
974
+ out_path = Path(taxonomy_alignment_path).parent / "narrative.md"
975
+ out_path.write_text(
976
+ f"## Section 7: Discussion & Implications\n\n{narrative_text}\n",
977
+ encoding="utf-8",
978
+ )
979
+
980
+ return json.dumps({
981
+ "narrative_path": str(out_path),
982
+ "word_count": len(narrative_text.split()),
983
+ "narrative": narrative_text,
984
+ "needs_review": True,
985
+ }, indent=2)
986
+
987
+
988
+ ALL_TOOLS = [
989
+ load_scopus_csv,
990
+ run_bertopic_discovery,
991
+ label_topics_with_llm,
992
+ reassign_sentences,
993
+ consolidate_into_themes,
994
+ compute_saturation,
995
+ generate_theme_profiles,
996
+ compare_with_taxonomy,
997
+ generate_comparison_csv,
998
+ export_narrative,
999
+ ]