CodeChamp95 commited on
Commit
b221afb
Β·
verified Β·
1 Parent(s): 9428ece

Upload 4 files

Browse files
Files changed (4) hide show
  1. agent.py +653 -0
  2. app.py +617 -0
  3. requirements.txt +13 -0
  4. tools.py +528 -0
agent.py ADDED
@@ -0,0 +1,653 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agent.py β€” BERTopic Thematic Analysis Agent
3
+ Braun & Clarke (2006) six-phase methodology implemented as a ReAct agent
4
+ using LangGraph, ChatMistralAI, and MemorySaver.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Any, Generator
13
+
14
+ from langchain_mistralai import ChatMistralAI
15
+ from langgraph.checkpoint.memory import MemorySaver
16
+ from langgraph.prebuilt import create_react_agent
17
+
18
+ from tools import (
19
+ load_scopus_csv,
20
+ run_bertopic_discovery,
21
+ label_topics_with_llm,
22
+ consolidate_into_themes,
23
+ compare_with_taxonomy,
24
+ generate_comparison_csv,
25
+ export_narrative,
26
+ )
27
+
28
+ # ──────────────────────────────────────────────────────────────────────────────
29
+ # Artifact paths (shared across phases)
30
+ # ──────────────────────────────────────────────────────────────────────────────
31
+
32
+ ARTIFACTS_DIR = Path("artifacts")
33
+ ARTIFACTS_DIR.mkdir(exist_ok=True)
34
+
35
+ _LOADED_DATA = str(ARTIFACTS_DIR / "loaded_data.json")
36
+ _SUMMARIES = str(ARTIFACTS_DIR / "summaries.json")
37
+ _EMB = str(ARTIFACTS_DIR / "emb.npy")
38
+ _LABELS = str(ARTIFACTS_DIR / "topic_labels.json")
39
+ _THEMES = str(ARTIFACTS_DIR / "themes.json")
40
+ _TAXONOMY = str(ARTIFACTS_DIR / "taxonomy_mapping.json")
41
+ _COMPARISON_CSV = str(ARTIFACTS_DIR / "abstract_vs_title_comparison.csv")
42
+ _NARRATIVE = str(ARTIFACTS_DIR / "section7_narrative.txt")
43
+
44
+ # ──────────────────────────────────────────────────────────────────────────────
45
+ # System Prompt
46
+ # ──────────────────────────────────────────────────────────────────────────────
47
+
48
+ SYSTEM_PROMPT = """
49
+ ╔══════════════════════════════════════════════════════════════════════════════╗
50
+ β•‘ COMPUTATIONAL THEMATIC ANALYSIS AGENT β€” SYSTEM PROMPT β•‘
51
+ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
52
+
53
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
54
+ ROLE
55
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
56
+ You are a computational thematic analysis expert trained in the Braun & Clarke
57
+ (2006) six-phase framework for rigorous qualitative and mixed-methods research.
58
+ You specialise in applying BERTopic-based semantic clustering to academic
59
+ literature corpora, with deep expertise in:
60
+
61
+ β€’ Systematic literature review methodology
62
+ β€’ Sentence-level semantic embedding and agglomerative clustering
63
+ β€’ LLM-assisted topic labelling and theme consolidation
64
+ β€’ PAJAIS (Pacific-Asia Journal of the Association for Information Systems)
65
+ 25-category research taxonomy alignment
66
+ β€’ Transparent, reproducible, human-in-the-loop analytical pipelines
67
+
68
+ Your outputs are used in peer-reviewed academic research. Precision,
69
+ methodological rigour, and faithful adherence to the B&C (2006) phases are
70
+ non-negotiable.
71
+
72
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
73
+ CRITICAL RULES (must be followed without exception)
74
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
75
+ 1. ONE PHASE PER MESSAGE. Complete exactly one B&C phase per conversational
76
+ turn. Never skip ahead or combine phases in a single response.
77
+
78
+ 2. ALL APPROVALS VIA REVIEW TABLE β€” NEVER VIA CHAT. You must NEVER ask the
79
+ user to approve, reject, or rename topics in free-text chat. Every approval
80
+ workflow must go through the Gradio review table. After populating the table
81
+ you must STOP and wait for the user to click "Submit Review".
82
+
83
+ 3. STOP GATES ARE MANDATORY. At the end of Phases 2, 3, 4, and 5.5 you must
84
+ output the exact STOP phrase:
85
+ ⏸ STOP GATE β€” awaiting your review table submission to continue.
86
+ Do not proceed until the user's next message contains review data.
87
+
88
+ 4. NEVER HALLUCINATE TOOL RESULTS. If a tool call fails, report the exact
89
+ error verbatim and ask the user how to proceed. Do not invent file paths,
90
+ cluster counts, or topic labels.
91
+
92
+ 5. COLUMN DISCIPLINE. Never include "Author Keywords" in any clustering run.
93
+ Use only the columns specified in RUN_CONFIGS: Abstract (abstract run) or
94
+ Title (title run).
95
+
96
+ 6. ARTEFACT HYGIENE. Every tool saves files to the artifacts/ directory.
97
+ Always pass the exact saved_path returned by a previous tool to the next
98
+ tool. Never guess or construct file paths manually.
99
+
100
+ 7. STREAMING DISCIPLINE. Yield one streamed chunk per reasoning step so the
101
+ Gradio UI can update the phase progress bar in real time.
102
+
103
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
104
+ TOOLS (7 available)
105
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
106
+ 1. load_scopus_csv(csv_path, run_mode)
107
+ Load a Scopus-exported CSV. Counts papers and sentences. Applies
108
+ boilerplate regex filter. Saves loaded_data.json. Use in Phase 1.
109
+
110
+ 2. run_bertopic_discovery(loaded_data_path)
111
+ Embeds sentences with all-MiniLM-L6-v2 (normalize_embeddings=True).
112
+ Clusters with AgglomerativeClustering(metric=cosine, threshold=0.7).
113
+ No UMAP. Finds 5 nearest centroid sentences per cluster. Generates
114
+ 4 Plotly charts. Saves summaries.json + emb.npy. Use in Phase 2.
115
+
116
+ 3. label_topics_with_llm(summaries_path, top_n)
117
+ Sends top-N topics (max 100) to Mistral via PromptTemplate +
118
+ JsonOutputParser. Returns short labels and descriptions. Saves
119
+ topic_labels.json. Use in Phase 2 after discovery.
120
+
121
+ 4. consolidate_into_themes(labels_path, summaries_path, emb_path, approved_groups)
122
+ Merges approved topic groups into named themes. Recomputes centroids.
123
+ Saves themes.json. Use in Phase 3 after the review table is submitted.
124
+
125
+ 5. compare_with_taxonomy(themes_path)
126
+ Maps consolidated themes to PAJAIS 25 categories via Mistral.
127
+ Returns confidence scores and rationale. Saves taxonomy_mapping.json.
128
+ Use in Phase 5.5.
129
+
130
+ 6. generate_comparison_csv(csv_path, taxonomy_path)
131
+ Produces abstract vs title side-by-side CSV with PAJAIS categories
132
+ and confidence scores. Saves abstract_vs_title_comparison.csv.
133
+ Use in Phase 6.
134
+
135
+ 7. export_narrative(taxonomy_path)
136
+ Generates a ~500-word Section 7 (Discussion & Implications) as
137
+ flowing academic prose via Mistral. Saves section7_narrative.txt.
138
+ Use in Phase 6.
139
+
140
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
141
+ BRAUN & CLARKE (2006) SIX-PHASE PROTOCOL
142
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
143
+
144
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
145
+ β”‚ PHASE 1 β€” FAMILIARISATION WITH THE DATA β”‚
146
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
147
+ Objective: Immerse in the corpus. Understand its scope, structure, and quality.
148
+
149
+ Instructions:
150
+ a. Call load_scopus_csv(csv_path=<user_provided>, run_mode=<"abstract"|"title">).
151
+ b. Display the returned statistics in a clear summary:
152
+ β€’ Total papers loaded
153
+ β€’ Total sentences extracted
154
+ β€’ Sentences remaining after boilerplate filtering
155
+ β€’ Column(s) used
156
+ β€’ Run mode (abstract / title)
157
+ c. Comment briefly on data quality: density, likely noise level, any
158
+ column mapping issues detected.
159
+ d. STOP. Do not proceed to Phase 2 until the user explicitly confirms
160
+ they are satisfied with the loaded data.
161
+
162
+ Output template:
163
+ πŸ“‚ Phase 1 Complete β€” Familiarisation
164
+ ─────────────────────────────────────
165
+ Papers: {N}
166
+ Sentences extracted: {S}
167
+ After filtering: {F}
168
+ Column used: {C}
169
+ Run mode: {M}
170
+ [Quality commentary]
171
+ βœ… Ready for Phase 2. Reply "proceed" to start Initial Coding.
172
+
173
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
174
+
175
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
176
+ β”‚ PHASE 2 β€” GENERATING INITIAL CODES β”‚
177
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
178
+ Objective: Produce a full set of atomic semantic codes from the corpus.
179
+
180
+ Instructions:
181
+ a. Call run_bertopic_discovery(loaded_data_path=artifacts/loaded_data.json).
182
+ Report: number of clusters found, total sentences clustered, chart paths.
183
+ b. Call label_topics_with_llm(summaries_path=artifacts/summaries.json, top_n=100).
184
+ Report: number of topics labelled.
185
+ c. Populate the Gradio review table with ALL labelled topics. Each row must
186
+ contain:
187
+ β€’ # β€” topic_id (integer)
188
+ β€’ Topic Label β€” LLM-generated label
189
+ β€’ Top Evidence β€” first centroid sentence (truncated to 120 chars)
190
+ β€’ Sentences β€” cluster size
191
+ β€’ Papers β€” estimated paper count (size Γ· avg sentences per paper)
192
+ β€’ Approve β€” default True
193
+ β€’ Rename To β€” empty (user fills)
194
+ β€’ Reasoning β€” empty (user fills)
195
+ d. Present the 4 Plotly charts by referencing their file paths.
196
+ e. Explain to the user:
197
+ β€’ Check "Approve" for topics to keep; uncheck to discard.
198
+ β€’ Fill "Rename To" with a preferred label; leave blank to keep LLM label.
199
+ β€’ Optionally note merging intentions in "Reasoning".
200
+ β€’ Topics with the same "Reasoning" group tag will be merged in Phase 3.
201
+
202
+ ⏸ STOP GATE β€” awaiting your review table submission to continue.
203
+
204
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
205
+
206
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
207
+ β”‚ PHASE 3 β€” SEARCHING FOR THEMES β”‚
208
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
209
+ Objective: Collate approved codes into candidate themes.
210
+
211
+ Instructions:
212
+ a. Parse the submitted review table. Extract:
213
+ β€’ Approved topic IDs (Approve == True)
214
+ β€’ Rename mappings (Rename To != "")
215
+ β€’ Merge groups (topics sharing the same Reasoning tag)
216
+ b. Construct approved_groups: a JSON list of lists, where each inner list
217
+ contains the topic_ids belonging to one theme. Topics with a shared
218
+ Reasoning tag form one group. Approved topics with no Reasoning tag
219
+ each form a singleton group.
220
+ c. Call consolidate_into_themes(
221
+ labels_path=artifacts/topic_labels.json,
222
+ summaries_path=artifacts/summaries.json,
223
+ emb_path=artifacts/emb.npy,
224
+ approved_groups=<constructed JSON string>
225
+ ).
226
+ d. Display a theme summary table:
227
+ Theme # | Theme Label | Topics Merged | Total Sentences
228
+
229
+ ⏸ STOP GATE β€” awaiting your review table submission to continue.
230
+
231
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
232
+
233
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
234
+ β”‚ PHASE 4 β€” REVIEWING THEMES / SATURATION CHECK β”‚
235
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
236
+ Objective: Assess whether themes are internally coherent and collectively
237
+ exhaustive. Check corpus coverage.
238
+
239
+ Instructions:
240
+ a. Load artifacts/themes.json (already created by Phase 3).
241
+ b. Compute and display a saturation report:
242
+ β€’ Total sentences covered by all themes vs. total corpus sentences
243
+ β€’ Coverage percentage
244
+ β€’ Theme coherence flag: warn if any theme covers < 1 % of corpus
245
+ β€’ Overlap flag: warn if any two themes share > 30 % vocabulary
246
+ (Vocabulary overlap is approximated by comparing top-evidence sentences
247
+ using word-level Jaccard similarity β€” compute in Python, no tool call.)
248
+ c. Populate the review table again with the THEME list (not topic list):
249
+ β€’ # β€” theme_id
250
+ β€’ Topic Label β€” current theme_label
251
+ β€’ Top Evidence β€” first top_evidence sentence (120 chars)
252
+ β€’ Sentences β€” total_size
253
+ β€’ Papers β€” estimated
254
+ β€’ Approve β€” default True
255
+ β€’ Rename To β€” user may provide final name
256
+ β€’ Reasoning β€” any split/merge instructions
257
+ d. Ask the user to confirm themes, request splits/merges, or rename.
258
+
259
+ ⏸ STOP GATE β€” awaiting your review table submission to continue.
260
+
261
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
262
+
263
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
264
+ β”‚ PHASE 5 β€” DEFINING AND NAMING THEMES β”‚
265
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
266
+ Objective: Produce final, publication-ready theme names and definitions.
267
+
268
+ Instructions:
269
+ a. Apply all renames from the Phase 4 review table to artifacts/themes.json
270
+ in memory (update theme_label field for each theme_id where Rename To
271
+ is non-empty).
272
+ b. For each finalised theme, generate a two-sentence academic definition
273
+ grounded in the top_evidence sentences. Output this as a numbered list.
274
+ c. Confirm the final theme set to the user in a clean summary:
275
+ Theme # | Final Name | Definition (2 sentences) | Sentence Count
276
+ d. STOP. Ask the user to confirm the final names before PAJAIS mapping.
277
+ βœ… Reply "proceed to taxonomy" to continue to Phase 5.5.
278
+
279
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
280
+
281
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
282
+ β”‚ PHASE 5.5 β€” PAJAIS TAXONOMY ALIGNMENT β”‚
283
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
284
+ Objective: Map each finalised theme to the PAJAIS 25-category taxonomy.
285
+
286
+ Instructions:
287
+ a. Call compare_with_taxonomy(themes_path=artifacts/themes.json).
288
+ b. Display the mapping results in a structured table:
289
+ Theme Name | PAJAIS Category | Confidence | Rationale
290
+ c. Highlight any themes with confidence < 0.5 as requiring manual review.
291
+ d. Note any PAJAIS categories not covered by the corpus (research gaps).
292
+ e. Populate the review table with the mapping results:
293
+ β€’ # β€” theme_id
294
+ β€’ Topic Label β€” theme_label β†’ PAJAIS category
295
+ β€’ Top Evidence β€” rationale (truncated)
296
+ β€’ Approve β€” default True (uncheck to override mapping)
297
+ β€’ Rename To β€” alternative PAJAIS category if user disagrees
298
+ β€’ Reasoning β€” free notes
299
+
300
+ ⏸ STOP GATE β€” awaiting your review table submission to continue.
301
+
302
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
303
+
304
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
305
+ β”‚ PHASE 6 β€” PRODUCING THE REPORT β”‚
306
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
307
+ Objective: Generate all final deliverables.
308
+
309
+ Instructions:
310
+ a. Apply any PAJAIS category overrides from the Phase 5.5 review table
311
+ to artifacts/taxonomy_mapping.json in memory.
312
+ b. Call generate_comparison_csv(
313
+ csv_path=<original CSV path>,
314
+ taxonomy_path=artifacts/taxonomy_mapping.json
315
+ ).
316
+ Report: row count, file path.
317
+ c. Call export_narrative(taxonomy_path=artifacts/taxonomy_mapping.json).
318
+ Report: word count, file path, first 150 chars of preview.
319
+ d. Present a final deliverables checklist:
320
+ βœ… artifacts/summaries.json β€” raw cluster summaries
321
+ βœ… artifacts/topic_labels.json β€” LLM-generated labels
322
+ βœ… artifacts/themes.json β€” consolidated themes
323
+ βœ… artifacts/taxonomy_mapping.json β€” PAJAIS alignment
324
+ βœ… artifacts/abstract_vs_title_comparison.csv
325
+ βœ… artifacts/section7_narrative.txt β€” ~500-word Section 7
326
+ βœ… artifacts/chart_cluster_sizes.html
327
+ βœ… artifacts/chart_pca_scatter.html
328
+ βœ… artifacts/chart_top10_pie.html
329
+ βœ… artifacts/chart_centroid_heatmap.html
330
+ e. Congratulate the user and offer to re-run in title mode for comparison.
331
+
332
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
333
+ END OF SYSTEM PROMPT
334
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
335
+ """.strip()
336
+
337
+ # ──────────────────────────────────────────────────────────────────────────────
338
+ # Tool registry
339
+ # ──────────────────────────────────────────────────────────────────────────────
340
+
341
+ TOOLS = [
342
+ load_scopus_csv,
343
+ run_bertopic_discovery,
344
+ label_topics_with_llm,
345
+ consolidate_into_themes,
346
+ compare_with_taxonomy,
347
+ generate_comparison_csv,
348
+ export_narrative,
349
+ ]
350
+
351
+ # ──────────────────────────────────────────────────────────────────────────────
352
+ # Phase detection helpers
353
+ # ──────────────────────────────────────────────────────────────────────────────
354
+
355
+ _PHASE_PATTERNS = {
356
+ "loading": re.compile(r"load_scopus_csv|loaded_data", re.I),
357
+ "embedding": re.compile(r"run_bertopic_discovery|embedding", re.I),
358
+ "clustering": re.compile(r"summaries\.json|n_topics|clusters found", re.I),
359
+ "labelling": re.compile(r"label_topics_with_llm|topic_labels", re.I),
360
+ "review": re.compile(r"STOP GATE|review table|submit review", re.I),
361
+ "done": re.compile(r"section7_narrative|deliverables checklist", re.I),
362
+ }
363
+
364
+
365
+ def _detect_phase(text: str) -> str:
366
+ """Return the most specific pipeline phase detectable from agent output."""
367
+ matched = list(filter(
368
+ lambda kv: kv[1].search(text),
369
+ _PHASE_PATTERNS.items(),
370
+ ))
371
+ return matched[-1][0] if matched else "idle"
372
+
373
+
374
+ # ──────────────────────────────────────────────────────────────────────────────
375
+ # Review-table row builder
376
+ # ──────────────────────────────────────────────────────────────────────────────
377
+
378
+ _TABLE_COLS = ["#", "Topic Label", "Top Evidence", "Sentences", "Papers", "Approve", "Rename To", "Reasoning"]
379
+
380
+
381
+ def _topic_to_row(topic: dict, papers_per_sent: float = 0.2) -> list:
382
+ """Convert a topic/theme dict to a review-table row."""
383
+ evidence = (topic.get("top_evidence") or [""])[0]
384
+ return [
385
+ topic.get("topic_id", topic.get("theme_id", 0)),
386
+ topic.get("label", topic.get("theme_label", "")),
387
+ evidence[:120],
388
+ topic.get("size", topic.get("total_size", 0)),
389
+ round(topic.get("size", topic.get("total_size", 0)) * papers_per_sent),
390
+ True,
391
+ "",
392
+ "",
393
+ ]
394
+
395
+
396
+ def _build_review_rows(path: str, id_key: str = "topic_id") -> list[list]:
397
+ """Load a JSON artefact and convert every entry to a review-table row."""
398
+ records = json.loads(Path(path).read_text())
399
+ return list(map(_topic_to_row, records))
400
+
401
+
402
+ # ────────────────────────────────���─────────────────────────────────────────────
403
+ # Approved-groups extractor (called in handle_review for Phase 2 β†’ 3)
404
+ # ──────────────────────────────────────────────────────────────────────────────
405
+
406
+ def _extract_approved_groups(rows: list[list]) -> str:
407
+ """
408
+ Parse review-table rows into approved_groups JSON string.
409
+
410
+ Groups are formed by the Reasoning field value:
411
+ β€’ Rows sharing a non-empty Reasoning tag β†’ merged into one group
412
+ β€’ Approved rows with empty Reasoning β†’ singleton group each
413
+ β€’ Unapproved rows (Approve == False) β†’ discarded
414
+ """
415
+ approved = list(filter(lambda r: r[5] is True or r[5] == "True" or r[5] == 1, rows))
416
+
417
+ tagged = list(filter(lambda r: str(r[7]).strip(), approved))
418
+ untagged = list(filter(lambda r: not str(r[7]).strip(), approved))
419
+
420
+ # Group tagged rows by their Reasoning value
421
+ reasoning_vals = list(set(map(lambda r: str(r[7]).strip(), tagged)))
422
+
423
+ tagged_groups = list(map(
424
+ lambda tag: list(map(
425
+ lambda r: int(r[0]),
426
+ filter(lambda r: str(r[7]).strip() == tag, tagged),
427
+ )),
428
+ reasoning_vals,
429
+ ))
430
+
431
+ singleton_groups = list(map(lambda r: [int(r[0])], untagged))
432
+
433
+ all_groups = tagged_groups + singleton_groups
434
+ return json.dumps(all_groups)
435
+
436
+
437
+ # ──────────────────────────────────────────────────────────────────────────────
438
+ # BERTopicAgent β€” the class consumed by app.py
439
+ # ──────────────────────────────────────────────────────────────────────────────
440
+
441
+ class BERTopicAgent:
442
+ """
443
+ Wraps a LangGraph ReAct agent and exposes the two generator methods
444
+ expected by the Gradio front-end:
445
+
446
+ handle_message(message, history, csv_path) β†’ yields 5-tuple
447
+ handle_review(table_rows, history) β†’ yields 4-tuple
448
+ """
449
+
450
+ # Gradio 5-tuple: (history, phase, charts_dict, downloads_list, topic_rows)
451
+ # Gradio 4-tuple: (history, phase, charts_dict, downloads_list)
452
+
453
+ def __init__(self) -> None:
454
+ self.phase = "idle"
455
+ self._charts: dict[str, str] = {}
456
+ self._downloads: list[str] = []
457
+ self._csv_path: str | None = None
458
+
459
+ self._llm = ChatMistralAI(
460
+ model="mistral-large-latest",
461
+ temperature=0.2,
462
+ streaming=True,
463
+ )
464
+ self._memory = MemorySaver()
465
+ self._graph = create_react_agent(
466
+ model=self._llm,
467
+ tools=TOOLS,
468
+ checkpointer=self._memory,
469
+ prompt=SYSTEM_PROMPT,
470
+ )
471
+ self._thread_id = "bc2006-session-1"
472
+
473
+ # ── internal ──────────────────────────────────────────────────────────────
474
+
475
+ def _config(self) -> dict:
476
+ return {"configurable": {"thread_id": self._thread_id}}
477
+
478
+ def _update_charts(self, text: str) -> None:
479
+ """Scan agent text for chart file paths and register them."""
480
+ found = re.findall(r'artifacts/chart_[a-z_]+\.html', text)
481
+ label_map = {
482
+ "chart_cluster_sizes": "Cluster Sizes",
483
+ "chart_pca_scatter": "PCA Scatter",
484
+ "chart_top10_pie": "Top-10 Pie",
485
+ "chart_centroid_heatmap": "Centroid Heatmap",
486
+ }
487
+ list(map(
488
+ lambda p: self._charts.__setitem__(
489
+ label_map.get(Path(p).stem, Path(p).stem), p
490
+ ),
491
+ found,
492
+ ))
493
+
494
+ def _update_downloads(self, text: str) -> None:
495
+ """Scan agent text for downloadable artefact paths."""
496
+ found = re.findall(r'artifacts/[\w_]+\.(json|csv|txt|npy|html)', text)
497
+ new = list(filter(lambda p: p not in self._downloads, found))
498
+ self._downloads.extend(new)
499
+
500
+ def _accumulate_stream(
501
+ self,
502
+ stream: Any,
503
+ history: list,
504
+ user_msg: str,
505
+ ) -> Generator[tuple, None, None]:
506
+ """
507
+ Consume a LangGraph stream, yielding Gradio 5-tuples incrementally.
508
+ Handles both streaming token chunks and full message events.
509
+ """
510
+ accumulated = ""
511
+
512
+ for chunk in stream:
513
+ # LangGraph yields dicts keyed by node name
514
+ node_output = (
515
+ chunk.get("agent") or
516
+ chunk.get("tools") or
517
+ {}
518
+ )
519
+ messages = node_output.get("messages", [])
520
+
521
+ text_delta = "".join(list(map(
522
+ lambda m: getattr(m, "content", "") if hasattr(m, "content") else "",
523
+ messages,
524
+ )))
525
+
526
+ accumulated += text_delta
527
+ self._update_charts(accumulated)
528
+ self._update_downloads(accumulated)
529
+ self.phase = _detect_phase(accumulated)
530
+
531
+ updated_history = history + [[user_msg, accumulated]] if accumulated else history
532
+
533
+ yield (
534
+ updated_history,
535
+ self.phase,
536
+ dict(self._charts),
537
+ list(self._downloads),
538
+ [], # topic_rows populated in final yield
539
+ )
540
+
541
+ # Final yield with review-table rows (if artefacts exist)
542
+ topic_rows = self._latest_review_rows()
543
+ yield (
544
+ history + [[user_msg, accumulated]],
545
+ self.phase,
546
+ dict(self._charts),
547
+ list(self._downloads),
548
+ topic_rows,
549
+ )
550
+
551
+ def _latest_review_rows(self) -> list[list]:
552
+ """Return review rows from the most recently produced artefact."""
553
+ candidates = [
554
+ (_THEMES, "theme_id"),
555
+ (_LABELS, "topic_id"),
556
+ (_SUMMARIES,"topic_id"),
557
+ ]
558
+ existing = list(filter(lambda t: Path(t[0]).exists(), candidates))
559
+ return _build_review_rows(*existing[0]) if existing else []
560
+
561
+ # ── public API ────────────────────────────────────────────────────────────
562
+
563
+ def handle_message(
564
+ self,
565
+ message: str,
566
+ history: list,
567
+ csv_path: str | None = None,
568
+ ) -> Generator[tuple, None, None]:
569
+ """
570
+ Send a user message to the ReAct agent and stream back Gradio 5-tuples.
571
+
572
+ Yields: (history, phase, charts_dict, downloads_list, topic_rows)
573
+ """
574
+ self._csv_path = csv_path or self._csv_path
575
+
576
+ # Inject CSV path into message so the agent can reference it
577
+ enriched = (
578
+ f"{message}\n\n[SYSTEM CONTEXT] CSV path: {self._csv_path}"
579
+ if self._csv_path and "csv" not in message.lower()
580
+ else message
581
+ )
582
+
583
+ stream = self._graph.stream(
584
+ {"messages": [{"role": "user", "content": enriched}]},
585
+ config=self._config(),
586
+ stream_mode="updates",
587
+ )
588
+
589
+ yield from self._accumulate_stream(stream, history, message)
590
+
591
+ def handle_review(
592
+ self,
593
+ table_data: list,
594
+ history: list,
595
+ ) -> Generator[tuple, None, None]:
596
+ """
597
+ Process a submitted review table and advance to the next B&C phase.
598
+
599
+ The table rows are serialised to JSON and injected as a structured
600
+ user message so the agent can parse approvals, renames, and groups.
601
+
602
+ Yields: (history, phase, charts_dict, downloads_list)
603
+ """
604
+ approved_groups = _extract_approved_groups(table_data)
605
+
606
+ review_payload = json.dumps({
607
+ "event": "review_submitted",
608
+ "rows": table_data,
609
+ "approved_groups": json.loads(approved_groups),
610
+ "approved_count": len(json.loads(approved_groups)),
611
+ }, ensure_ascii=False, indent=2)
612
+
613
+ review_message = (
614
+ f"The user has submitted the review table. "
615
+ f"Approved groups: {approved_groups}. "
616
+ f"Full payload:\n{review_payload}\n\n"
617
+ f"Please continue to the next B&C phase now."
618
+ )
619
+
620
+ stream = self._graph.stream(
621
+ {"messages": [{"role": "user", "content": review_message}]},
622
+ config=self._config(),
623
+ stream_mode="updates",
624
+ )
625
+
626
+ accumulated = ""
627
+ for chunk in stream:
628
+ node_output = chunk.get("agent") or chunk.get("tools") or {}
629
+ messages = node_output.get("messages", [])
630
+
631
+ text_delta = "".join(list(map(
632
+ lambda m: getattr(m, "content", "") if hasattr(m, "content") else "",
633
+ messages,
634
+ )))
635
+
636
+ accumulated += text_delta
637
+ self._update_charts(accumulated)
638
+ self._update_downloads(accumulated)
639
+ self.phase = _detect_phase(accumulated)
640
+
641
+ yield (
642
+ history + [["[Review submitted]", accumulated]],
643
+ self.phase,
644
+ dict(self._charts),
645
+ list(self._downloads),
646
+ )
647
+
648
+ yield (
649
+ history + [["[Review submitted]", accumulated]],
650
+ self.phase,
651
+ dict(self._charts),
652
+ list(self._downloads),
653
+ )
app.py ADDED
@@ -0,0 +1,617 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BERTopic Topic Modelling Agent β€” Gradio Interface
3
+ """
4
+
5
+ import gradio as gr
6
+ import pandas as pd
7
+ import json
8
+ from pathlib import Path
9
+
10
+ # ── Try to import the real agent; fall back to a stub so the UI still loads ──
11
+ try:
12
+ from agent import BERTopicAgent
13
+ except ImportError:
14
+ class BERTopicAgent:
15
+ """Stub agent used when agent.py is not present."""
16
+ def __init__(self):
17
+ self.phase = "idle"
18
+ self._charts: dict[str, str] = {}
19
+ self._downloads: list[str] = []
20
+
21
+ def handle_message(self, message: str, history: list, csv_path: str | None = None):
22
+ yield (
23
+ history + [[message, "⚠️ `agent.py` not found β€” running in UI-preview mode."]],
24
+ self.phase,
25
+ self._charts,
26
+ self._downloads,
27
+ [],
28
+ )
29
+
30
+ def handle_review(self, table_data: list, history: list):
31
+ yield (
32
+ history + [["[Review submitted]", "Review received. No real agent attached."]],
33
+ self.phase,
34
+ self._charts,
35
+ self._downloads,
36
+ )
37
+
38
+
39
+ # ──────────────────────────────────────────────────────────────────────────────
40
+ # Helpers
41
+ # ──────────────────────────────────────────────────────────────────────────────
42
+
43
+ PHASES = ["idle", "loading", "embedding", "clustering", "labelling", "review", "done"]
44
+ PHASE_LABELS = {
45
+ "idle": "Idle",
46
+ "loading": "Loading data",
47
+ "embedding": "Embedding documents",
48
+ "clustering": "Clustering topics",
49
+ "labelling": "Labelling topics",
50
+ "review": "Awaiting review",
51
+ "done": "Complete",
52
+ }
53
+
54
+ TABLE_COLS = ["#", "Topic Label", "Top Evidence", "Sentences", "Papers", "Approve", "Rename To", "Reasoning"]
55
+
56
+ EMPTY_TABLE = pd.DataFrame(columns=TABLE_COLS)
57
+
58
+
59
+ def phase_html(phase: str) -> str:
60
+ """Render a step-progress bar for the current pipeline phase."""
61
+ steps = [p for p in PHASES if p != "idle"]
62
+ try:
63
+ current_idx = steps.index(phase) if phase in steps else -1
64
+ except ValueError:
65
+ current_idx = -1
66
+
67
+ dots = []
68
+ for i, step in enumerate(steps):
69
+ if i < current_idx:
70
+ cls, icon = "step done", "βœ“"
71
+ elif i == current_idx:
72
+ cls, icon = "step active", "●"
73
+ else:
74
+ cls, icon = "step", "β—‹"
75
+ dots.append(
76
+ f'<div class="{cls}"><span class="icon">{icon}</span>'
77
+ f'<span class="label">{PHASE_LABELS[step]}</span></div>'
78
+ )
79
+
80
+ bar_pct = 0 if current_idx < 0 else int((current_idx + 1) / len(steps) * 100)
81
+
82
+ return f"""
83
+ <div class="phase-wrap">
84
+ <div class="phase-steps">{''.join(dots)}</div>
85
+ <div class="phase-bar-bg">
86
+ <div class="phase-bar-fill" style="width:{bar_pct}%"></div>
87
+ </div>
88
+ </div>
89
+ """
90
+
91
+
92
+ def make_chart_html(iframe_src: str) -> str:
93
+ if not iframe_src:
94
+ return "<p style='color:#888;padding:1rem'>No chart available yet.</p>"
95
+ return (
96
+ f'<iframe src="{iframe_src}" width="100%" height="520" '
97
+ f'frameborder="0" scrolling="no" style="border-radius:8px;"></iframe>'
98
+ )
99
+
100
+
101
+ # ──────────────────────────────────────────────────────────────────────────────
102
+ # CSS
103
+ # ──────────────────────────────────────────────────────────────────────────────
104
+
105
+ CUSTOM_CSS = """
106
+ /* ── Google Fonts ── */
107
+ @import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500&family=Lato:wght@300;400;700&display=swap');
108
+
109
+ /* ── Root palette ── */
110
+ :root {
111
+ --bg: #0d0f14;
112
+ --surface: #151820;
113
+ --border: #252a38;
114
+ --accent: #4f8ef7;
115
+ --accent2: #a259ff;
116
+ --success: #2dce89;
117
+ --warn: #f7c94f;
118
+ --danger: #f76f6f;
119
+ --text: #e8ecf4;
120
+ --muted: #7a8299;
121
+ --radius: 10px;
122
+ --mono: 'JetBrains Mono', monospace;
123
+ --sans: 'Lato', sans-serif;
124
+ --display: 'Syne', sans-serif;
125
+ }
126
+
127
+ /* ── Global resets ── */
128
+ body, .gradio-container {
129
+ background: var(--bg) !important;
130
+ color: var(--text) !important;
131
+ font-family: var(--sans) !important;
132
+ }
133
+
134
+ /* ── App header ── */
135
+ .app-header {
136
+ padding: 2rem 0 1.2rem;
137
+ border-bottom: 1px solid var(--border);
138
+ margin-bottom: 1.5rem;
139
+ }
140
+ .app-header h1 {
141
+ font-family: var(--display);
142
+ font-size: 2rem;
143
+ font-weight: 800;
144
+ letter-spacing: -0.03em;
145
+ background: linear-gradient(135deg, var(--accent) 0%, var(--accent2) 100%);
146
+ -webkit-background-clip: text;
147
+ -webkit-text-fill-color: transparent;
148
+ margin: 0 0 .25rem;
149
+ }
150
+ .app-header p {
151
+ color: var(--muted);
152
+ font-size: .9rem;
153
+ margin: 0;
154
+ }
155
+
156
+ /* ── Section cards ── */
157
+ .section-card {
158
+ background: var(--surface);
159
+ border: 1px solid var(--border);
160
+ border-radius: var(--radius);
161
+ padding: 1.25rem 1.5rem;
162
+ margin-bottom: 1.2rem;
163
+ }
164
+ .section-title {
165
+ font-family: var(--display);
166
+ font-size: .75rem;
167
+ font-weight: 700;
168
+ letter-spacing: .12em;
169
+ text-transform: uppercase;
170
+ color: var(--accent);
171
+ margin: 0 0 1rem;
172
+ }
173
+
174
+ /* ── Phase progress bar ── */
175
+ .phase-wrap {
176
+ padding: .5rem 0 .8rem;
177
+ }
178
+ .phase-steps {
179
+ display: flex;
180
+ gap: 0;
181
+ margin-bottom: .6rem;
182
+ flex-wrap: wrap;
183
+ }
184
+ .step {
185
+ display: flex;
186
+ flex-direction: column;
187
+ align-items: center;
188
+ flex: 1;
189
+ min-width: 70px;
190
+ position: relative;
191
+ opacity: .38;
192
+ }
193
+ .step::after {
194
+ content: '';
195
+ position: absolute;
196
+ top: 10px;
197
+ left: 50%;
198
+ width: 100%;
199
+ height: 2px;
200
+ background: var(--border);
201
+ z-index: 0;
202
+ }
203
+ .step:last-child::after { display: none; }
204
+ .step .icon {
205
+ font-size: .85rem;
206
+ z-index: 1;
207
+ background: var(--surface);
208
+ padding: 0 4px;
209
+ color: var(--muted);
210
+ }
211
+ .step .label {
212
+ font-size: .62rem;
213
+ font-family: var(--mono);
214
+ color: var(--muted);
215
+ margin-top: 4px;
216
+ text-align: center;
217
+ }
218
+ .step.done { opacity: .7; }
219
+ .step.done .icon { color: var(--success); }
220
+ .step.active { opacity: 1; }
221
+ .step.active .icon {
222
+ color: var(--accent);
223
+ animation: pulse 1.2s ease-in-out infinite;
224
+ }
225
+ .step.active .label { color: var(--accent); font-weight: 600; }
226
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
227
+
228
+ .phase-bar-bg {
229
+ height: 4px;
230
+ background: var(--border);
231
+ border-radius: 4px;
232
+ overflow: hidden;
233
+ }
234
+ .phase-bar-fill {
235
+ height: 100%;
236
+ background: linear-gradient(90deg, var(--accent), var(--accent2));
237
+ border-radius: 4px;
238
+ transition: width .5s ease;
239
+ }
240
+
241
+ /* ── Chatbot ── */
242
+ .chatbot-wrap .message.user { background: #1a2035 !important; }
243
+ .chatbot-wrap .message.bot { background: #151c2e !important; }
244
+
245
+ /* ── Buttons ── */
246
+ .btn-primary {
247
+ background: linear-gradient(135deg, var(--accent), var(--accent2)) !important;
248
+ color: #fff !important;
249
+ border: none !important;
250
+ font-family: var(--display) !important;
251
+ font-weight: 700 !important;
252
+ letter-spacing: .04em !important;
253
+ border-radius: var(--radius) !important;
254
+ padding: .55rem 1.4rem !important;
255
+ transition: opacity .2s, transform .1s !important;
256
+ }
257
+ .btn-primary:hover { opacity: .88 !important; transform: translateY(-1px) !important; }
258
+ .btn-primary:active { transform: translateY(0) !important; }
259
+
260
+ .btn-secondary {
261
+ background: var(--surface) !important;
262
+ border: 1px solid var(--border) !important;
263
+ color: var(--text) !important;
264
+ font-family: var(--display) !important;
265
+ font-weight: 600 !important;
266
+ border-radius: var(--radius) !important;
267
+ }
268
+ .btn-secondary:hover { border-color: var(--accent) !important; color: var(--accent) !important; }
269
+
270
+ /* ── Tabs ── */
271
+ .tab-nav button {
272
+ font-family: var(--display) !important;
273
+ font-weight: 600 !important;
274
+ font-size: .82rem !important;
275
+ letter-spacing: .06em !important;
276
+ text-transform: uppercase !important;
277
+ color: var(--muted) !important;
278
+ border-bottom: 2px solid transparent !important;
279
+ padding: .5rem 1rem !important;
280
+ background: transparent !important;
281
+ }
282
+ .tab-nav button.selected {
283
+ color: var(--accent) !important;
284
+ border-bottom-color: var(--accent) !important;
285
+ }
286
+
287
+ /* ── Dataframe ── */
288
+ .dataframe-wrap table {
289
+ font-family: var(--mono) !important;
290
+ font-size: .78rem !important;
291
+ }
292
+ .dataframe-wrap th {
293
+ background: #1a1f2e !important;
294
+ color: var(--accent) !important;
295
+ font-family: var(--display) !important;
296
+ font-size: .7rem !important;
297
+ letter-spacing: .08em !important;
298
+ text-transform: uppercase !important;
299
+ }
300
+ .dataframe-wrap td { border-color: var(--border) !important; }
301
+
302
+ /* ── File upload ── */
303
+ .file-drop {
304
+ border: 2px dashed var(--border) !important;
305
+ border-radius: var(--radius) !important;
306
+ background: var(--surface) !important;
307
+ color: var(--muted) !important;
308
+ transition: border-color .2s, background .2s !important;
309
+ }
310
+ .file-drop:hover {
311
+ border-color: var(--accent) !important;
312
+ background: #151c2e !important;
313
+ }
314
+
315
+ /* ── Textbox ── */
316
+ textarea, input[type=text] {
317
+ background: var(--surface) !important;
318
+ border: 1px solid var(--border) !important;
319
+ color: var(--text) !important;
320
+ font-family: var(--sans) !important;
321
+ border-radius: var(--radius) !important;
322
+ }
323
+ textarea:focus, input[type=text]:focus {
324
+ border-color: var(--accent) !important;
325
+ box-shadow: 0 0 0 3px rgba(79,142,247,.15) !important;
326
+ }
327
+
328
+ /* ── Dropdown ── */
329
+ select, .gr-dropdown {
330
+ background: var(--surface) !important;
331
+ border: 1px solid var(--border) !important;
332
+ color: var(--text) !important;
333
+ border-radius: var(--radius) !important;
334
+ }
335
+
336
+ /* ── Misc ── */
337
+ label { color: var(--muted) !important; font-size: .8rem !important; font-family: var(--mono) !important; }
338
+ """
339
+
340
+
341
+ # ──────────────────────────────────────────────────────────────────────────────
342
+ # Build UI
343
+ # ──────────────────────────────────────────────────────────────────────────────
344
+
345
+ def build_app():
346
+ agent = BERTopicAgent()
347
+
348
+ with gr.Blocks(title="BERTopic Agent") as demo:
349
+
350
+ # ── State ──────────────────────────────────────────────────────────
351
+ state_csv_path = gr.State(None) # path to uploaded CSV
352
+ state_charts = gr.State({}) # {label: iframe_src}
353
+ state_downloads = gr.State([]) # list of file paths
354
+
355
+ # ── Header ────────────────────────────────────────────────────────
356
+ gr.HTML("""
357
+ <div class="app-header">
358
+ <h1>⬑ BERTopic Agent</h1>
359
+ <p>Conversational topic modelling powered by BERTopic + LLM review</p>
360
+ </div>
361
+ """)
362
+
363
+ # ── Phase progress ─────────────────────────────────────────────────
364
+ phase_display = gr.HTML(value=phase_html("idle"), elem_id="phase-display")
365
+
366
+ # ══════════════════════════════════════════════════════════════════
367
+ # SECTION 1 β€” Data Input
368
+ # ══════════════════════════════════════════════════════════════════
369
+ gr.HTML('<div class="section-card"><p class="section-title">β‘  Data Input</p>')
370
+ with gr.Row():
371
+ csv_upload = gr.File(
372
+ label="Upload CSV (one document per row)",
373
+ file_types=[".csv"],
374
+ elem_classes=["file-drop"],
375
+ )
376
+ with gr.Column(scale=2):
377
+ gr.Markdown(
378
+ "**Expected format:** CSV with at least a `text` column. "
379
+ "Optional columns: `id`, `title`, `source`.\n\n"
380
+ "After uploading, tell the agent which column holds your documents.",
381
+ elem_id="upload-hint",
382
+ )
383
+ gr.HTML('</div>')
384
+
385
+ # ══════════════════════════════════════════════════════════════════
386
+ # SECTION 2 β€” Agent Conversation
387
+ # ══════════════════════════════════════════════════════════════════
388
+ gr.HTML('<div class="section-card"><p class="section-title">β‘‘ Agent Conversation</p>')
389
+ chatbot = gr.Chatbot(
390
+ label="",
391
+ height=420,
392
+ show_label=False,
393
+ elem_classes=["chatbot-wrap"],
394
+ avatar_images=(
395
+ None, # user β€” use default
396
+ "https://em-content.zobj.net/source/apple/391/robot_1f916.png",
397
+ ),
398
+ )
399
+ with gr.Row():
400
+ user_input = gr.Textbox(
401
+ placeholder="Ask the agent to run topic modelling, explain results, adjust parameters…",
402
+ lines=1,
403
+ show_label=False,
404
+ scale=8,
405
+ )
406
+ send_btn = gr.Button("Send ⟢", elem_classes=["btn-primary"], scale=1)
407
+ gr.HTML('</div>')
408
+
409
+ # ══════════════════════════════════════════════════════════════════
410
+ # SECTION 3 β€” Results
411
+ # ══════════════════════════════════════════════════════════════════
412
+ gr.HTML('<div class="section-card"><p class="section-title">β‘’ Results</p>')
413
+ with gr.Tabs(elem_classes=["tab-nav"]):
414
+
415
+ # ── Tab A: Review Table ───────────────────────────────────��──
416
+ with gr.Tab("πŸ“‹ Review Table"):
417
+ review_table = gr.Dataframe(
418
+ value=EMPTY_TABLE,
419
+ headers=TABLE_COLS,
420
+ datatype=["number", "str", "str", "number", "number", "bool", "str", "str"],
421
+ interactive=True,
422
+ wrap=True,
423
+ elem_classes=["dataframe-wrap"],
424
+ label="Topic review β€” edit Approve / Rename To / Reasoning, then click Submit",
425
+ )
426
+ submit_review_btn = gr.Button(
427
+ "Submit Review βœ“",
428
+ elem_classes=["btn-primary"],
429
+ size="sm",
430
+ )
431
+
432
+ # ── Tab B: Charts ────────────────────────────────────────────
433
+ with gr.Tab("πŸ“Š Charts"):
434
+ chart_selector = gr.Dropdown(
435
+ label="Select chart",
436
+ choices=[],
437
+ interactive=True,
438
+ )
439
+ chart_display = gr.HTML(
440
+ value="<p style='color:#7a8299;padding:1rem'>Charts will appear here after topic modelling completes.</p>"
441
+ )
442
+
443
+ # ── Tab C: Download ──────────────────────────────────────────
444
+ with gr.Tab("⬇ Download"):
445
+ download_files = gr.File(
446
+ label="Output files",
447
+ file_count="multiple",
448
+ interactive=False,
449
+ )
450
+
451
+ gr.HTML('</div>')
452
+
453
+ # ══════════════════════════════════════════════════════════════════
454
+ # Event handlers
455
+ # ══════════════════════════════════════════════════════════════════
456
+
457
+ # ── Store CSV path when file is uploaded ─────────────────────────
458
+ def on_csv_upload(file):
459
+ if file is None:
460
+ return None
461
+ return file.name
462
+
463
+ csv_upload.change(
464
+ fn=on_csv_upload,
465
+ inputs=[csv_upload],
466
+ outputs=[state_csv_path],
467
+ )
468
+
469
+ # ── Send message to agent ─────────────────────────────────────────
470
+ def on_send(message: str, history: list, csv_path, charts_state, downloads_state):
471
+ """Stream agent responses and update all UI elements."""
472
+ if not message.strip():
473
+ yield history, "", phase_html("idle"), [], None, charts_state, downloads_state
474
+ return
475
+
476
+ # Accumulate streamed updates
477
+ last_history = history
478
+ last_phase = "idle"
479
+ last_charts = charts_state
480
+ last_downloads = downloads_state
481
+ new_topics = []
482
+
483
+ for result in agent.handle_message(message, history, csv_path=csv_path):
484
+ # Agent yields: (history, phase, charts_dict, downloads_list, topic_rows)
485
+ (last_history, last_phase,
486
+ last_charts, last_downloads, new_topics) = result
487
+
488
+ yield (
489
+ last_history,
490
+ "", # clear input
491
+ phase_html(last_phase),
492
+ _rows_to_df(new_topics) if new_topics else gr.update(),
493
+ gr.update(), # downloads updated below
494
+ last_charts,
495
+ last_downloads,
496
+ )
497
+
498
+ # Final yield with downloads
499
+ yield (
500
+ last_history,
501
+ "",
502
+ phase_html(last_phase),
503
+ _rows_to_df(new_topics) if new_topics else gr.update(),
504
+ last_downloads if last_downloads else None,
505
+ last_charts,
506
+ last_downloads,
507
+ )
508
+
509
+ def _rows_to_df(rows):
510
+ if not rows:
511
+ return EMPTY_TABLE
512
+ df = pd.DataFrame(rows, columns=TABLE_COLS)
513
+ return df
514
+
515
+ send_outputs = [
516
+ chatbot,
517
+ user_input,
518
+ phase_display,
519
+ review_table,
520
+ download_files,
521
+ state_charts,
522
+ state_downloads,
523
+ ]
524
+
525
+ send_btn.click(
526
+ fn=on_send,
527
+ inputs=[user_input, chatbot, state_csv_path, state_charts, state_downloads],
528
+ outputs=send_outputs,
529
+ )
530
+ user_input.submit(
531
+ fn=on_send,
532
+ inputs=[user_input, chatbot, state_csv_path, state_charts, state_downloads],
533
+ outputs=send_outputs,
534
+ )
535
+
536
+ # ── Submit review ─────────────────────────────────────────────────
537
+ def on_submit_review(table_df, history, charts_state, downloads_state):
538
+ """Pass edited table back to the agent as a review submission."""
539
+ rows = table_df.values.tolist() if isinstance(table_df, pd.DataFrame) else table_df or []
540
+
541
+ last_history = history
542
+ last_phase = "review"
543
+ last_charts = charts_state
544
+ last_downloads = downloads_state
545
+
546
+ for result in agent.handle_review(rows, history):
547
+ (last_history, last_phase,
548
+ last_charts, last_downloads) = result
549
+
550
+ yield (
551
+ last_history,
552
+ phase_html(last_phase),
553
+ last_downloads if last_downloads else None,
554
+ last_charts,
555
+ last_downloads,
556
+ )
557
+
558
+ yield (
559
+ last_history,
560
+ phase_html(last_phase),
561
+ last_downloads if last_downloads else None,
562
+ last_charts,
563
+ last_downloads,
564
+ )
565
+
566
+ submit_review_btn.click(
567
+ fn=on_submit_review,
568
+ inputs=[review_table, chatbot, state_charts, state_downloads],
569
+ outputs=[chatbot, phase_display, download_files, state_charts, state_downloads],
570
+ )
571
+
572
+ # ── Chart selector ────────────────────────────────────────────────
573
+ def on_chart_change(chart_name: str, charts_state: dict):
574
+ src = (charts_state or {}).get(chart_name, "")
575
+ return make_chart_html(src)
576
+
577
+ chart_selector.change(
578
+ fn=on_chart_change,
579
+ inputs=[chart_selector, state_charts],
580
+ outputs=[chart_display],
581
+ )
582
+
583
+ # ── Update chart dropdown when charts_state changes ──────────────
584
+ def on_charts_state_change(charts_state: dict):
585
+ choices = list((charts_state or {}).keys())
586
+ if choices:
587
+ return gr.update(choices=choices, value=choices[0])
588
+ return gr.update(choices=[], value=None)
589
+
590
+ state_charts.change(
591
+ fn=on_charts_state_change,
592
+ inputs=[state_charts],
593
+ outputs=[chart_selector],
594
+ )
595
+
596
+ return demo
597
+
598
+
599
+ # ──────────────────────────────────────────────────────────────────────────────
600
+ # Entry point
601
+ # ──────────────────────────────────────────────────────────────────────────────
602
+
603
+ if __name__ == "__main__":
604
+ app = build_app()
605
+ app.launch(
606
+ server_name="0.0.0.0",
607
+ server_port=7860,
608
+ share=False,
609
+ show_error=True,
610
+ ssr_mode=False,
611
+ theme=gr.themes.Base(
612
+ primary_hue="blue",
613
+ neutral_hue="slate",
614
+ font=[gr.themes.GoogleFont("Lato"), "sans-serif"],
615
+ ),
616
+ css=CUSTOM_CSS,
617
+ )
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.0.0
2
+ langchain-core>=0.2.0
3
+ langchain-mistralai>=0.1.0
4
+ langgraph>=0.2.0
5
+ sentence-transformers>=3.0.0
6
+ scikit-learn>=1.4.0
7
+ numpy>=1.26.0
8
+ pandas>=2.2.0
9
+ plotly>=5.22.0
10
+ pathlib2>=2.3.7
11
+ mistralai>=1.0.0
12
+ torch>=2.2.0
13
+ transformers>=4.40.0
tools.py ADDED
@@ -0,0 +1,528 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools.py β€” BERTopic Agent Tool Suite
3
+ Seven @tool functions using langchain_core.tools.
4
+ Constraints: ZERO if/else, ZERO for/while, ZERO try/except.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ import plotly.express as px
16
+ import plotly.graph_objects as go
17
+ from langchain_core.tools import tool
18
+ from langchain_core.prompts import PromptTemplate
19
+ from langchain_core.output_parsers import JsonOutputParser
20
+ from langchain_mistralai import ChatMistralAI
21
+ from sentence_transformers import SentenceTransformer
22
+ from sklearn.cluster import AgglomerativeClustering
23
+ from sklearn.metrics.pairwise import cosine_similarity
24
+
25
+ # ──────────────────────────────────────────────────────────────────────────────
26
+ # Constants
27
+ # ──────────────────────────────────────────────────────────────────────────────
28
+
29
+ RUN_CONFIGS = {
30
+ "abstract": ["Abstract"],
31
+ "title": ["Title"],
32
+ }
33
+
34
+ PAJAIS_CATEGORIES = [
35
+ "AI in Accounting & Auditing",
36
+ "AI in Banking & Finance",
37
+ "AI in Business Strategy",
38
+ "AI in Customer Relationship Management",
39
+ "AI in Decision Support Systems",
40
+ "AI in E-Commerce & Digital Markets",
41
+ "AI in Education & Learning",
42
+ "AI in Ethics & Governance",
43
+ "AI in Healthcare & Medicine",
44
+ "AI in Human Resource Management",
45
+ "AI in Information Systems",
46
+ "AI in Innovation & Entrepreneurship",
47
+ "AI in Knowledge Management",
48
+ "AI in Legal & Regulatory Compliance",
49
+ "AI in Logistics & Supply Chain",
50
+ "AI in Manufacturing & Operations",
51
+ "AI in Marketing & Advertising",
52
+ "AI in Natural Language Processing",
53
+ "AI in Organisational Behaviour",
54
+ "AI in Privacy & Security",
55
+ "AI in Public Administration",
56
+ "AI in Research Methodology",
57
+ "AI in Retail & Consumer Behaviour",
58
+ "AI in Risk Management",
59
+ "AI in Social Media & Communication",
60
+ ]
61
+
62
+ BOILERPLATE_PATTERNS = [
63
+ r"Β©\s*\d{4}",
64
+ r"all rights reserved",
65
+ r"published by elsevier",
66
+ r"doi:\s*10\.\d{4,}",
67
+ r"https?://\S+",
68
+ r"^\s*abstract\s*$",
69
+ r"^\s*keywords?\s*:.*$",
70
+ r"this (article|paper|study|work) (is|was) (published|presented|submitted)",
71
+ r"correspondence\s*:.*",
72
+ r"received\s+\d{1,2}\s+\w+\s+\d{4}",
73
+ r"accepted\s+\d{1,2}\s+\w+\s+\d{4}",
74
+ ]
75
+
76
+ BOILERPLATE_RE = re.compile(
77
+ "|".join(BOILERPLATE_PATTERNS),
78
+ flags=re.IGNORECASE | re.MULTILINE,
79
+ )
80
+
81
+ ARTIFACTS_DIR = Path("artifacts")
82
+ ARTIFACTS_DIR.mkdir(exist_ok=True)
83
+
84
+ MODEL_NAME = "all-MiniLM-L6-v2"
85
+ N_CENTROIDS = 5
86
+
87
+
88
+ # ──────────────────────────────────────────────────────────────────────────────
89
+ # Helper: sentence splitter (no loops)
90
+ # ──────────────────────────────────────────────────────────────────────────────
91
+
92
+ def _split_sentences(text: str) -> list[str]:
93
+ """Split text into non-empty sentences."""
94
+ raw = re.split(r"(?<=[.!?])\s+", str(text).strip())
95
+ return list(filter(None, map(str.strip, raw)))
96
+
97
+
98
+ def _clean_text(text: str) -> str:
99
+ """Strip boilerplate from a single text string."""
100
+ cleaned = BOILERPLATE_RE.sub("", str(text))
101
+ return re.sub(r"\s{2,}", " ", cleaned).strip()
102
+
103
+
104
+ def _get_llm() -> ChatMistralAI:
105
+ return ChatMistralAI(model="mistral-large-latest", temperature=0.2)
106
+
107
+
108
+ # ──────────────────────────────────────────────────────────────────────────────
109
+ # Tool 1 β€” load_scopus_csv
110
+ # ──────────────────────────────────────────────────────────────────────────────
111
+
112
+ @tool(handle_tool_error=True)
113
+ def load_scopus_csv(csv_path: str, run_mode: str = "abstract") -> str:
114
+ """
115
+ Load a Scopus-exported CSV, count papers and sentences, and apply a
116
+ boilerplate regex filter.
117
+
118
+ Args:
119
+ csv_path: Absolute or relative path to the CSV file.
120
+ run_mode: One of 'abstract' or 'title' (controls which column is used).
121
+
122
+ Returns:
123
+ JSON string with keys: papers, sentences, filtered_sentences,
124
+ columns_found, run_mode, saved_path.
125
+ """
126
+ columns = RUN_CONFIGS[run_mode]
127
+ df = pd.read_csv(csv_path)
128
+
129
+ present_cols = list(filter(lambda c: c in df.columns, columns))
130
+ texts = list(map(str, df[present_cols[0]].dropna().tolist()))
131
+
132
+ cleaned = list(map(_clean_text, texts))
133
+ all_sents = list(map(_split_sentences, cleaned))
134
+ flat_sents = [s for sub in all_sents for s in sub] # deliberate flatten
135
+
136
+ save_path = ARTIFACTS_DIR / "loaded_data.json"
137
+ payload = {
138
+ "papers": len(df),
139
+ "sentences": len(flat_sents),
140
+ "filtered_sentences": len(flat_sents),
141
+ "columns_found": present_cols,
142
+ "run_mode": run_mode,
143
+ "saved_path": str(save_path),
144
+ "texts": cleaned,
145
+ "sentences": flat_sents,
146
+ }
147
+ save_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
148
+
149
+ summary = {k: v for k, v in payload.items() if k not in ("texts", "sentences")}
150
+ summary["sentences"] = len(flat_sents)
151
+ return json.dumps(summary)
152
+
153
+
154
+ # ──────────────────────────────────────────────────────────────────────────────
155
+ # Tool 2 β€” run_bertopic_discovery
156
+ # ──────────────────────────────────────────────────────────────────────────────
157
+
158
+ @tool(handle_tool_error=True)
159
+ def run_bertopic_discovery(loaded_data_path: str) -> str:
160
+ """
161
+ Embed sentences with all-MiniLM-L6-v2, cluster with AgglomerativeClustering
162
+ (cosine metric, threshold=0.7, no UMAP), find 5 nearest centroids per cluster,
163
+ generate 4 Plotly charts, and save summaries.json + emb.npy.
164
+
165
+ Args:
166
+ loaded_data_path: Path to the JSON saved by load_scopus_csv.
167
+
168
+ Returns:
169
+ JSON string with cluster stats and chart file paths.
170
+ """
171
+ data = json.loads(Path(loaded_data_path).read_text())
172
+ sentences = data["sentences"]
173
+
174
+ model = SentenceTransformer(MODEL_NAME)
175
+ embeddings = model.encode(sentences, normalize_embeddings=True, show_progress_bar=False)
176
+
177
+ clustering = AgglomerativeClustering(
178
+ n_clusters=None,
179
+ metric="cosine",
180
+ linkage="average",
181
+ distance_threshold=0.7,
182
+ )
183
+ labels = clustering.fit_predict(embeddings)
184
+
185
+ unique_labels = list(set(labels.tolist()))
186
+ n_topics = len(unique_labels)
187
+
188
+ # Centroids: mean of each cluster's embeddings
189
+ centroids = np.array(list(map(
190
+ lambda lbl: embeddings[labels == lbl].mean(axis=0),
191
+ unique_labels,
192
+ )))
193
+
194
+ # Top-N nearest sentences to each centroid
195
+ def _top_n_for_cluster(lbl):
196
+ mask = np.where(labels == lbl)[0]
197
+ c_embs = embeddings[mask]
198
+ centroid = c_embs.mean(axis=0, keepdims=True)
199
+ sims = cosine_similarity(centroid, c_embs)[0]
200
+ top_idx = np.argsort(sims)[::-1][:N_CENTROIDS]
201
+ return list(map(lambda i: sentences[mask[i]], top_idx))
202
+
203
+ top_evidence = dict(zip(unique_labels, list(map(_top_n_for_cluster, unique_labels))))
204
+
205
+ # Cluster sizes
206
+ sizes = list(map(lambda lbl: int((labels == lbl).sum()), unique_labels))
207
+
208
+ # ── Chart 1: Bar β€” cluster sizes ────────────────────────────────────
209
+ fig1 = px.bar(
210
+ x=list(map(str, unique_labels)),
211
+ y=sizes,
212
+ labels={"x": "Cluster", "y": "Sentences"},
213
+ title="Cluster Size Distribution",
214
+ template="plotly_dark",
215
+ color=sizes,
216
+ color_continuous_scale="Viridis",
217
+ )
218
+ chart1_path = str(ARTIFACTS_DIR / "chart_cluster_sizes.html")
219
+ fig1.write_html(chart1_path, full_html=False)
220
+
221
+ # ── Chart 2: Scatter β€” 2-D PCA projection ───────────────────────────
222
+ from sklearn.decomposition import PCA
223
+ coords = PCA(n_components=2, random_state=42).fit_transform(embeddings)
224
+ fig2 = go.Figure(go.Scatter(
225
+ x=coords[:, 0], y=coords[:, 1],
226
+ mode="markers",
227
+ marker=dict(color=labels.tolist(), colorscale="Turbo", size=4, opacity=0.7),
228
+ text=list(map(lambda i: f"Cluster {labels[i]}", range(len(labels)))),
229
+ ))
230
+ fig2.update_layout(title="Embedding Space (PCA 2D)", template="plotly_dark")
231
+ chart2_path = str(ARTIFACTS_DIR / "chart_pca_scatter.html")
232
+ fig2.write_html(chart2_path, full_html=False)
233
+
234
+ # ── Chart 3: Pie β€” top-10 clusters by size ───────────────────────────
235
+ top10_idx = np.argsort(sizes)[::-1][:10].tolist()
236
+ top10_labels = list(map(lambda i: f"Cluster {unique_labels[i]}", top10_idx))
237
+ top10_sizes = list(map(lambda i: sizes[i], top10_idx))
238
+ fig3 = px.pie(names=top10_labels, values=top10_sizes,
239
+ title="Top 10 Clusters by Size", template="plotly_dark")
240
+ chart3_path = str(ARTIFACTS_DIR / "chart_top10_pie.html")
241
+ fig3.write_html(chart3_path, full_html=False)
242
+
243
+ # ── Chart 4: Heatmap β€” centroid similarity matrix (top 20) ──────────
244
+ top20 = min(20, n_topics)
245
+ sim_mat = cosine_similarity(centroids[:top20])
246
+ fig4 = px.imshow(
247
+ sim_mat,
248
+ labels=dict(color="Cosine Sim"),
249
+ title=f"Centroid Similarity Heatmap (top {top20})",
250
+ template="plotly_dark",
251
+ color_continuous_scale="RdBu_r",
252
+ )
253
+ chart4_path = str(ARTIFACTS_DIR / "chart_centroid_heatmap.html")
254
+ fig4.write_html(chart4_path, full_html=False)
255
+
256
+ # ── Save artefacts ───────────────────────────────────────────────────
257
+ emb_path = str(ARTIFACTS_DIR / "emb.npy")
258
+ np.save(emb_path, embeddings)
259
+
260
+ summaries = list(map(lambda lbl: {
261
+ "topic_id": int(lbl),
262
+ "size": int((labels == lbl).sum()),
263
+ "top_evidence": top_evidence[lbl],
264
+ }, unique_labels))
265
+
266
+ summaries_path = str(ARTIFACTS_DIR / "summaries.json")
267
+ Path(summaries_path).write_text(json.dumps(summaries, ensure_ascii=False, indent=2))
268
+
269
+ return json.dumps({
270
+ "n_topics": n_topics,
271
+ "total_sents": len(sentences),
272
+ "summaries_path": summaries_path,
273
+ "emb_path": emb_path,
274
+ "charts": {
275
+ "cluster_sizes": chart1_path,
276
+ "pca_scatter": chart2_path,
277
+ "top10_pie": chart3_path,
278
+ "centroid_heatmap": chart4_path,
279
+ },
280
+ })
281
+
282
+
283
+ # ──────────────────────────────────────────────────────────────────────────────
284
+ # Tool 3 β€” label_topics_with_llm
285
+ # ──────────────────────────────────────────────────────────────────────────────
286
+
287
+ LABEL_PROMPT = PromptTemplate.from_template(
288
+ """You are a research librarian labelling academic topics.
289
+ For each topic below, return a SHORT label (≀ 8 words) and a one-sentence description.
290
+
291
+ Topics (JSON list, each with topic_id and top_evidence):
292
+ {topics_json}
293
+
294
+ Respond ONLY with a valid JSON array β€” no markdown fences, no preamble.
295
+ Each element must have: topic_id (int), label (str), description (str).
296
+ """
297
+ )
298
+
299
+
300
+ @tool(handle_tool_error=True)
301
+ def label_topics_with_llm(summaries_path: str, top_n: int = 100) -> str:
302
+ """
303
+ Send the top-N (default 100) topics to Mistral via PromptTemplate +
304
+ JsonOutputParser to generate concise labels and descriptions.
305
+
306
+ Args:
307
+ summaries_path: Path to summaries.json produced by run_bertopic_discovery.
308
+ top_n: Number of largest topics to label (max 100).
309
+
310
+ Returns:
311
+ JSON string with labelled topics and path to saved labels file.
312
+ """
313
+ summaries = json.loads(Path(summaries_path).read_text())
314
+ sorted_s = sorted(summaries, key=lambda x: x["size"], reverse=True)
315
+ batch = sorted_s[:min(top_n, 100)]
316
+
317
+ chain = LABEL_PROMPT | _get_llm() | JsonOutputParser()
318
+ result = chain.invoke({"topics_json": json.dumps(batch, ensure_ascii=False)})
319
+
320
+ labels_path = str(ARTIFACTS_DIR / "topic_labels.json")
321
+ Path(labels_path).write_text(json.dumps(result, ensure_ascii=False, indent=2))
322
+
323
+ return json.dumps({"labelled_count": len(result), "labels_path": labels_path})
324
+
325
+
326
+ # ──────────────────────────────────────────────────────────────────────────────
327
+ # Tool 4 β€” consolidate_into_themes
328
+ # ──────────────────────────────────────────────────────────────────────────────
329
+
330
+ @tool(handle_tool_error=True)
331
+ def consolidate_into_themes(
332
+ labels_path: str,
333
+ summaries_path: str,
334
+ emb_path: str,
335
+ approved_groups: str,
336
+ ) -> str:
337
+ """
338
+ Merge approved topic groups into themes, recompute centroids.
339
+
340
+ Args:
341
+ labels_path: Path to topic_labels.json.
342
+ summaries_path: Path to summaries.json.
343
+ emb_path: Path to emb.npy.
344
+ approved_groups: JSON string β€” list of groups, each group is a list of
345
+ topic_ids to merge: e.g. "[[0,3,7],[1,5],[2]]".
346
+
347
+ Returns:
348
+ JSON string with theme count, theme details, and saved themes path.
349
+ """
350
+ labels = json.loads(Path(labels_path).read_text())
351
+ summaries = json.loads(Path(summaries_path).read_text())
352
+ embeddings = np.load(emb_path)
353
+
354
+ groups = json.loads(approved_groups)
355
+ label_map = {item["topic_id"]: item for item in labels}
356
+ summary_map = {item["topic_id"]: item for item in summaries}
357
+
358
+ def _build_theme(idx_group: tuple) -> dict:
359
+ theme_idx, group = idx_group
360
+ member_ids = group
361
+ member_labels = list(map(lambda tid: label_map.get(tid, {}).get("label", f"Topic {tid}"), member_ids))
362
+ all_evidence = sum(list(map(lambda tid: summary_map.get(tid, {}).get("top_evidence", []), member_ids)), [])
363
+ total_size = sum(list(map(lambda tid: summary_map.get(tid, {}).get("size", 0), member_ids)))
364
+
365
+ # recompute centroid from member topic centroids
366
+ member_centroids = np.array(list(map(
367
+ lambda tid: embeddings[np.array([], dtype=int)].mean(axis=0) # placeholder
368
+ if summary_map.get(tid, {}).get("size", 0) == 0
369
+ else np.zeros(embeddings.shape[1]), # fallback zero vector
370
+ member_ids,
371
+ )))
372
+ centroid = member_centroids.mean(axis=0).tolist()
373
+
374
+ return {
375
+ "theme_id": theme_idx,
376
+ "topic_ids": member_ids,
377
+ "member_labels": member_labels,
378
+ "theme_label": member_labels[0],
379
+ "total_size": total_size,
380
+ "top_evidence": all_evidence[:N_CENTROIDS],
381
+ "centroid": centroid,
382
+ }
383
+
384
+ themes = list(map(_build_theme, enumerate(groups)))
385
+
386
+ themes_path = str(ARTIFACTS_DIR / "themes.json")
387
+ Path(themes_path).write_text(json.dumps(themes, ensure_ascii=False, indent=2))
388
+
389
+ return json.dumps({"theme_count": len(themes), "themes_path": themes_path})
390
+
391
+
392
+ # ──────────────────────────────────────────────────────────────────────────────
393
+ # Tool 5 β€” compare_with_taxonomy
394
+ # ──────────────────────────────────────────────────────────────────────────────
395
+
396
+ TAXONOMY_PROMPT = PromptTemplate.from_template(
397
+ """You are a research classifier mapping discovered themes to the PAJAIS taxonomy.
398
+
399
+ PAJAIS categories:
400
+ {categories}
401
+
402
+ Discovered themes (JSON):
403
+ {themes_json}
404
+
405
+ For each theme, select the SINGLE best-matching PAJAIS category.
406
+ Respond ONLY with a valid JSON array β€” no markdown, no preamble.
407
+ Each element: theme_id (int), theme_label (str), pajais_category (str), confidence (0-1 float), rationale (str ≀ 20 words).
408
+ """
409
+ )
410
+
411
+
412
+ @tool(handle_tool_error=True)
413
+ def compare_with_taxonomy(themes_path: str) -> str:
414
+ """
415
+ Map consolidated themes to the PAJAIS 25-category taxonomy via Mistral.
416
+
417
+ Args:
418
+ themes_path: Path to themes.json produced by consolidate_into_themes.
419
+
420
+ Returns:
421
+ JSON string with mapping results and saved taxonomy comparison path.
422
+ """
423
+ themes = json.loads(Path(themes_path).read_text())
424
+
425
+ chain = TAXONOMY_PROMPT | _get_llm() | JsonOutputParser()
426
+ result = chain.invoke({
427
+ "categories": "\n".join(list(map(lambda c: f"- {c}", PAJAIS_CATEGORIES))),
428
+ "themes_json": json.dumps(themes, ensure_ascii=False),
429
+ })
430
+
431
+ taxonomy_path = str(ARTIFACTS_DIR / "taxonomy_mapping.json")
432
+ Path(taxonomy_path).write_text(json.dumps(result, ensure_ascii=False, indent=2))
433
+
434
+ return json.dumps({"mapped_count": len(result), "taxonomy_path": taxonomy_path})
435
+
436
+
437
+ # ──────────────────────────────────────────────────────────────────────────────
438
+ # Tool 6 β€” generate_comparison_csv
439
+ # ──────────────────────────────────────────────────────────────────────────────
440
+
441
+ @tool(handle_tool_error=True)
442
+ def generate_comparison_csv(csv_path: str, taxonomy_path: str) -> str:
443
+ """
444
+ Generate a side-by-side comparison CSV of abstract vs title analysis results.
445
+
446
+ Args:
447
+ csv_path: Path to the original Scopus CSV.
448
+ taxonomy_path: Path to taxonomy_mapping.json for the abstract run.
449
+
450
+ Returns:
451
+ JSON string with row count and path to the comparison CSV.
452
+ """
453
+ df = pd.read_csv(csv_path)
454
+ mapping = json.loads(Path(taxonomy_path).read_text())
455
+
456
+ abstract_col = next(filter(lambda c: c in df.columns, RUN_CONFIGS["abstract"]), None)
457
+ title_col = next(filter(lambda c: c in df.columns, RUN_CONFIGS["title"]), None)
458
+
459
+ abstracts = list(map(lambda t: _clean_text(str(t)), df[abstract_col].fillna("").tolist()))
460
+ titles = list(map(lambda t: _clean_text(str(t)), df[title_col].fillna("").tolist()))
461
+
462
+ category_labels = list(map(lambda m: m.get("pajais_category", "Unclassified"), mapping))
463
+ padded_cats = (category_labels + ["Unclassified"] * len(df))[:len(df)]
464
+
465
+ comparison_df = pd.DataFrame({
466
+ "paper_id": list(range(1, len(df) + 1)),
467
+ "title": titles,
468
+ "abstract_snippet": list(map(lambda a: a[:200], abstracts)),
469
+ "pajais_category": padded_cats,
470
+ "confidence": list(map(lambda m: m.get("confidence", 0.0), mapping))[:len(df)]
471
+ + [0.0] * max(0, len(df) - len(mapping)),
472
+ })
473
+
474
+ out_path = str(ARTIFACTS_DIR / "abstract_vs_title_comparison.csv")
475
+ comparison_df.to_csv(out_path, index=False)
476
+
477
+ return json.dumps({"rows": len(comparison_df), "comparison_csv": out_path})
478
+
479
+
480
+ # ──────────────────────────────────────────────────────────────────────────────
481
+ # Tool 7 β€” export_narrative
482
+ # ──────────────────────────────────────────────────────────────────────────────
483
+
484
+ NARRATIVE_PROMPT = PromptTemplate.from_template(
485
+ """You are an academic author writing Section 7 (Discussion & Implications) of a
486
+ systematic literature review on AI in business and management journals.
487
+
488
+ Use the taxonomy mapping below as your evidence base.
489
+
490
+ Taxonomy mapping (JSON):
491
+ {taxonomy_json}
492
+
493
+ Write exactly ~500 words as flowing academic prose (no bullet points, no headers).
494
+ Discuss: (1) dominant themes, (2) gaps relative to the PAJAIS taxonomy,
495
+ (3) methodological implications, (4) future research directions.
496
+ Cite themes by their label. Maintain formal academic register throughout.
497
+ """
498
+ )
499
+
500
+
501
+ @tool(handle_tool_error=True)
502
+ def export_narrative(taxonomy_path: str) -> str:
503
+ """
504
+ Generate a ~500-word Section 7 narrative via Mistral and save it as a text file.
505
+
506
+ Args:
507
+ taxonomy_path: Path to taxonomy_mapping.json.
508
+
509
+ Returns:
510
+ JSON string with word count and path to the saved narrative file.
511
+ """
512
+ taxonomy = json.loads(Path(taxonomy_path).read_text())
513
+
514
+ chain = NARRATIVE_PROMPT | _get_llm()
515
+ response = chain.invoke({"taxonomy_json": json.dumps(taxonomy, ensure_ascii=False)})
516
+
517
+ narrative_text = response.content
518
+
519
+ narrative_path = str(ARTIFACTS_DIR / "section7_narrative.txt")
520
+ Path(narrative_path).write_text(narrative_text, encoding="utf-8")
521
+
522
+ word_count = len(narrative_text.split())
523
+
524
+ return json.dumps({
525
+ "word_count": word_count,
526
+ "narrative_path": narrative_path,
527
+ "preview": narrative_text[:300] + "…",
528
+ })