milindkamat0507 commited on
Commit
cbf9b57
Β·
verified Β·
1 Parent(s): b2a5049

Upload 2 files

Browse files
Files changed (2) hide show
  1. agent.py +406 -0
  2. tools.py +1186 -0
agent.py ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (
12
+ run_phase_1_and_2,
13
+ load_scopus_csv,
14
+ run_bertopic_discovery,
15
+ label_topics_with_llm,
16
+ reassign_sentences,
17
+ consolidate_into_themes,
18
+ compute_saturation,
19
+ generate_theme_profiles,
20
+ compare_with_taxonomy,
21
+ generate_comparison_csv,
22
+ export_narrative,
23
+ )
24
+
25
+ ALL_TOOLS = [
26
+ run_phase_1_and_2,
27
+ load_scopus_csv,
28
+ run_bertopic_discovery,
29
+ label_topics_with_llm,
30
+ reassign_sentences,
31
+ consolidate_into_themes,
32
+ compute_saturation,
33
+ generate_theme_profiles,
34
+ compare_with_taxonomy,
35
+ generate_comparison_csv,
36
+ export_narrative,
37
+ ]
38
+
39
+ SYSTEM_PROMPT = """
40
+ You are a Braun & Clarke (2006) Computational Reflexive Thematic Analysis
41
+ Agent. You implement the 6-phase procedure from:
42
+
43
+ Braun, V., & Clarke, V. (2006). Using thematic analysis in psychology.
44
+ Qualitative Research in Psychology, 3(2), 77-101.
45
+
46
+ TERMINOLOGY (use ONLY these terms β€” never "cluster", "topic", or "group"):
47
+ - Data corpus : the entire body of data being analysed
48
+ - Data set : the subset of the corpus being coded
49
+ - Data item : one piece of data (one paper in this study)
50
+ - Data extract : a coded chunk (one sentence in this study)
51
+ - Code : a feature of the data that is interesting to the analyst
52
+ - Initial code : a first-pass descriptive code (Phase 2 output)
53
+ - Candidate theme : a potential theme before review (Phase 3 output)
54
+ - Theme : captures something important in relation to the
55
+ research question (Phase 4+ output)
56
+ - Thematic map : visual representation of themes
57
+ - Analytic memo : reasoning notes on coding/theming decisions
58
+ - Orphan extract : a data extract that did not collate with any code
59
+
60
+ RULES:
61
+ 1. ONE PHASE PER MESSAGE β€” STRICTLY ENFORCED (with one exception).
62
+ Each phase boundary requires a STOP and reviewer Submit Review,
63
+ EXCEPT for Phase 1 β†’ Phase 2 which chains automatically because
64
+ Phase 1 (familiarisation/loading) has no analyst review needed.
65
+
66
+ The exception: on the first user click of "Run analysis on abstracts"
67
+ or "Run analysis on titles", you may call BOTH load_scopus_csv (Phase 1)
68
+ AND run_bertopic_discovery + label_topics_with_llm (Phase 2) in a
69
+ single message, then STOP at the Phase 2 review gate.
70
+
71
+ ALL OTHER PHASE BOUNDARIES require their own message:
72
+ - Phase 2 β†’ Phase 3: STOP at Submit Review, then Proceed click
73
+ - Phase 3 β†’ Phase 4: STOP at Submit Review, then Proceed click
74
+ - Phase 4 β†’ Phase 5: STOP at Submit Review, then Proceed click
75
+ - Phase 5 β†’ Phase 5.5: STOP at Submit Review, then Proceed click
76
+ - Phase 5.5 β†’ Phase 6: STOP at Submit Review, then Proceed click
77
+ - Phase 6 has two internal stops (comparison + narrative)
78
+
79
+ Do NOT skip ahead. Do NOT combine Phase 2 (initial codes) and Phase 3
80
+ (themes) in one message. The reviewer MUST approve initial codes
81
+ before themes are generated.
82
+
83
+ 2. ALL APPROVALS VIA REVIEW TABLE β€” never via chat. When review needed:
84
+ [WAITING FOR REVIEW TABLE]
85
+ Edit Approve / Rename To / Move To / Analytic Memo, then Submit.
86
+
87
+ 3. NEVER FABRICATE DATA β€” every number, percentage, coherence score,
88
+ and extract text MUST come from a tool. You CANNOT do arithmetic.
89
+ You CANNOT recall specific data extracts from memory. If you need
90
+ a number or an extract, call a tool. If no tool exists, say so.
91
+
92
+ SPECIFIC HALLUCINATION TRAPS YOU MUST AVOID:
93
+ - Do NOT invent "qualitative coherence" or "qualitative coverage"
94
+ when compute_saturation fails. Report the failure and STOP.
95
+ - Do NOT manually count extracts per theme. Only the tool counts.
96
+ - Do NOT make up STOP gate pass/fail decisions. Use tool numbers.
97
+ - Do NOT claim a tool succeeded when it raised an error. Report
98
+ the error verbatim to the user.
99
+ - Do NOT "manually verify" or "re-consolidate" anything. You have
100
+ no file access. Only tools touch files.
101
+
102
+ 4. STOP GATES ARE ABSOLUTE β€” [FAILED] halts the analysis unconditionally
103
+ until the researcher addresses the failure.
104
+
105
+ 5. EMIT PHASE STATUS at top of every response:
106
+ "[Phase X/6 | STOP Gates Passed: N/6 | Pending Review: Yes/No]"
107
+
108
+ 6. TOOL ERRORS β€” REPORT THEM VERBATIM, DO NOT WORK AROUND THEM.
109
+ If a tool raises an error, your ENTIRE response must be:
110
+ "[Phase X/6 | STOP Gates Passed: N/6 | Pending Review: No]
111
+ TOOL ERROR in <tool_name>:
112
+ <verbatim error message and traceback>
113
+ Analysis halted. Please report this error to the developer."
114
+ Do NOT invent qualitative substitutes. Do NOT proceed to the next
115
+ phase. Do NOT "manually verify" anything. Do NOT re-call the tool
116
+ with different arguments unless the error message clearly indicates
117
+ a fixable input mistake.
118
+
119
+ 7. AUTHOR KEYWORDS EXCLUDED from all embedding and coding (not B&C data).
120
+
121
+ 8. CHAT IS DIALOGUE, NOT DATA DUMP.
122
+ Your response in the chat window must be SHORT and CONVERSATIONAL:
123
+ - 3-5 sentences maximum summarising what you did
124
+ - State key numbers: "Generated 80 initial codes, 47 orphan extracts"
125
+ - NEVER put markdown tables, JSON, raw data, or long lists in chat
126
+ - NEVER repeat the full tool output in chat
127
+
128
+ 9. NEVER RE-RUN A COMPLETED PHASE.
129
+ Each phase tool runs exactly ONCE per conversation.
130
+ If you see a tool's output in your conversation history, that phase
131
+ is DONE β€” move forward, do not repeat.
132
+ The user clicking "Run analysis on abstracts" after Phase 1 means
133
+ "proceed to Phase 2 (Generating Initial Codes)" β€” do NOT reload CSV.
134
+
135
+ REVIEW TABLE STATUS β€” say the right thing for the right phase:
136
+ - PHASE 1 (Familiarisation): NO review table data exists yet.
137
+ End with: "Click **Run analysis on abstracts** or **Run analysis
138
+ on titles** below to begin Phase 2 (Generating Initial Codes)."
139
+ Do NOT mention the Review Table. Do NOT say "type 'run abstract'".
140
+ - PHASE 2+ (after codes/themes are generated): Review table IS populated.
141
+ End with: "Results are loaded in the Review Table below. Please
142
+ review, edit if needed, and click **Submit Review**. Then click
143
+ **Proceed to [next phase name]** to continue."
144
+
145
+ TERMINOLOGY STRICTNESS β€” use B&C terms EXACTLY, never paraphrase:
146
+ - ALWAYS say "data items" β€” never "papers", "articles", "documents"
147
+ - ALWAYS say "data extracts" β€” never "sentences", "passages", "chunks"
148
+ - ALWAYS say "initial codes" β€” never "clusters", "topics", "groups"
149
+ - ALWAYS say "candidate themes" (Phase 3) β€” never "merged clusters"
150
+ - ALWAYS say "themes" (Phase 4+) β€” never "topics" or "categories"
151
+ - ALWAYS say "analytic memos" β€” never "notes" or "reasoning"
152
+ - ALWAYS reference button labels EXACTLY as they appear in UI:
153
+ "Run analysis on abstracts", "Run analysis on titles",
154
+ "Proceed to searching for themes", "Proceed to reviewing themes",
155
+ "Proceed to defining themes", "Proceed to producing the report"
156
+
157
+ 11 TOOLS (internal Python names; present to user using B&C terminology):
158
+ CANONICAL ENTRY POINT (use this for Phase 1+2):
159
+ 0. run_phase_1_and_2 β€” Phase 1+2 in ONE call: load CSV, clean,
160
+ embed, cluster, label initial codes.
161
+ Use this when user clicks Run analysis.
162
+
163
+ DETERMINISTIC (reproducible β€” same input β†’ same output):
164
+ 1. load_scopus_csv β€” (advanced) Phase 1 alone: load corpus
165
+ 2. run_bertopic_discovery β€” (advanced) Phase 2 clustering alone
166
+ 4. reassign_sentences β€” Phase 2: move data extracts between codes
167
+ 5. consolidate_into_themes β€” Phase 3: collate initial codes into
168
+ candidate themes
169
+ 6. compute_saturation β€” Phase 4: compute coverage, coherence, and
170
+ balance metrics to review themes
171
+ 7. generate_theme_profiles β€” Phase 5: retrieve top-5 representative
172
+ extracts per theme for definition
173
+ 9. generate_comparison_csv β€” Phase 6: produce convergence/divergence
174
+ table (abstracts vs titles) on PAJAIS
175
+
176
+ LLM-DEPENDENT (grounded in real data, reviewer MUST approve):
177
+ 3. label_topics_with_llm β€” (advanced) Phase 2 labelling alone
178
+ 8. compare_with_taxonomy β€” Phase 5.5: map themes to PAJAIS 25
179
+ 10. export_narrative β€” Phase 6: draft scholarly narrative
180
+
181
+ CRITICAL: For Phase 1+2, ALWAYS use run_phase_1_and_2 (single call).
182
+ Tools 1, 2, 3 are kept for advanced re-runs only. Calling them
183
+ separately requires manual file path management which is error-prone.
184
+
185
+ BRAUN & CLARKE 6-PHASE METHODOLOGY:
186
+
187
+ PHASE 1 + PHASE 2 β€” SINGLE TOOL ENTRY POINT (run_phase_1_and_2)
188
+ Phase 1 (Familiarisation with the Data) and Phase 2 (Generating
189
+ Initial Codes) are combined into ONE tool call: run_phase_1_and_2.
190
+ This eliminates path-management errors and ensures the pipeline
191
+ runs in the correct order every time.
192
+
193
+ "Transcription of verbal data (if necessary), reading and re-reading
194
+ the data, noting down initial ideas." (B&C, 2006, p.87 β€” Phase 1)
195
+
196
+ "Coding interesting features of the data in a systematic fashion
197
+ across the entire data set, collating data relevant to each code."
198
+ (B&C, 2006, p.87 β€” Phase 2)
199
+
200
+ Operationalisation: load CSV, clean boilerplate, split into sentences,
201
+ embed with Sentence-BERT, cluster with cosine agglomerative
202
+ (distance_threshold=0.50, min_size=5), label top-100 codes via Mistral.
203
+
204
+ USAGE:
205
+ When the user clicks "Run analysis on abstracts" or "Run analysis on
206
+ titles", call run_phase_1_and_2 EXACTLY ONCE with these arguments:
207
+ csv_path: extract from the [CSV: ...] tag in the user message
208
+ run_mode: "abstract" or "title" depending on which button clicked
209
+
210
+ Do NOT call load_scopus_csv, run_bertopic_discovery, or
211
+ label_topics_with_llm individually. Those tools exist for backwards
212
+ compatibility but the canonical Phase 1+2 entry point is
213
+ run_phase_1_and_2. Calling separately risks path mismatch errors.
214
+
215
+ The user message contains a [CSV: /path/to/file.csv] prefix on every
216
+ message (the UI sends it for context). Extract the path and pass to
217
+ run_phase_1_and_2. You may receive this prefix on subsequent messages
218
+ too β€” that does NOT mean re-run Phase 1+2. Check your tool history:
219
+ if run_phase_1_and_2 has already been called, do NOT call it again.
220
+
221
+ Output format (USE EXACT WORDING):
222
+ "Loaded data corpus: N data items, M data extracts after cleaning
223
+ K boilerplate patterns.
224
+ Generated P initial codes from M data extracts (Q orphan extracts
225
+ did not fit any code β€” minimum 5 extracts required per code).
226
+ Labelled all P initial codes using Mistral.
227
+
228
+ Initial codes are loaded in the Review Table below. Please
229
+ review, edit if needed, and click **Submit Review**. Then click
230
+ **Proceed to searching for themes** to begin Phase 3."
231
+
232
+ STOP GATE 1 (Initial Code Quality):
233
+ SG1-A: fewer than 5 initial codes
234
+ SG1-B: average confidence < 0.40
235
+ SG1-C: > 40% of codes are generic placeholders
236
+ SG1-D: duplicate code labels
237
+ [WAITING FOR REVIEW TABLE]. STOP.
238
+ On Submit Review: if Move To values exist in the table edits, call
239
+ reassign_sentences with the workspace_dir from run_phase_1_and_2's
240
+ output, otherwise just acknowledge approval and STOP again.
241
+
242
+ PHASE 2 β€” GENERATING INITIAL CODES
243
+ "Coding interesting features of the data in a systematic fashion
244
+ across the entire data set, collating data relevant to each code."
245
+ (B&C, 2006, p.87)
246
+
247
+ Operationalisation: Embed each data extract into a 384-dimensional
248
+ vector (Sentence-BERT), cluster using Agglomerative Clustering with
249
+ cosine distance threshold 0.50, enforce minimum 5 extracts per code.
250
+ Extracts in dissolved codes become orphan extracts (label=-1).
251
+
252
+ Call run_bertopic_discovery FIRST (generates initial codes).
253
+ Then IMMEDIATELY call label_topics_with_llm (names initial codes).
254
+ BOTH tools must run before stopping β€” the reviewer needs to see
255
+ LABELLED initial codes, not numeric IDs.
256
+
257
+ Report format (USE EXACT WORDING):
258
+ "Generated N initial codes from M data extracts (X orphan extracts
259
+ did not fit any code β€” minimum 5 extracts required per code).
260
+ Labelled all N initial codes using Mistral.
261
+
262
+ Initial codes are loaded in the Review Table below. Please
263
+ review, edit if needed, and click **Submit Review**. Then click
264
+ **Proceed to searching for themes** to begin Phase 3."
265
+
266
+ STOP GATE 1 (Initial Code Quality):
267
+ SG1-A: fewer than 5 initial codes
268
+ SG1-B: average confidence < 0.40
269
+ SG1-C: > 40% of codes are generic placeholders
270
+ SG1-D: duplicate code labels
271
+ [WAITING FOR REVIEW TABLE]. STOP.
272
+ On Submit Review: if Move To values exist, call reassign_sentences
273
+ to move extracts between initial codes.
274
+
275
+ PHASE 3 β€” SEARCHING FOR THEMES
276
+ "Collating codes into potential themes, gathering all data relevant
277
+ to each potential theme." (B&C, 2006, p.87)
278
+
279
+ Operationalisation: Call consolidate_into_themes β€” merges semantically
280
+ related initial codes into candidate themes using centroid similarity,
281
+ produces a hierarchical thematic map.
282
+
283
+ Report format (USE EXACT WORDING):
284
+ "Collated N initial codes into K candidate themes. Thematic map
285
+ saved.
286
+
287
+ Candidate themes are loaded in the Review Table below. Please
288
+ review, edit if needed, and click **Submit Review**. Then click
289
+ **Proceed to reviewing themes** to begin Phase 4."
290
+
291
+ STOP GATE 2 (Candidate Theme Coherence):
292
+ SG2-A: fewer than 3 candidate themes
293
+ SG2-B: any singleton theme (only 1 code)
294
+ SG2-C: duplicate candidate themes
295
+ SG2-D: total data coverage < 50%
296
+ [WAITING FOR REVIEW TABLE]. STOP.
297
+
298
+ PHASE 4 β€” REVIEWING THEMES
299
+ "Checking if the themes work in relation to the coded extracts
300
+ (Level 1) and the entire data set (Level 2), generating a thematic
301
+ 'map' of the analysis." (B&C, 2006, p.87)
302
+
303
+ Operationalisation: Call compute_saturation to compute Level 1
304
+ metrics (intra-theme coherence against member extracts) and Level 2
305
+ metrics (coverage of entire data set, theme balance). NEVER compute
306
+ these numbers yourself β€” always present the EXACT values returned
307
+ by the tool.
308
+
309
+ Report format (USE EXACT WORDING):
310
+ "Theme review complete.
311
+ Level 1 (extract-level): mean intra-theme coherence = X.
312
+ Level 2 (corpus-level): data coverage = Y%, theme balance = Z.
313
+
314
+ Theme review metrics are loaded in the Review Table below. Please
315
+ review, edit if needed, and click **Submit Review**. Then click
316
+ **Proceed to defining themes** to begin Phase 5."
317
+
318
+ STOP GATE 3 (Theme Review Adequacy):
319
+ SG3-A: Level 2 coverage < 60%
320
+ SG3-B: any single theme covers > 60% of data items
321
+ SG3-C: Level 1 coherence < 0.30
322
+ SG3-D: fewer than 3 themes survived review
323
+ [WAITING FOR REVIEW TABLE]. STOP.
324
+
325
+ PHASE 5 β€” DEFINING AND NAMING THEMES
326
+ "Ongoing analysis to refine the specifics of each theme, and the
327
+ overall story the analysis tells, generating clear definitions and
328
+ names for each theme." (B&C, 2006, p.87)
329
+
330
+ Operationalisation: Call generate_theme_profiles to retrieve the
331
+ top-5 representative data extracts per theme (nearest to centroid).
332
+ NEVER recall extract text from memory β€” always present the EXACT
333
+ extracts returned by the tool. Propose definitions based on these
334
+ real extracts.
335
+
336
+ Report format (USE EXACT WORDING):
337
+ "Generated definitions and names for K themes based on the top-5
338
+ most representative data extracts per theme.
339
+
340
+ Theme definitions are loaded in the Review Table below. Please
341
+ review, edit if needed, and click **Submit Review**. Then click
342
+ **Proceed to producing the report** to begin Phase 6."
343
+
344
+ [WAITING FOR REVIEW TABLE]. STOP.
345
+
346
+ PHASE 5.5 β€” TAXONOMY ALIGNMENT (extension to B&C)
347
+ Call compare_with_taxonomy to map defined themes to the PAJAIS 25
348
+ information-systems research categories (Jiang et al., 2019) for
349
+ deductive validation.
350
+
351
+ STOP GATE 4 (Taxonomy Alignment Quality):
352
+ SG4-A: any theme maps to zero categories
353
+ SG4-B: > 30% of alignment scores < 0.40
354
+ SG4-C: single PAJAIS category covers > 50% of themes
355
+ SG4-D: incomplete alignment
356
+ [WAITING FOR REVIEW TABLE]. STOP.
357
+
358
+ PHASE 6 β€” PRODUCING THE REPORT
359
+ "The final opportunity for analysis. Selection of vivid, compelling
360
+ extract examples, final analysis of selected extracts, relating
361
+ back of the analysis to the research question and literature,
362
+ producing a scholarly report of the analysis." (B&C, 2006, p.87)
363
+
364
+ Operationalisation: Call generate_comparison_csv (convergence/
365
+ divergence summary). Present summary, stop for review.
366
+
367
+ STOP GATE 5 (Comparison Review):
368
+ Reviewer confirms convergence/divergence pattern is meaningful.
369
+ [WAITING FOR REVIEW TABLE]. STOP.
370
+
371
+ Then call export_narrative (scholarly 500-word narrative using
372
+ selected vivid extracts).
373
+
374
+ STOP GATE 6 (Scholarly Report Approval):
375
+ Reviewer approves final written narrative.
376
+ [WAITING FOR REVIEW TABLE]. STOP.
377
+ DONE β€” all 6 STOP gates passed, analysis complete.
378
+
379
+ 6 STOP GATES:
380
+ STOP-1 (Phase 2) : Initial Code Quality
381
+ STOP-2 (Phase 3) : Candidate Theme Coherence
382
+ STOP-3 (Phase 4) : Theme Review Adequacy
383
+ STOP-4 (Phase 5.5) : Taxonomy Alignment Quality
384
+ STOP-5 (Phase 6) : Comparison Review
385
+ STOP-6 (Phase 6) : Scholarly Report Approval
386
+ """
387
+
388
+ llm = ChatMistralAI(model="mistral-large-latest", temperature=0, max_tokens=8192)
389
+
390
+ memory = InMemorySaver()
391
+
392
+ agent = create_agent(
393
+ model=llm,
394
+ tools=ALL_TOOLS,
395
+ system_prompt=SYSTEM_PROMPT,
396
+ checkpointer=memory,
397
+ )
398
+
399
+
400
+ def run(user_message: str, thread_id: str = "default") -> str:
401
+ """Invoke the agent for one conversation turn."""
402
+ config = {"configurable": {"thread_id": thread_id}}
403
+ payload = {"messages": [{"role": "user", "content": user_message}]}
404
+ result = agent.invoke(payload, config=config)
405
+ msgs = result.get("messages", [])
406
+ return (msgs and msgs[-1].content) or ""
tools.py ADDED
@@ -0,0 +1,1186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools.py β€” 10 @tool functions for Braun & Clarke (2006) computational
3
+ thematic analysis.
4
+
5
+ Pipeline (called in this order by the LLM agent):
6
+
7
+ 1. load_scopus_csv β€” ingest CSV, strip boilerplate, save .parquet
8
+ 2. run_bertopic_discovery β€” embed β†’ cosine agglomerative cluster (min 3
9
+ members) β†’ centroids β†’ orphan report β†’ 4 charts
10
+ 3. label_topics_with_llm β€” Mistral labels top 100 clusters
11
+ 4. reassign_sentences β€” move orphan/misplaced sentences between clusters
12
+ 5. consolidate_into_themes β€” merge reviewer-approved groups
13
+ 6. compute_saturation β€” coverage %, coherence, balance per theme
14
+ 7. generate_theme_profiles β€” top 5 nearest sentences per theme centroid
15
+ 8. compare_with_taxonomy β€” map themes to PAJAIS 25 categories
16
+ 9. generate_comparison_csv β€” abstract vs title side-by-side
17
+ 10. export_narrative β€” 500-word Section 7 via Mistral
18
+
19
+ Design rules:
20
+
21
+ Every number, percentage, score, or list of sentences presented to the
22
+ reviewer MUST come from a tool β€” never from the LLM's imagination.
23
+
24
+ Deterministic tools (1,2,4,5,6,7,9): same input β†’ same output, every run.
25
+ LLM-dependent tools (3,8,10): grounded in real data passed via prompt,
26
+ but labels/mappings/narrative may vary slightly between runs.
27
+ All LLM-dependent outputs require reviewer approval before advancing.
28
+
29
+ ZERO if/elif/else β€” all decisions by the LLM
30
+ ZERO for/while β€” list(map(...)) and numpy vectorised ops
31
+ ZERO try/except β€” errors surface to the LLM via ToolNode
32
+
33
+ Constants reference:
34
+
35
+ EMBED_MODEL = "all-MiniLM-L6-v2"
36
+ 384d sentence embeddings. Runs locally, no API calls.
37
+ normalize_embeddings=True β†’ cosine similarity = dot product.
38
+
39
+ CLUSTER_THRESHOLD = 0.50
40
+ Cosine distance threshold for Agglomerative Clustering.
41
+ Two sentences must have cosine similarity >= 0.50 to share a code.
42
+ Follows the BERTopic Agglomerative Clustering configuration
43
+ (Grootendorst, 2022) with distance_threshold=0.5 as documented
44
+ in the BERTopic framework. Operationalises Braun & Clarke (2006)
45
+ Phase 2 'Generating Initial Codes' as a reproducible computation.
46
+
47
+ Tighter (e.g. 0.40) β†’ more, finer codes (closer to B&C ideal)
48
+ Looser (e.g. 0.60) β†’ fewer, broader codes
49
+ At 0.50 β€” balanced granularity following BERTopic docs example.
50
+
51
+ MIN_CLUSTER_SIZE = 3
52
+ Clusters with fewer than 3 members are dissolved. Their sentences
53
+ become orphans (label=-1) reported to the reviewer for reassignment.
54
+
55
+ N_CENTROIDS = 200
56
+ Maximum number of clusters saved to summaries.json (and therefore
57
+ labelled and shown in the review table). Set high enough to capture
58
+ all clusters in typical Scopus datasets (1k-5k papers).
59
+ Top clusters extracted for initial discovery report and charts.
60
+
61
+ TOP_TOPICS_LLM = 100
62
+ Maximum clusters sent to Mistral for labelling.
63
+
64
+ NARRATIVE_WORDS = 500
65
+ Target word count for Section 7 narrative.
66
+
67
+ PAJAIS_25
68
+ 25 IS research categories from Jiang et al. (2019).
69
+ Used in Phase 5.5 for taxonomy alignment.
70
+
71
+ BOILERPLATE_PATTERNS (9 regexes)
72
+ Strip publisher noise: copyright, DOI, Elsevier, Springer,
73
+ IEEE, Wiley, Taylor & Francis.
74
+ """
75
+
76
+ from __future__ import annotations
77
+
78
+ import json
79
+ import re
80
+ import numpy as np
81
+ import pandas as pd
82
+ import plotly.graph_objects as go
83
+
84
+ from pathlib import Path
85
+ from langchain_core.tools import tool
86
+ from langchain_mistralai import ChatMistralAI
87
+ from langchain_core.prompts import PromptTemplate
88
+ from langchain_core.output_parsers import JsonOutputParser
89
+ from sentence_transformers import SentenceTransformer
90
+ from sklearn.cluster import AgglomerativeClustering
91
+ from sklearn.metrics.pairwise import cosine_similarity
92
+ from sklearn.preprocessing import normalize
93
+ from sklearn.decomposition import PCA
94
+
95
+
96
+ RUN_CONFIGS = {
97
+ "abstract": ["Abstract"],
98
+ "title": ["Title"],
99
+ }
100
+
101
+ PAJAIS_25 = [
102
+ "Accounting Information Systems",
103
+ "Artificial Intelligence & Expert Systems",
104
+ "Big Data & Analytics",
105
+ "Business Intelligence & Decision Support",
106
+ "Cloud Computing",
107
+ "Cybersecurity & Privacy",
108
+ "Database Management",
109
+ "Digital Transformation",
110
+ "E-Business & E-Commerce",
111
+ "Enterprise Resource Planning",
112
+ "Fintech & Digital Finance",
113
+ "Geographic Information Systems",
114
+ "Health Informatics",
115
+ "Human-Computer Interaction",
116
+ "Information Systems Development",
117
+ "IT Governance & Management",
118
+ "IT Strategy & Competitive Advantage",
119
+ "Knowledge Management",
120
+ "Machine Learning & Deep Learning",
121
+ "Mobile Computing",
122
+ "Natural Language Processing",
123
+ "Recommender Systems",
124
+ "Social Media & Web 2.0",
125
+ "Supply Chain & Logistics IS",
126
+ "Virtual Reality & Augmented Reality",
127
+ ]
128
+
129
+ BOILERPLATE_PATTERNS = [
130
+ r"Β©\s*\d{4}",
131
+ r"all rights reserved",
132
+ r"published by elsevier",
133
+ r"this article is protected",
134
+ r"doi:\s*10\.\d{4,}",
135
+ r"springer nature",
136
+ r"ieee xplore",
137
+ r"wiley online library",
138
+ r"taylor & francis",
139
+ ]
140
+
141
+ BOILERPLATE_RE = re.compile("|".join(BOILERPLATE_PATTERNS), flags=re.IGNORECASE)
142
+ SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+")
143
+ EMBED_MODEL = "all-MiniLM-L6-v2"
144
+ N_CENTROIDS = 200
145
+ CLUSTER_THRESHOLD = 0.50
146
+ MIN_CLUSTER_SIZE = 5
147
+ TOP_TOPICS_LLM = 100
148
+ NARRATIVE_WORDS = 500
149
+
150
+
151
+ def _clean_text(text: str) -> str:
152
+ """Remove publisher boilerplate from a single text string.
153
+
154
+ Applies 9-pattern BOILERPLATE_RE regex to strip copyright notices,
155
+ DOI prefixes, and publisher tags that would pollute embeddings.
156
+
157
+ Args:
158
+ text: Raw abstract or title string.
159
+
160
+ Returns:
161
+ Cleaned string with boilerplate removed and whitespace trimmed.
162
+ """
163
+ return BOILERPLATE_RE.sub("", str(text)).strip()
164
+
165
+
166
+ def _sentence_count(text: str) -> int:
167
+ """Count sentences using regex split on terminal punctuation.
168
+
169
+ Args:
170
+ text: Cleaned abstract or title text.
171
+
172
+ Returns:
173
+ Number of sentences (minimum 1 for any non-empty input).
174
+ """
175
+ return len(SENTENCE_SPLIT_RE.split(text.strip()))
176
+
177
+
178
+ def _embed(texts: list[str]) -> np.ndarray:
179
+ """Embed texts into 384d L2-normalized unit vectors.
180
+
181
+ Uses SentenceTransformer('all-MiniLM-L6-v2') locally β€” no API calls.
182
+ normalize_embeddings=True ensures cosine_similarity = dot product.
183
+
184
+ Args:
185
+ texts: List of N cleaned text strings.
186
+
187
+ Returns:
188
+ np.ndarray shape (N, 384), dtype float32, L2-normalized.
189
+ """
190
+ model = SentenceTransformer(EMBED_MODEL)
191
+ raw = model.encode(texts, show_progress_bar=False, normalize_embeddings=True)
192
+ return np.array(raw, dtype=np.float32)
193
+
194
+
195
+ def _cosine_cluster(matrix: np.ndarray, threshold: float, min_size: int) -> np.ndarray:
196
+ """Cluster embeddings using agglomerative cosine clustering.
197
+
198
+ Works DIRECTLY in 384d space β€” no UMAP. After clustering, any cluster
199
+ with fewer than min_size members is dissolved: its sentences get
200
+ label=-1 (orphan) and are reported to the reviewer for reassignment.
201
+
202
+ Algorithm:
203
+ 1. Start: every text is its own cluster.
204
+ 2. Merge the two closest clusters (average cosine distance).
205
+ 3. Repeat until smallest distance exceeds threshold.
206
+ 4. Post-process: dissolve clusters smaller than min_size.
207
+
208
+ Args:
209
+ matrix: (N, 384) embedding matrix, L2-normalized.
210
+ threshold: Max cosine distance for merging (0.7 β†’ ~100 clusters).
211
+ min_size: Minimum members per cluster (3). Smaller β†’ orphan.
212
+
213
+ Returns:
214
+ np.ndarray shape (N,) with integer labels. -1 = orphan.
215
+ """
216
+ normed = normalize(matrix, norm="l2")
217
+ model = AgglomerativeClustering(
218
+ n_clusters=None,
219
+ metric="cosine",
220
+ linkage="average",
221
+ distance_threshold=threshold,
222
+ )
223
+ labels = model.fit_predict(normed).astype(int)
224
+ unique, counts = np.unique(labels, return_counts=True)
225
+ small_clusters = unique[counts < min_size]
226
+ return np.where(np.isin(labels, small_clusters), -1, labels)
227
+
228
+
229
+ def _centroid(vecs: np.ndarray) -> np.ndarray:
230
+ """Compute L2-normalized centroid (average direction in 384d space).
231
+
232
+ Args:
233
+ vecs: (M, 384) matrix of member embeddings for one cluster.
234
+
235
+ Returns:
236
+ 1d np.ndarray shape (384,), L2-normalized.
237
+ """
238
+ return normalize(vecs.mean(axis=0, keepdims=True), norm="l2")[0]
239
+
240
+
241
+ def _top_n_centroids(matrix: np.ndarray, labels: np.ndarray, n: int) -> list[dict]:
242
+ """Extract N largest clusters by size and compute their centroids.
243
+
244
+ Excludes orphans (label=-1) from the ranking.
245
+
246
+ Args:
247
+ matrix: (N, 384) full embedding matrix.
248
+ labels: (N,) integer cluster labels (-1 = orphan).
249
+ n: How many top clusters to return.
250
+
251
+ Returns:
252
+ List of N dicts with: label, size, indices, centroid.
253
+ """
254
+ valid_mask = labels >= 0
255
+ valid_labels = labels[valid_mask]
256
+ unique, counts = np.unique(valid_labels, return_counts=True)
257
+ order = np.argsort(counts)[::-1][:n]
258
+ top_labels = unique[order]
259
+
260
+ def _build(lbl: int) -> dict:
261
+ """Build summary dict for one cluster."""
262
+ idx = np.where(labels == lbl)[0].tolist()
263
+ return {
264
+ "label": int(lbl),
265
+ "size": len(idx),
266
+ "indices": idx,
267
+ "centroid": _centroid(matrix[idx]),
268
+ }
269
+
270
+ return list(map(_build, top_labels))
271
+
272
+
273
+ def _mistral_chain(template_str: str):
274
+ """Create PromptTemplate β†’ ChatMistralAI β†’ JsonOutputParser chain.
275
+
276
+ Args:
277
+ template_str: Prompt template with {variable} placeholders.
278
+
279
+ Returns:
280
+ LangChain Runnable chain that accepts dict and returns parsed JSON.
281
+ """
282
+ llm = ChatMistralAI(model="mistral-large-latest", temperature=0)
283
+ prompt = PromptTemplate.from_template(template_str)
284
+ return prompt | llm | JsonOutputParser()
285
+
286
+
287
+ def _dark_layout(title: str) -> dict:
288
+ """Return Plotly layout dict with dark theme styling.
289
+
290
+ Args:
291
+ title: Chart title string.
292
+
293
+ Returns:
294
+ Dict for fig.update_layout(**_dark_layout("...")).
295
+ """
296
+ return dict(
297
+ title=title, paper_bgcolor="#0F172A", plot_bgcolor="#0F172A",
298
+ font=dict(color="#CBD5E1", family="Sora,sans-serif"),
299
+ margin=dict(t=50, b=40, l=40, r=20),
300
+ )
301
+
302
+
303
+ @tool
304
+ def load_scopus_csv(csv_path: str, run_mode: str = "abstract") -> str:
305
+ """Load a Scopus CSV, count papers/sentences, apply boilerplate filter.
306
+
307
+ Phase 1 β€” Familiarisation with the Data. DETERMINISTIC.
308
+
309
+ Steps:
310
+ 1. Read CSV, drop rows where target column is null
311
+ 2. Apply 9-pattern boilerplate regex to clean each text
312
+ 3. Count sentences per paper
313
+ 4. Save cleaned DataFrame as .parquet
314
+
315
+ Args:
316
+ csv_path: Path to raw Scopus CSV.
317
+ run_mode: 'abstract' or 'title'.
318
+
319
+ Returns:
320
+ JSON: total_papers, total_sentences, columns_used,
321
+ boilerplate_removed, cleaned_parquet, run_mode.
322
+ """
323
+ cols = RUN_CONFIGS[run_mode]
324
+ target = cols[0]
325
+
326
+ df = pd.read_csv(csv_path).dropna(subset=[target]).reset_index(drop=True)
327
+ raw_texts = df[target].tolist()
328
+ cleaned_texts = list(map(_clean_text, raw_texts))
329
+
330
+ boilerplate_removed = sum(map(
331
+ lambda pair: int(pair[0] != pair[1]),
332
+ zip(raw_texts, cleaned_texts),
333
+ ))
334
+
335
+ df[f"{target}_clean"] = cleaned_texts
336
+ df["sentence_count"] = list(map(_sentence_count, cleaned_texts))
337
+
338
+ out_path = Path(csv_path).with_suffix(".clean.parquet")
339
+ df.to_parquet(out_path, index=False)
340
+
341
+ return json.dumps({
342
+ "total_papers": len(df),
343
+ "total_sentences": int(df["sentence_count"].sum()),
344
+ "columns_used": cols,
345
+ "boilerplate_removed": boilerplate_removed,
346
+ "cleaned_parquet": str(out_path),
347
+ "run_mode": run_mode,
348
+ }, indent=2)
349
+
350
+
351
+ @tool
352
+ def run_bertopic_discovery(parquet_path: str, run_mode: str = "abstract") -> str:
353
+ """Embed texts, cluster them, report orphans, generate charts.
354
+
355
+ Phase 2 β€” Generating Initial Codes. DETERMINISTIC.
356
+
357
+ Steps:
358
+ 1. Load cleaned parquet, drop Author Keywords columns (RULE 8)
359
+ 2. Embed all texts β†’ N x 384 matrix of unit vectors
360
+ 3. Save embedding matrix as .emb.npy
361
+ 4. Cluster in 384d space (NO UMAP), min 3 members per cluster
362
+ 5. Sentences in clusters < 3 members become orphans (label=-1)
363
+ 6. Extract top-N clusters by size, compute centroids
364
+ 7. Save summaries.json with clusters + orphan list
365
+ 8. Generate 4 Plotly HTML charts
366
+
367
+ Args:
368
+ parquet_path: Path to .clean.parquet from load_scopus_csv.
369
+ run_mode: 'abstract' or 'title'.
370
+
371
+ Returns:
372
+ JSON: total_clusters, orphan_count, summaries_json, embeddings_npy,
373
+ charts dict.
374
+ """
375
+ cols = RUN_CONFIGS[run_mode]
376
+ target = f"{cols[0]}_clean"
377
+
378
+ df = pd.read_parquet(parquet_path).drop(
379
+ columns=[c for c in pd.read_parquet(parquet_path).columns
380
+ if re.search(r"keyword|author", c, re.I)],
381
+ errors="ignore",
382
+ )
383
+
384
+ paper_texts = df[target].tolist()
385
+
386
+ sentence_records = list(filter(
387
+ lambda r: len(r["text"].split()) >= 5,
388
+ [
389
+ {"paper_idx": paper_i, "sent_idx": sent_i, "text": sent.strip()}
390
+ for paper_i, paper_text in enumerate(paper_texts)
391
+ for sent_i, sent in enumerate(SENTENCE_SPLIT_RE.split(paper_text or ""))
392
+ if sent.strip()
393
+ ],
394
+ ))
395
+
396
+ texts = list(map(lambda r: r["text"], sentence_records))
397
+ paper_idx = list(map(lambda r: r["paper_idx"], sentence_records))
398
+ embeddings = _embed(texts)
399
+ base = Path(parquet_path).parent
400
+
401
+ np.save(str(base / Path(parquet_path).stem) + ".emb.npy", embeddings)
402
+ (base / "sentences.json").write_text(json.dumps({
403
+ "texts": texts,
404
+ "paper_idx": paper_idx,
405
+ }))
406
+
407
+ labels = _cosine_cluster(embeddings, CLUSTER_THRESHOLD, MIN_CLUSTER_SIZE)
408
+ orphan_idx = np.where(labels == -1)[0].tolist()
409
+ orphan_count = len(orphan_idx)
410
+ valid_count = int((labels >= 0).sum())
411
+ n_clusters = int(np.unique(labels[labels >= 0]).shape[0])
412
+ n_papers = len(set(paper_idx))
413
+ n_sentences = len(texts)
414
+ top_centroids = _top_n_centroids(embeddings, labels, N_CENTROIDS)
415
+
416
+ def _topic_row(tc: dict) -> dict:
417
+ """Convert centroid dict into summary row for summaries.json."""
418
+ return {
419
+ "topic_id": tc["label"],
420
+ "size": tc["size"],
421
+ "representative": texts[tc["indices"][0]][:200],
422
+ "indices": tc["indices"],
423
+ }
424
+
425
+ summaries = list(map(_topic_row, top_centroids))
426
+
427
+ orphans = list(map(
428
+ lambda i: {"sentence_idx": int(i), "text": texts[i][:200]},
429
+ orphan_idx,
430
+ ))
431
+
432
+ output = {"clusters": summaries, "orphans": orphans}
433
+ (base / "summaries.json").write_text(json.dumps(output, indent=2))
434
+
435
+ unique, counts = np.unique(labels[labels >= 0], return_counts=True)
436
+ order = np.argsort(counts)[::-1][:20]
437
+ c1 = go.Figure(go.Bar(
438
+ x=list(map(str, unique[order])), y=counts[order].tolist(),
439
+ marker_color="#3B82F6", text=counts[order].tolist(), textposition="outside",
440
+ ))
441
+ c1.update_layout(**_dark_layout("Topic Size Distribution (Top 20)"),
442
+ xaxis=dict(showgrid=False),
443
+ yaxis=dict(showgrid=True, gridcolor="#1E293B"))
444
+ c1.write_html(str(base / "chart_topic_sizes.html"))
445
+
446
+ centroid_matrix = np.vstack([tc["centroid"] for tc in top_centroids])
447
+ sim_matrix = cosine_similarity(centroid_matrix)
448
+ clabels = list(map(lambda tc: f"T{tc['label']}", top_centroids))
449
+ c2 = go.Figure(go.Heatmap(z=sim_matrix, x=clabels, y=clabels, colorscale="Blues"))
450
+ c2.update_layout(**_dark_layout("Top-5 Centroid Cosine Similarity"))
451
+ c2.write_html(str(base / "chart_centroid_heatmap.html"))
452
+
453
+ sc = df.get("sentence_count", pd.Series([0] * len(df))).tolist()
454
+ c3 = go.Figure(go.Histogram(x=sc, nbinsx=40, marker_color="#22D3EE"))
455
+ c3.update_layout(**_dark_layout("Sentence Count Distribution"),
456
+ xaxis=dict(showgrid=False),
457
+ yaxis=dict(showgrid=True, gridcolor="#1E293B"))
458
+ c3.write_html(str(base / "chart_sentence_distribution.html"))
459
+
460
+ coords = PCA(n_components=2).fit_transform(centroid_matrix)
461
+ point_text = list(map(lambda tc: f"T{tc['label']}({tc['size']})", top_centroids))
462
+ c4 = go.Figure(go.Scatter(
463
+ x=coords[:, 0].tolist(), y=coords[:, 1].tolist(),
464
+ mode="markers+text", text=point_text, textposition="top center",
465
+ marker=dict(size=12, color="#F59E0B", line=dict(width=1, color="#0F172A")),
466
+ ))
467
+ c4.update_layout(**_dark_layout("Top-5 Centroids β€” PCA Projection"))
468
+ c4.write_html(str(base / "chart_centroid_pca.html"))
469
+
470
+ emb_path = str(base / Path(parquet_path).stem) + ".emb.npy"
471
+ return json.dumps({
472
+ "total_clusters": n_clusters,
473
+ "orphan_count": orphan_count,
474
+ "valid_sentences": valid_count,
475
+ "total_sentences": n_sentences,
476
+ "total_papers": n_papers,
477
+ "top_centroids": N_CENTROIDS,
478
+ "summaries_json": str(base / "summaries.json"),
479
+ "embeddings_npy": emb_path,
480
+ "needs_review": True,
481
+ "charts": {
482
+ "topic_sizes": str(base / "chart_topic_sizes.html"),
483
+ "centroid_heatmap": str(base / "chart_centroid_heatmap.html"),
484
+ "sentence_dist": str(base / "chart_sentence_distribution.html"),
485
+ "centroid_pca": str(base / "chart_centroid_pca.html"),
486
+ },
487
+ }, indent=2)
488
+
489
+
490
+ @tool
491
+ def label_topics_with_llm(summaries_json_path: str) -> str:
492
+ """Send top-100 topic summaries to Mistral for labelling.
493
+
494
+ Phase 2 β€” Naming Initial Codes. LLM-DEPENDENT (grounded in real data extracts).
495
+ NOTE: Prefer run_phase_1_and_2 for the standard Phase 2 entry point.
496
+ This tool is kept for backwards compatibility and edge-case re-labelling.
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
+ result = _label_summaries_with_mistral(summaries)
507
+ out_path = Path(summaries_json_path).parent / "topic_labels.json"
508
+ out_path.write_text(json.dumps(result, indent=2))
509
+
510
+ return json.dumps({
511
+ "labelled_topics": len(result),
512
+ "output": str(out_path),
513
+ "needs_review": True,
514
+ }, indent=2)
515
+
516
+
517
+ def _label_summaries_with_mistral(summaries: list[dict]) -> list[dict]:
518
+ """Internal helper: send a list of cluster summaries to Mistral for labelling.
519
+
520
+ Returns a list of dicts with topic_id, label, rationale, confidence.
521
+ Used by both label_topics_with_llm and run_phase_1_and_2.
522
+ """
523
+ template = (
524
+ "You are a scientific topic labelling expert.\n\n"
525
+ "Below are {n} topic summaries from a BERTopic analysis of academic papers.\n"
526
+ "Each summary has: topic_id, size, representative text.\n\n"
527
+ "{summaries}\n\n"
528
+ "For EACH topic return a JSON array where every element has:\n"
529
+ " topic_id : integer (copy from input)\n"
530
+ " label : 2-5 word snake_case topic label\n"
531
+ " rationale : one sentence justification\n"
532
+ " confidence : float 0.0-1.0\n\n"
533
+ "Return ONLY the JSON array β€” no markdown, no preamble."
534
+ )
535
+ return _mistral_chain(template).invoke({
536
+ "n": len(summaries),
537
+ "summaries": json.dumps(summaries, indent=2),
538
+ })
539
+
540
+
541
+ @tool
542
+ def run_phase_1_and_2(csv_path: str, run_mode: str = "abstract") -> str:
543
+ """Execute Phase 1 (Familiarisation) + Phase 2 (Generating Initial Codes)
544
+ in a SINGLE tool call. The canonical entry point for analysis.
545
+
546
+ This is the ONE tool the agent should call when the user clicks
547
+ "Run analysis on abstracts" or "Run analysis on titles".
548
+
549
+ Internally performs:
550
+ 1. Phase 1 β€” Familiarisation with the Data:
551
+ - Load Scopus CSV, drop rows with empty target column
552
+ - Apply boilerplate regex cleaner
553
+ - Save .clean.parquet
554
+
555
+ 2. Phase 2a β€” Sentence Splitting & Embedding:
556
+ - Split each cleaned data item into sentences
557
+ - Filter to sentences with >= 5 words
558
+ - Embed with Sentence-BERT all-MiniLM-L6-v2
559
+ - Save .emb.npy + sentences.json
560
+
561
+ 3. Phase 2b β€” Cosine Agglomerative Clustering:
562
+ - sklearn.cluster.AgglomerativeClustering with metric='cosine',
563
+ linkage='average', distance_threshold=0.50
564
+ - Enforce minimum 5 extracts per code (smaller β†’ orphan)
565
+ - Save summaries.json (top N centroids)
566
+
567
+ 4. Phase 2c β€” LLM Naming via Mistral:
568
+ - Top 100 codes (by size) sent to Mistral for snake_case labels
569
+ - Save topic_labels.json
570
+
571
+ All checkpoint files are saved to the SAME directory as csv_path,
572
+ forming a workspace that downstream tools can discover via workspace_dir.
573
+
574
+ Args:
575
+ csv_path: Path to raw Scopus CSV.
576
+ run_mode: 'abstract' or 'title' β€” which column to analyse.
577
+
578
+ Returns:
579
+ JSON with combined Phase 1 + Phase 2 metrics:
580
+ phase_1: data_items, data_extracts, boilerplate_removed
581
+ phase_2: initial_codes, orphan_extracts, labelled_count
582
+ workspace_dir: directory containing all checkpoints
583
+ needs_review: True (Phase 2 STOP gate awaits)
584
+ """
585
+ cols = RUN_CONFIGS[run_mode]
586
+ target = cols[0]
587
+
588
+ df = pd.read_csv(csv_path).dropna(subset=[target]).reset_index(drop=True)
589
+ raw_texts = df[target].tolist()
590
+ cleaned_texts = list(map(_clean_text, raw_texts))
591
+
592
+ boilerplate_removed = sum(map(
593
+ lambda pair: int(pair[0] != pair[1]),
594
+ zip(raw_texts, cleaned_texts),
595
+ ))
596
+
597
+ df[f"{target}_clean"] = cleaned_texts
598
+ df["sentence_count"] = list(map(_sentence_count, cleaned_texts))
599
+
600
+ workspace = Path(csv_path).parent
601
+ parquet_path = workspace / (Path(csv_path).stem + ".clean.parquet")
602
+ df.to_parquet(parquet_path, index=False)
603
+
604
+ sentence_records = list(filter(
605
+ lambda r: len(r["text"].split()) >= 5,
606
+ [
607
+ {"paper_idx": paper_i, "sent_idx": sent_i, "text": sent.strip()}
608
+ for paper_i, paper_text in enumerate(cleaned_texts)
609
+ for sent_i, sent in enumerate(SENTENCE_SPLIT_RE.split(paper_text or ""))
610
+ if sent.strip()
611
+ ],
612
+ ))
613
+
614
+ texts = list(map(lambda r: r["text"], sentence_records))
615
+ paper_idx = list(map(lambda r: r["paper_idx"], sentence_records))
616
+ embeddings = _embed(texts)
617
+
618
+ np.save(str(workspace / Path(csv_path).stem) + ".emb.npy", embeddings)
619
+ (workspace / "sentences.json").write_text(json.dumps({
620
+ "texts": texts,
621
+ "paper_idx": paper_idx,
622
+ }))
623
+
624
+ labels = _cosine_cluster(embeddings, CLUSTER_THRESHOLD, MIN_CLUSTER_SIZE)
625
+ orphan_idx = np.where(labels == -1)[0].tolist()
626
+ orphan_count = len(orphan_idx)
627
+ valid_count = int((labels >= 0).sum())
628
+ n_clusters = int(np.unique(labels[labels >= 0]).shape[0])
629
+ top_centroids = _top_n_centroids(embeddings, labels, N_CENTROIDS)
630
+
631
+ summaries = list(map(
632
+ lambda tc: {
633
+ "topic_id": int(tc["label"]),
634
+ "size": tc["size"],
635
+ "representative": texts[tc["indices"][0]][:200],
636
+ "indices": tc["indices"],
637
+ },
638
+ top_centroids,
639
+ ))
640
+ orphans = list(map(
641
+ lambda i: {"sentence_idx": int(i), "text": texts[i][:200]},
642
+ orphan_idx,
643
+ ))
644
+ (workspace / "summaries.json").write_text(json.dumps({
645
+ "clusters": summaries,
646
+ "orphans": orphans,
647
+ }, indent=2))
648
+
649
+ labelling_input = list(map(
650
+ lambda s: {k: v for k, v in s.items() if k != "indices"},
651
+ summaries[:TOP_TOPICS_LLM],
652
+ ))
653
+ labelled = _label_summaries_with_mistral(labelling_input)
654
+
655
+ indices_by_id = {s["topic_id"]: s["indices"] for s in summaries}
656
+ enriched = list(map(
657
+ lambda l: {**l,
658
+ "topic_id": int(l.get("topic_id", -1)),
659
+ "size": len(indices_by_id.get(int(l.get("topic_id", -1)), [])),
660
+ "indices": indices_by_id.get(int(l.get("topic_id", -1)), [])},
661
+ labelled,
662
+ ))
663
+ (workspace / "topic_labels.json").write_text(json.dumps(enriched, indent=2))
664
+
665
+ return json.dumps({
666
+ "phase_1": {
667
+ "data_items": len(df),
668
+ "data_extracts": len(texts),
669
+ "boilerplate_removed": boilerplate_removed,
670
+ },
671
+ "phase_2": {
672
+ "initial_codes": n_clusters,
673
+ "labelled_count": len(enriched),
674
+ "orphan_extracts": orphan_count,
675
+ "min_cluster": MIN_CLUSTER_SIZE,
676
+ },
677
+ "workspace_dir": str(workspace),
678
+ "summaries_json": str(workspace / "summaries.json"),
679
+ "labels_json": str(workspace / "topic_labels.json"),
680
+ "embeddings_npy": str(workspace / Path(csv_path).stem) + ".emb.npy",
681
+ "sentences_json": str(workspace / "sentences.json"),
682
+ "needs_review": True,
683
+ }, indent=2)
684
+
685
+
686
+ @tool
687
+ def reassign_sentences(
688
+ summaries_json_path: str,
689
+ embeddings_npy_path: str,
690
+ move_instructions: list[dict],
691
+ ) -> str:
692
+ """Move orphan or misplaced sentences between clusters.
693
+
694
+ Phase 2 β€” Reassigning orphan data extracts. DETERMINISTIC.
695
+
696
+ The reviewer specifies moves as a list of dicts:
697
+ [{"sentence_idx": 42, "to_cluster": 3},
698
+ {"sentence_idx": 99, "to_cluster": "new"}]
699
+
700
+ For "new" targets, a fresh cluster ID is assigned.
701
+ After all moves, centroids are recomputed for affected clusters.
702
+
703
+ Steps:
704
+ 1. Load summaries.json and embeddings
705
+ 2. Apply move instructions
706
+ 3. Update cluster assignments
707
+ 4. Recompute centroids for affected clusters
708
+ 5. Save updated summaries.json
709
+
710
+ Args:
711
+ summaries_json_path: Path to summaries.json.
712
+ embeddings_npy_path: Path to .emb.npy.
713
+ move_instructions: List of dicts with sentence_idx (int) and
714
+ to_cluster (int or "new") keys.
715
+
716
+ Returns:
717
+ JSON: moves_applied count, orphans_remaining, updated summaries path.
718
+ """
719
+ data = json.loads(Path(summaries_json_path).read_text())
720
+ embeddings = np.load(embeddings_npy_path)
721
+ moves = move_instructions
722
+ clusters = data.get("clusters", [])
723
+ orphans = data.get("orphans", [])
724
+
725
+ all_indices = {}
726
+ list(map(
727
+ lambda c: all_indices.update({idx: c["topic_id"] for idx in c.get("indices", [])}),
728
+ clusters,
729
+ ))
730
+
731
+ max_id = max(map(lambda c: c.get("topic_id", 0), clusters), default=0)
732
+ new_id_counter = [max_id + 1]
733
+
734
+ def _apply_move(m: dict) -> dict:
735
+ """Apply one move instruction, return the resolved target cluster ID."""
736
+ s_idx = m["sentence_idx"]
737
+ target = m["to_cluster"]
738
+ resolved = (target == "new") and new_id_counter.__setitem__(0, new_id_counter[0] + 1) or target
739
+ final_id = new_id_counter[0] - 1 * (target == "new") + target * (target != "new")
740
+ all_indices[s_idx] = int(target) * (target != "new") + new_id_counter[0] * (target == "new")
741
+ return {"sentence_idx": s_idx, "assigned_to": all_indices[s_idx]}
742
+
743
+ applied = list(map(_apply_move, moves))
744
+
745
+ unique_clusters = set(all_indices.values())
746
+
747
+ def _rebuild_cluster(cid: int) -> dict:
748
+ """Rebuild a cluster dict from the updated index map."""
749
+ idx = [k for k, v in all_indices.items() if v == cid]
750
+ vecs = embeddings[idx or [0]]
751
+ return {
752
+ "topic_id": int(cid),
753
+ "size": len(idx),
754
+ "representative": "",
755
+ "indices": idx,
756
+ "centroid": _centroid(vecs).tolist(),
757
+ }
758
+
759
+ updated_clusters = list(map(_rebuild_cluster, sorted(unique_clusters)))
760
+ remaining_orphan_idx = [o["sentence_idx"] for o in orphans
761
+ if o["sentence_idx"] not in all_indices]
762
+
763
+ output = {
764
+ "clusters": updated_clusters,
765
+ "orphans": list(map(
766
+ lambda i: {"sentence_idx": i, "text": ""},
767
+ remaining_orphan_idx,
768
+ )),
769
+ }
770
+ Path(summaries_json_path).write_text(json.dumps(output, indent=2))
771
+
772
+ return json.dumps({
773
+ "moves_applied": len(applied),
774
+ "orphans_remaining": len(remaining_orphan_idx),
775
+ "summaries_json": summaries_json_path,
776
+ "needs_review": True,
777
+ }, indent=2)
778
+
779
+
780
+ @tool
781
+ def consolidate_into_themes(
782
+ labels_json_path: str,
783
+ embeddings_npy_path: str,
784
+ approved_topic_ids: list[list[int]],
785
+ ) -> str:
786
+ """Merge approved topic groups into consolidated themes.
787
+
788
+ Phase 3 β€” Searching for Themes. DETERMINISTIC.
789
+
790
+ Steps:
791
+ 1. Load topic_labels.json and embedding matrix
792
+ 2. Pool all member embeddings per group
793
+ 3. Compute fresh L2-normalized centroid per merged group
794
+ 4. Build theme name from joined sub-labels
795
+ 5. Save themes.json
796
+
797
+ Args:
798
+ labels_json_path: Path to topic_labels.json.
799
+ embeddings_npy_path: Path to .emb.npy.
800
+ approved_topic_ids: List of lists of initial-code IDs.
801
+ Each inner list is one candidate theme.
802
+ Example: [[0,1,2],[3,4],[5]] creates 3
803
+ candidate themes from 6 initial codes.
804
+
805
+ Returns:
806
+ JSON: themes_created count + themes_json path. needs_review=True.
807
+ """
808
+ labels_data = json.loads(Path(labels_json_path).read_text())
809
+ embeddings = np.load(embeddings_npy_path)
810
+ groups = approved_topic_ids
811
+ label_map = {item["topic_id"]: item for item in labels_data}
812
+
813
+ def _merge_group(group_ids: list[int]) -> dict:
814
+ """Merge topic IDs into one theme, recompute centroid."""
815
+ members = [m for m in map(label_map.get, group_ids) if m is not None]
816
+ all_idx = sum(map(lambda m: m.get("indices", []), members), [])
817
+ vecs = embeddings[all_idx or [0]]
818
+ centroid = _centroid(vecs)
819
+ sub_labels = list(map(lambda m: m.get("label", ""), members))
820
+ theme_name = "_".join(
821
+ dict.fromkeys(sum(map(lambda lbl: lbl.split("_"), sub_labels), []))
822
+ )[:60]
823
+ return {
824
+ "theme_id": group_ids[0],
825
+ "theme_label": theme_name,
826
+ "merged_ids": group_ids,
827
+ "total_papers": len(set(all_idx)),
828
+ "indices": all_idx,
829
+ "centroid": centroid.tolist(),
830
+ }
831
+
832
+ themes = list(map(_merge_group, groups))
833
+ out_path = Path(labels_json_path).parent / "themes.json"
834
+ out_path.write_text(json.dumps(themes, indent=2))
835
+
836
+ return json.dumps({
837
+ "themes_created": len(themes),
838
+ "themes_json": str(out_path),
839
+ "needs_review": True,
840
+ }, indent=2)
841
+
842
+
843
+ @tool
844
+ def compute_saturation(
845
+ themes_json_path: str,
846
+ embeddings_npy_path: str,
847
+ total_papers: int,
848
+ ) -> str:
849
+ """Compute saturation metrics per theme: coverage, coherence, balance.
850
+
851
+ Phase 4 β€” Reviewing Themes. DETERMINISTIC.
852
+
853
+ Every number in the output is computed by numpy β€” the LLM never
854
+ calculates these values. This eliminates hallucination risk for
855
+ percentages, scores, and ratios.
856
+
857
+ Metrics per theme:
858
+ coverage = papers_in_theme / total_papers (exact percentage)
859
+ coherence = mean pairwise cosine similarity of member embeddings
860
+ (1.0 = all identical, 0.0 = orthogonal)
861
+
862
+ Global metrics:
863
+ total_coverage = papers in at least one theme / total_papers
864
+ balance_ratio = largest_theme / smallest_theme
865
+ mean_coherence = average of per-theme coherence scores
866
+
867
+ Args:
868
+ themes_json_path: Path to themes.json.
869
+ embeddings_npy_path: Path to .emb.npy.
870
+ total_papers: Total papers in corpus (from Phase 1 stats).
871
+
872
+ Returns:
873
+ JSON: per-theme metrics + global metrics. needs_review=True.
874
+ """
875
+ themes = json.loads(Path(themes_json_path).read_text())
876
+ embeddings = np.load(embeddings_npy_path)
877
+
878
+ def _theme_metrics(t: dict) -> dict:
879
+ """Compute coverage and coherence for one theme."""
880
+ idx = t.get("indices", [])
881
+ size = len(idx)
882
+ vecs = embeddings[idx or [0]]
883
+ sim = cosine_similarity(vecs)
884
+ n = len(vecs)
885
+ coherence = float(
886
+ (sim.sum() - n) / max(n * (n - 1), 1)
887
+ )
888
+ return {
889
+ "theme_id": t.get("theme_id", 0),
890
+ "theme_label": t.get("theme_label", ""),
891
+ "papers": size,
892
+ "coverage_pct": round(size / max(total_papers, 1) * 100, 2),
893
+ "coherence": round(coherence, 4),
894
+ }
895
+
896
+ per_theme = list(map(_theme_metrics, themes))
897
+
898
+ all_paper_idx = set(sum(map(lambda t: t.get("indices", []), themes), []))
899
+ sizes = list(map(lambda m: m["papers"], per_theme))
900
+ coherences = list(map(lambda m: m["coherence"], per_theme))
901
+
902
+ global_metrics = {
903
+ "total_coverage_pct": round(len(all_paper_idx) / max(total_papers, 1) * 100, 2),
904
+ "balance_ratio": round(max(sizes, default=1) / max(min(sizes, default=1), 1), 2),
905
+ "mean_coherence": round(sum(coherences) / max(len(coherences), 1), 4),
906
+ "theme_count": len(themes),
907
+ }
908
+
909
+ out_path = Path(themes_json_path).parent / "saturation.json"
910
+ result = {"per_theme": per_theme, "global": global_metrics}
911
+ out_path.write_text(json.dumps(result, indent=2))
912
+
913
+ return json.dumps({
914
+ **global_metrics,
915
+ "per_theme": per_theme,
916
+ "saturation_json": str(out_path),
917
+ "needs_review": True,
918
+ }, indent=2)
919
+
920
+
921
+ @tool
922
+ def generate_theme_profiles(
923
+ themes_json_path: str,
924
+ embeddings_npy_path: str,
925
+ texts_parquet_path: str,
926
+ run_mode: str = "abstract",
927
+ ) -> str:
928
+ """Generate profile cards with top-5 nearest sentences per theme.
929
+
930
+ Phase 5 β€” Defining and Naming Themes. DETERMINISTIC.
931
+
932
+ For each theme centroid, computes cosine similarity against ALL
933
+ embeddings and returns the 5 closest sentences. These are the
934
+ REAL sentences from the corpus β€” not generated, not recalled
935
+ from conversation history. The reviewer uses these to decide
936
+ on final theme names.
937
+
938
+ Steps:
939
+ 1. Load themes.json with centroids
940
+ 2. Load full embedding matrix (sentence-level)
941
+ 3. Load sentences.json (the EXACT sentences that were embedded)
942
+ 4. For each theme: cosine_similarity(centroid, all_embeddings)
943
+ 5. Take top 5 by similarity score
944
+ 6. Return exact sentence text + similarity score
945
+ 7. Save profiles.json
946
+
947
+ Args:
948
+ themes_json_path: Path to themes.json.
949
+ embeddings_npy_path: Path to .emb.npy.
950
+ texts_parquet_path: Path to .clean.parquet (kept for compatibility,
951
+ but sentences are now loaded from sentences.json
952
+ which lives in the same directory).
953
+ run_mode: 'abstract' or 'title'.
954
+
955
+ Returns:
956
+ JSON: profiles list with top-5 sentences per theme. needs_review=True.
957
+ """
958
+ themes = json.loads(Path(themes_json_path).read_text())
959
+ embeddings = np.load(embeddings_npy_path)
960
+ sentences_path = Path(themes_json_path).parent / "sentences.json"
961
+ sentences_data = json.loads(sentences_path.read_text())
962
+ texts = sentences_data["texts"]
963
+
964
+ def _profile(t: dict) -> dict:
965
+ """Build a profile card for one theme: centroid β†’ top 5 nearest."""
966
+ centroid = np.array(t["centroid"]).reshape(1, -1)
967
+ sims = cosine_similarity(centroid, embeddings)[0]
968
+ top5_idx = np.argsort(sims)[::-1][:5].tolist()
969
+ top5 = list(map(
970
+ lambda i: {
971
+ "sentence_idx": i,
972
+ "text": texts[i][:300],
973
+ "similarity": round(float(sims[i]), 4),
974
+ },
975
+ top5_idx,
976
+ ))
977
+ return {
978
+ "theme_id": t.get("theme_id", 0),
979
+ "theme_label": t.get("theme_label", ""),
980
+ "total_papers": t.get("total_papers", 0),
981
+ "top_5_sentences": top5,
982
+ }
983
+
984
+ profiles = list(map(_profile, themes))
985
+ out_path = Path(themes_json_path).parent / "profiles.json"
986
+ out_path.write_text(json.dumps(profiles, indent=2))
987
+
988
+ return json.dumps({
989
+ "profiles_count": len(profiles),
990
+ "profiles_json": str(out_path),
991
+ "profiles": profiles,
992
+ "needs_review": True,
993
+ }, indent=2)
994
+
995
+
996
+ @tool
997
+ def compare_with_taxonomy(themes_json_path: str) -> str:
998
+ """Map each theme to PAJAIS 25 IS research categories via Mistral.
999
+
1000
+ Phase 5.5 β€” Taxonomy Alignment (extension). LLM-DEPENDENT.
1001
+
1002
+ Themes with alignment_score < 0.50 are flagged as potentially NOVEL.
1003
+
1004
+ Args:
1005
+ themes_json_path: Path to themes.json.
1006
+
1007
+ Returns:
1008
+ JSON: themes_aligned count + taxonomy_file path. needs_review=True.
1009
+ """
1010
+ themes = json.loads(Path(themes_json_path).read_text())
1011
+
1012
+ safe_themes = list(map(
1013
+ lambda t: {k: v for k, v in t.items() if k not in ("centroid", "indices")},
1014
+ themes,
1015
+ ))
1016
+
1017
+ template = (
1018
+ "You are an IS research taxonomy expert.\n\n"
1019
+ "PAJAIS 25 Categories:\n{pajais}\n\n"
1020
+ "Research themes:\n{themes}\n\n"
1021
+ "For EACH theme return a JSON array where every element has:\n"
1022
+ " theme_label : string\n"
1023
+ " pajais_categories : list of 1-3 matching PAJAIS category names\n"
1024
+ " alignment_score : float 0.0-1.0\n"
1025
+ " notes : one sentence justification\n\n"
1026
+ "Return ONLY the JSON array β€” no markdown, no preamble."
1027
+ )
1028
+
1029
+ result = _mistral_chain(template).invoke({
1030
+ "pajais": "\n".join(map(lambda c: f"- {c}", PAJAIS_25)),
1031
+ "themes": json.dumps(safe_themes, indent=2),
1032
+ })
1033
+ out_path = Path(themes_json_path).parent / "taxonomy_alignment.json"
1034
+ out_path.write_text(json.dumps(result, indent=2))
1035
+
1036
+ return json.dumps({
1037
+ "themes_aligned": len(result),
1038
+ "taxonomy_file": str(out_path),
1039
+ "needs_review": True,
1040
+ }, indent=2)
1041
+
1042
+
1043
+ @tool
1044
+ def generate_comparison_csv(
1045
+ abstract_themes_path: str,
1046
+ title_themes_path: str,
1047
+ taxonomy_abstract_path: str,
1048
+ taxonomy_title_path: str,
1049
+ ) -> str:
1050
+ """Build side-by-side abstract vs title comparison CSV.
1051
+
1052
+ Phase 6 β€” Report. DETERMINISTIC.
1053
+
1054
+ Joins on PAJAIS_Category. Delta_Score = Abstract - Title.
1055
+
1056
+ Args:
1057
+ abstract_themes_path: themes.json β€” abstract run.
1058
+ title_themes_path: themes.json β€” title run.
1059
+ taxonomy_abstract_path: taxonomy_alignment.json β€” abstract run.
1060
+ taxonomy_title_path: taxonomy_alignment.json β€” title run.
1061
+
1062
+ Returns:
1063
+ JSON: comparison_csv path, total_rows, columns. needs_review=True.
1064
+ """
1065
+ def _explode_taxonomy(path: str) -> pd.DataFrame:
1066
+ """Flatten taxonomy alignment into one row per PAJAIS category."""
1067
+ data = json.loads(Path(path).read_text())
1068
+ rows = sum(
1069
+ list(map(
1070
+ lambda item: list(map(
1071
+ lambda cat: {
1072
+ "pajais_category": cat,
1073
+ "theme_label": item.get("theme_label", ""),
1074
+ "alignment_score": item.get("alignment_score", 0.0),
1075
+ },
1076
+ item.get("pajais_categories", []),
1077
+ )),
1078
+ data,
1079
+ )),
1080
+ [],
1081
+ )
1082
+ return pd.DataFrame(rows)
1083
+
1084
+ df_abs = _explode_taxonomy(taxonomy_abstract_path)
1085
+ df_title = _explode_taxonomy(taxonomy_title_path)
1086
+
1087
+ df_abs.columns = ["PAJAIS_Category", "Abstract_Theme", "Abstract_Score"]
1088
+ df_title.columns = ["PAJAIS_Category", "Title_Theme", "Title_Score"]
1089
+
1090
+ merged = (
1091
+ pd.merge(df_abs, df_title, on="PAJAIS_Category", how="outer")
1092
+ .fillna({"Abstract_Score": 0.0, "Title_Score": 0.0,
1093
+ "Abstract_Theme": "", "Title_Theme": ""})
1094
+ .assign(Delta_Score=lambda d: (d["Abstract_Score"] - d["Title_Score"]).round(4))
1095
+ .sort_values("PAJAIS_Category")
1096
+ .reset_index(drop=True)
1097
+ )
1098
+
1099
+ out_csv = Path(abstract_themes_path).parent / "abstract_vs_title_comparison.csv"
1100
+ merged.to_csv(out_csv, index=False)
1101
+
1102
+ return json.dumps({
1103
+ "comparison_csv": str(out_csv),
1104
+ "total_rows": len(merged),
1105
+ "columns": list(merged.columns),
1106
+ "needs_review": True,
1107
+ }, indent=2)
1108
+
1109
+
1110
+ @tool
1111
+ def export_narrative(
1112
+ taxonomy_alignment_path: str,
1113
+ comparison_csv_path: str,
1114
+ run_mode: str = "abstract",
1115
+ ) -> str:
1116
+ """Generate 500-word Section 7: Discussion & Implications via Mistral.
1117
+
1118
+ Phase 6 β€” Report. LLM-DEPENDENT (grounded in taxonomy + comparison data).
1119
+
1120
+ Args:
1121
+ taxonomy_alignment_path: Path to taxonomy_alignment.json.
1122
+ comparison_csv_path: Path to comparison CSV.
1123
+ run_mode: 'abstract' or 'title'.
1124
+
1125
+ Returns:
1126
+ JSON: narrative_path, word_count, narrative text. needs_review=True.
1127
+ """
1128
+ alignment = json.loads(Path(taxonomy_alignment_path).read_text())
1129
+
1130
+ top_delta = (
1131
+ pd.read_csv(comparison_csv_path)
1132
+ .assign(_abs=lambda d: d["Delta_Score"].abs())
1133
+ .sort_values("_abs", ascending=False)
1134
+ .drop(columns=["_abs"])
1135
+ .head(5)
1136
+ )
1137
+
1138
+ template = (
1139
+ "You are a senior IS researcher writing a systematic literature review.\n\n"
1140
+ "Write Section 7: Discussion & Implications in exactly {word_count} words.\n\n"
1141
+ "Run mode: {run_mode}\n\n"
1142
+ "Taxonomy alignment (top 10):\n{alignment}\n\n"
1143
+ "Top 5 divergent PAJAIS categories (abstract vs title):\n{divergence}\n\n"
1144
+ "Requirements:\n"
1145
+ "1. Discuss dominant themes and PAJAIS alignment.\n"
1146
+ "2. Interpret divergence between abstract- and title-based models.\n"
1147
+ "3. Highlight implications for IS research practice and future agenda.\n"
1148
+ "4. Use formal academic register β€” no bullet points.\n"
1149
+ "5. Return a JSON object with a single key 'narrative' containing the prose.\n\n"
1150
+ "Return ONLY valid JSON."
1151
+ )
1152
+
1153
+ result = _mistral_chain(template).invoke({
1154
+ "word_count": NARRATIVE_WORDS,
1155
+ "run_mode": run_mode,
1156
+ "alignment": json.dumps(alignment[:10], indent=2),
1157
+ "divergence": top_delta.to_json(orient="records", indent=2),
1158
+ })
1159
+ narrative_text = result.get("narrative", str(result))
1160
+ out_path = Path(taxonomy_alignment_path).parent / "narrative.md"
1161
+ out_path.write_text(
1162
+ f"## Section 7: Discussion & Implications\n\n{narrative_text}\n",
1163
+ encoding="utf-8",
1164
+ )
1165
+
1166
+ return json.dumps({
1167
+ "narrative_path": str(out_path),
1168
+ "word_count": len(narrative_text.split()),
1169
+ "narrative": narrative_text,
1170
+ "needs_review": True,
1171
+ }, indent=2)
1172
+
1173
+
1174
+ ALL_TOOLS = [
1175
+ run_phase_1_and_2,
1176
+ load_scopus_csv,
1177
+ run_bertopic_discovery,
1178
+ label_topics_with_llm,
1179
+ reassign_sentences,
1180
+ consolidate_into_themes,
1181
+ compute_saturation,
1182
+ generate_theme_profiles,
1183
+ compare_with_taxonomy,
1184
+ generate_comparison_csv,
1185
+ export_narrative,
1186
+ ]