anujjuna commited on
Commit
a575648
Β·
verified Β·
1 Parent(s): 7a59a8b

Upload 4 files

Browse files
Files changed (4) hide show
  1. agent.py +331 -0
  2. app.py +420 -0
  3. requirements.txt +13 -0
  4. tools.py +571 -0
agent.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agent.py β€” Brain of the BERTopic Agentic AI Application.
3
+ Contains SYSTEM_PROMPT with Braun & Clarke 6-phase workflow, 4 STOP gates,
4
+ and creates LangGraph ReAct agent with MemorySaver.
5
+ Rules: ALL workflow knowledge in prompt. Code is just wiring.
6
+ """
7
+
8
+ import os
9
+ from langchain_mistralai import ChatMistralAI
10
+ from langgraph.prebuilt import create_react_agent
11
+ from langgraph.checkpoint.memory import MemorySaver
12
+ from tools import ALL_TOOLS
13
+
14
+ # ── System Prompt ──────────────────────────────────────────────────────────────
15
+ SYSTEM_PROMPT = """
16
+ You are a computational thematic analysis agent implementing the Braun & Clarke (2006) six-phase
17
+ thematic analysis framework on academic literature from Scopus exports.
18
+
19
+ ═══════════════════════════════════════════════════════════════════════════
20
+ ROLE
21
+ ═══════════════════════════════════════════════════════════════════════════
22
+ You are a senior computational thematic analysis expert with deep knowledge of:
23
+ - Braun & Clarke (2006) six-phase qualitative thematic analysis
24
+ - BERTopic topic modelling with AgglomerativeClustering
25
+ - PAJAIS (Pacific Asia Journal of the Association for Information Systems) taxonomy
26
+ - Academic literature review methodology
27
+
28
+ Your purpose: Guide researchers through a rigorous, reproducible thematic analysis of
29
+ journal literature, ensuring human oversight at every phase.
30
+
31
+ ═══════════════════════════════════════════════════════════════════════════
32
+ CRITICAL RULES β€” NEVER VIOLATE THESE
33
+ ═══════════════════════════════════════════════════════════════════════════
34
+ 1. ONE PHASE PER MESSAGE: Execute exactly one B&C phase per response. Never jump ahead.
35
+ 2. ALL APPROVALS VIA TABLE: Never ask for approval via chat text. Always say "click Submit Review".
36
+ 3. ALWAYS STOP after each phase. Wait for the researcher's next message before proceeding.
37
+ 4. NEVER auto-advance: Do not execute Phase N+1 in the same message as Phase N.
38
+ 5. NEVER skip STOP gates: All 4 STOP gates are mandatory, no exceptions.
39
+ 6. ALWAYS call tools: Never simulate tool output. Always invoke the actual tool.
40
+ 7. NEVER hallucinate data: Only reference what tools actually return.
41
+ 8. ALWAYS be transparent: Explain what you did, what the table shows, what the researcher should do.
42
+ 9. RUN_CONFIGS: abstract = ["Abstract"], title = ["Title"]. Never include Author Keywords.
43
+ 10. MEMORY: You remember all prior messages in this conversation. Use this context.
44
+
45
+ ═══════════════════════════════════════════════════════════════════════════
46
+ YOUR 7 TOOLS
47
+ ═══════════════════════════════════════════════════════════════════════════
48
+
49
+ TOOL 1: load_scopus_csv(filepath)
50
+ - WHEN: Phase 1 β€” as soon as CSV is uploaded or researcher says "analyze CSV"
51
+ - WHAT: Loads CSV, counts papers and sentences, applies 22 boilerplate filters
52
+ - OUTPUT: Paper count, abstract sentences, title sentences, columns, year range
53
+
54
+ TOOL 2: run_bertopic_discovery(run_key, threshold=0.7)
55
+ - WHEN: Phase 2 β€” after researcher says "run abstract" or "run title"
56
+ - WHAT: Embeds sentences (all-MiniLM-L6-v2, 384d), clusters with AgglomerativeClustering
57
+ (metric=cosine, linkage=average, distance_threshold=0.7), NO UMAP,
58
+ finds 5 nearest sentences per centroid, generates 4 Plotly charts
59
+ - OUTPUT: summaries.json + emb.npy + 4 chart HTML files
60
+
61
+ TOOL 3: label_topics_with_llm(run_key)
62
+ - WHEN: Phase 2 β€” immediately after run_bertopic_discovery completes
63
+ - WHAT: Sends top 100 topics to Mistral, gets label/category/confidence/reasoning/niche per topic
64
+ - OUTPUT: labels.json (review table populated)
65
+
66
+ TOOL 4: consolidate_into_themes(run_key, theme_map)
67
+ - WHEN: Phase 3 β€” after researcher submits review table with approved groupings
68
+ - WHAT: Merges approved topic groups, recomputes centroids, recounts sentences/papers
69
+ - OUTPUT: themes.json (consolidated themes)
70
+ - theme_map format: '{"Theme Name": [topic_id1, topic_id2, ...], ...}'
71
+
72
+ TOOL 5: compare_with_taxonomy(run_key)
73
+ - WHEN: Phase 5.5 β€” after researcher approves final theme names
74
+ - WHAT: Maps themes to PAJAIS 25-category taxonomy. Marks unmatched as NOVEL.
75
+ - OUTPUT: taxonomy_map.json (table updates with PAJAIS column)
76
+
77
+ TOOL 6: generate_comparison_csv()
78
+ - WHEN: Phase 6 β€” only after BOTH abstract AND title runs have taxonomy_map.json
79
+ - WHAT: Creates side-by-side comparison of abstract vs title themes
80
+ - OUTPUT: comparison.csv
81
+
82
+ TOOL 7: export_narrative(run_key)
83
+ - WHEN: Phase 6 β€” after researcher confirms comparison.csv via Submit Review
84
+ - WHAT: Generates 500-word Section 7 for conference paper via Mistral
85
+ - OUTPUT: narrative.txt
86
+
87
+ ═══════════════════════════════════════════════════════════════════════════
88
+ BRAUN & CLARKE (2006) SIX-PHASE THEMATIC ANALYSIS β€” FULL WORKFLOW
89
+ ═══════════════════════════════════════════════════════════════════════════
90
+
91
+ ─────────────────────────────────────────────────────────────────────────
92
+ PHASE 1 β€” FAMILIARISATION WITH THE DATA
93
+ ─────────────────────────────────────────────────────────────────────────
94
+ TRIGGER: CSV uploaded or researcher says "analyze CSV" or "start" or "load data"
95
+
96
+ ACTIONS:
97
+ 1. Call load_scopus_csv(filepath) with the uploaded file path.
98
+ 2. Display the returned statistics clearly.
99
+ 3. Explain: "Familiarisation involves reading and re-reading the data to understand its scope
100
+ and content before any coding begins (Braun & Clarke, 2006)."
101
+ 4. Ask researcher to type "run abstract" to begin Phase 2 on abstracts.
102
+
103
+ RESPONSE FORMAT:
104
+ - Show paper count, sentence counts, year range
105
+ - Briefly explain what BERTopic will do in Phase 2
106
+ - End with: "Type **'run abstract'** when ready."
107
+
108
+ β˜… STOP GATE 1 β˜…
109
+ STOP HERE AFTER PHASE 1. Do NOT call any other tool.
110
+ Wait for researcher to type "run abstract" or "run title".
111
+
112
+ ─────────────────────────────────────────────────────────────────────────
113
+ PHASE 2 β€” GENERATING INITIAL CODES
114
+ ─────────────────────────────────────────────────────────────────────────
115
+ TRIGGER: Researcher types "run abstract" or "run title"
116
+
117
+ ACTIONS:
118
+ 1. Call run_bertopic_discovery(run_key="abstract", threshold=0.7)
119
+ [or run_key="title" if researcher specified "run title"]
120
+ 2. Immediately after (in same message), call label_topics_with_llm(run_key=...)
121
+ 3. Tell researcher: The review table now shows all labeled topics.
122
+ 4. Instruct researcher how to use the table:
123
+ - APPROVE column: Enter "yes" to keep, "no" to reject, "merge:X" to merge with topic X
124
+ - RENAME TO column: Enter new name if desired
125
+ - REASONING column: Brief justification for decision
126
+ 5. Explain: "Initial coding systematically labels features of the data relevant to the
127
+ research question (Braun & Clarke, 2006, p. 88)."
128
+
129
+ RESPONSE FORMAT:
130
+ - Confirm topics discovered and sentences clustered
131
+ - Show top 5 topics as examples with their labels and sentence counts
132
+ - Explain what threshold=0.7 means (produces ~100 fine-grained topics)
133
+ - End with: "**Review the table below. Edit Approve/Rename/Reasoning columns, then click Submit Review.**"
134
+
135
+ β˜… STOP GATE 2 (MANDATORY) β˜…
136
+ STOP HERE AFTER PHASE 2. Do NOT proceed to Phase 3 automatically.
137
+ Do NOT consolidate themes. Do NOT call any other tool.
138
+ WAIT for researcher to click Submit Review and send the review table data.
139
+
140
+ ─────────────────────────────────────────────────────────────────────────
141
+ PHASE 3 β€” SEARCHING FOR THEMES
142
+ ─────────────────────────────────────────────────────────────────────────
143
+ TRIGGER: Researcher submits review table (table data appears in message)
144
+
145
+ ACTIONS:
146
+ 1. Parse the researcher's review table decisions from the message.
147
+ 2. Build theme_map from approved topics: group topics with same RENAME TO into themes.
148
+ Example: If topics 0, 1, 5 all have RENAME TO = "AI Tourism", group them.
149
+ 3. Call consolidate_into_themes(run_key=..., theme_map='{"AI Tourism": [0,1,5], ...}')
150
+ 4. Display the consolidated themes with their sentence counts.
151
+ 5. Explain: "Searching for themes involves collating codes into potential themes and gathering
152
+ relevant coded data (Braun & Clarke, 2006, p. 89)."
153
+
154
+ RESPONSE FORMAT:
155
+ - List each consolidated theme: name, topics merged, sentence count
156
+ - Note any rejected topics (Approve=no) that were excluded
157
+ - End with: "**Review the consolidated themes in the table. Click Submit Review to proceed to Phase 4.**"
158
+
159
+ β˜… STOP GATE 3 (MANDATORY) β˜…
160
+ STOP HERE AFTER PHASE 3. Do NOT proceed to Phase 4 automatically.
161
+ Wait for researcher to click Submit Review again.
162
+
163
+ ─────────────────────────────────────────────────────────────────────────
164
+ PHASE 4 β€” REVIEWING THEMES (SATURATION CHECK)
165
+ ─────────────────────────────────────────────────────────────────────────
166
+ TRIGGER: Researcher submits review table after Phase 3
167
+
168
+ ACTIONS:
169
+ 1. Review the themes from themes.json.
170
+ 2. Check for saturation: Do themes adequately cover the data? Are there overlapping themes?
171
+ Are any themes too broad or too narrow?
172
+ 3. Report saturation status based on:
173
+ - Coverage: What % of sentences are captured by themes?
174
+ - Coherence: Do themes have internal consistency?
175
+ - Distinctiveness: Are themes sufficiently different from each other?
176
+ 4. Recommend any merges or splits if needed.
177
+ 5. Explain: "Reviewing themes ensures themes work in relation to the coded extracts and
178
+ the entire dataset (Braun & Clarke, 2006, p. 91)."
179
+
180
+ RESPONSE FORMAT:
181
+ - Report: X themes covering Y sentences (Z% of total)
182
+ - Saturation assessment: ACHIEVED / NEEDS REVISION
183
+ - Specific recommendations if revision needed
184
+ - End with: "**Confirm or adjust themes in the table. Click Submit Review to proceed to Phase 5.**"
185
+
186
+ β˜… STOP GATE 4 (MANDATORY) β˜…
187
+ STOP HERE AFTER PHASE 4. Do NOT proceed to Phase 5 automatically.
188
+ Wait for researcher to click Submit Review.
189
+
190
+ ─────────────────────────────────────────────────────────────────────────
191
+ PHASE 5 β€” DEFINING AND NAMING THEMES
192
+ ─────────────────────────────────────────────────────────────────────────
193
+ TRIGGER: Researcher submits review table after Phase 4
194
+
195
+ ACTIONS:
196
+ 1. Present final theme names and definitions.
197
+ 2. For each theme, provide:
198
+ - Concise name (3-5 words)
199
+ - One-sentence definition capturing the essence
200
+ - Key evidence sentences (from top_sentences)
201
+ 3. Invite researcher to finalise names via the RENAME TO column.
202
+ 4. Explain: "Defining and naming themes involves identifying the 'essence' of each theme
203
+ and determining the aspect of the data each theme captures (Braun & Clarke, 2006, p. 92)."
204
+
205
+ RESPONSE FORMAT:
206
+ - List each theme with proposed name and definition
207
+ - Show 2 evidence sentences per theme
208
+ - End with: "**Edit Rename To column if needed. Click Submit Review to proceed to Phase 5.5 (PAJAIS mapping).**"
209
+
210
+ ─────────────────────────────────────────────────────────────────────────
211
+ PHASE 5.5 β€” PAJAIS TAXONOMY MAPPING
212
+ ─────────────────────────────────────────────────────────────────────────
213
+ TRIGGER: Researcher submits review table after Phase 5
214
+
215
+ ACTIONS:
216
+ 1. Call compare_with_taxonomy(run_key=...)
217
+ 2. The review table's "Top Evidence" column now shows:
218
+ "β†’ PAJAIS: [Category Name] | Confidence: X.XX | [reasoning]" for MAPPED themes
219
+ "β†’ NOVEL | [reason why no category fits]" for NOVEL themes
220
+ 3. Highlight NOVEL themes as potential research contributions.
221
+ 4. Explain the PAJAIS taxonomy and what NOVEL means for publications.
222
+
223
+ RESPONSE FORMAT:
224
+ - Summary: X MAPPED, Y NOVEL themes
225
+ - List NOVEL themes explicitly β€” these are research gaps
226
+ - End with: "**Review PAJAIS mapping in the table. NOVEL themes = publishable research gaps.
227
+ Click Submit Review to proceed to Phase 6 (Report Generation).**"
228
+
229
+ β˜… STOP GATE 5 (MANDATORY) β˜…
230
+ STOP HERE AFTER PHASE 5.5. Do NOT proceed to Phase 6 automatically.
231
+ Wait for researcher to click Submit Review.
232
+
233
+ ─────────────────────────────────────────────────────────────────────────
234
+ PHASE 6 β€” PRODUCING THE REPORT
235
+ ─────────────────────────────────────────────────────────────────────────
236
+ TRIGGER: Researcher submits review table after Phase 5.5
237
+
238
+ ACTIONS:
239
+ Step 6a β€” Comparison CSV:
240
+ 1. Check if BOTH abstract and title taxonomy_map.json files exist.
241
+ 2. If both exist: Call generate_comparison_csv()
242
+ 3. If only one run complete: Inform researcher which run is missing.
243
+ 4. End with: "**Check Download tab for comparison.csv. Click Submit Review to generate narrative.**"
244
+
245
+ Step 6b β€” Narrative (after researcher confirms):
246
+ 5. Call export_narrative(run_key=...) for the current run.
247
+ 6. Congratulate researcher on completing the analysis.
248
+ 7. List all downloadable files in the Download tab.
249
+
250
+ RESPONSE FORMAT:
251
+ - Confirm comparison.csv is ready (if both runs complete)
252
+ - Confirm narrative.txt is generated
253
+ - List all output files: comparison.csv, abstract_taxonomy_map.json,
254
+ title_taxonomy_map.json, abstract_narrative.txt, title_narrative.txt
255
+ - End with: "**Download all files from the Download tab for your conference paper Section 7.**"
256
+
257
+ ═══════════════════════════════════════════════════════════════════════════
258
+ STOP GATE SUMMARY (4 Mandatory Gates)
259
+ ═══════════════════════════════════════════════════════════════════════════
260
+ Gate 1 β†’ After Phase 1 (Load): Wait for "run abstract" or "run title"
261
+ Gate 2 β†’ After Phase 2 (Codes): Wait for Submit Review (researcher approves topics)
262
+ Gate 3 β†’ After Phase 3 (Themes): Wait for Submit Review (researcher confirms themes)
263
+ Gate 4 β†’ After Phase 4 (Saturation): Wait for Submit Review (researcher confirms saturation)
264
+ Gate 5 β†’ After Phase 5.5 (PAJAIS): Wait for Submit Review (researcher reviews taxonomy)
265
+
266
+ ALL FIVE GATES ARE MANDATORY. Skipping any gate violates the researcher-in-the-loop principle.
267
+
268
+ ═══════════════════════════════════════════════════════════════════════════
269
+ ERROR HANDLING GUIDANCE
270
+ ═══════════════════════════════════════════════════════════════════════════
271
+ If a tool returns an error:
272
+ 1. Read the error message carefully.
273
+ 2. Diagnose the likely cause (missing file, wrong key, API issue).
274
+ 3. Explain the error to the researcher in plain language.
275
+ 4. Suggest a corrective action (e.g., re-upload CSV, retry, check API key).
276
+ 5. Do NOT crash. Do NOT give up. Adapt strategy.
277
+
278
+ If theme_map parsing fails:
279
+ - Ask researcher to re-submit the review table clearly.
280
+ - Provide an example of valid approve/rename instructions.
281
+
282
+ ═══════════════════════════════════════════════════════════════════════════
283
+ TONE AND COMMUNICATION STYLE
284
+ ═══════════════════════════════════════════════════════════════════════════
285
+ - Professional yet approachable
286
+ - Reference Braun & Clarke (2006) when explaining phases
287
+ - Use clear section headers in responses (Phase X β€” Name)
288
+ - Use emojis sparingly for visual cues (βœ… ⬜ πŸ”’ πŸ“Š 🏷️)
289
+ - Always end with a clear call-to-action for the researcher
290
+ - Never use jargon without explanation
291
+ """
292
+
293
+ # ── Agent Factory ──────────────────────────────────────────────────────────────
294
+
295
+ def create_agent():
296
+ """Create and return the LangGraph ReAct agent with Mistral LLM and MemorySaver."""
297
+ llm = ChatMistralAI(
298
+ model="mistral-small-latest",
299
+ api_key=os.environ.get("MISTRAL_API_KEY", ""),
300
+ temperature=0.1,
301
+ )
302
+ memory = MemorySaver()
303
+ agent = create_react_agent(
304
+ llm,
305
+ ALL_TOOLS,
306
+ prompt=SYSTEM_PROMPT,
307
+ checkpointer=memory,
308
+ )
309
+ return agent
310
+
311
+
312
+ # Singleton agent instance
313
+ _agent = None
314
+
315
+
316
+ def get_agent():
317
+ """Return singleton agent instance (created once on first call)."""
318
+ global _agent
319
+ _agent = _agent or create_agent()
320
+ return _agent
321
+
322
+
323
+ def invoke_agent(message: str, thread_id: str = "default") -> str:
324
+ """Invoke the agent with a user message and return its response text.
325
+ thread_id: conversation thread identifier for memory isolation."""
326
+ agent = get_agent()
327
+ config = {"configurable": {"thread_id": thread_id}}
328
+ result = agent.invoke({"messages": [("user", message)]}, config=config)
329
+ messages = result.get("messages", [])
330
+ last = messages[-1] if messages else None
331
+ return last.content if last and hasattr(last, "content") else str(last)
app.py ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py β€” Gradio UI for BERTopic Agentic AI Application (~370 lines)
3
+ Sections: β‘  Data Input β‘‘ Agent Conversation β‘’ Results (Table | Charts | Download)
4
+ Rules: ZERO business logic here. All decisions made by agent.py.
5
+ """
6
+
7
+ import os
8
+ import json
9
+ import glob
10
+ import gradio as gr
11
+ from agent import invoke_agent
12
+
13
+ CHECKPOINT_DIR = "checkpoints"
14
+ os.makedirs(CHECKPOINT_DIR, exist_ok=True)
15
+
16
+ CSV_PATH = os.path.join(CHECKPOINT_DIR, "uploaded.csv")
17
+
18
+ # ── Checkpoint file paths ──────────────────────────────────────────────────────
19
+ def ckpt(name):
20
+ return os.path.join(CHECKPOINT_DIR, name)
21
+
22
+
23
+ # ── Phase progress HTML ────────────────────────────────────────────────────────
24
+ def build_phase_bar():
25
+ phases = [
26
+ ("β‘  Load", "stats.json"),
27
+ ("β‘‘ Codes", "abstract_labels.json"),
28
+ ("β‘’ Themes", "abstract_themes.json"),
29
+ ("β‘£ Saturation", "abstract_themes.json"),
30
+ ("β‘€ Names", "abstract_themes.json"),
31
+ ("β‘€Β½ PAJAIS", "abstract_taxonomy_map.json"),
32
+ ("β‘₯ Report", "comparison.csv"),
33
+ ]
34
+ items = list(map(
35
+ lambda p: (
36
+ f'<div style="display:inline-flex;align-items:center;gap:6px;'
37
+ f'padding:6px 14px;border-radius:20px;font-size:13px;font-weight:600;'
38
+ f'background:{"#22c55e" if os.path.exists(ckpt(p[1])) else "#374151"};'
39
+ f'color:{"#fff" if os.path.exists(ckpt(p[1])) else "#9ca3af"};">'
40
+ f'{"βœ…" if os.path.exists(ckpt(p[1])) else "⬜"} {p[0]}</div>'
41
+ ),
42
+ phases,
43
+ ))
44
+ bar = (
45
+ '<div style="background:#111827;padding:12px 16px;border-radius:12px;'
46
+ 'border:1px solid #1f2937;display:flex;flex-wrap:wrap;gap:8px;align-items:center;">'
47
+ '<span style="color:#6b7280;font-size:12px;font-weight:700;margin-right:4px;">B&amp;C PHASES:</span>'
48
+ + "".join(items)
49
+ + "</div>"
50
+ )
51
+ return bar
52
+
53
+
54
+ # ── Review table loading ───────────────────────────────────────────────────────
55
+ def load_review_table():
56
+ """Priority: taxonomy_map β†’ themes β†’ labels β†’ summaries"""
57
+ priority = [
58
+ ("abstract_taxonomy_map.json", "taxonomy"),
59
+ ("abstract_themes.json", "themes"),
60
+ ("abstract_labels.json", "labels"),
61
+ ("abstract_summaries.json", "summaries"),
62
+ ]
63
+ for filename, mode in priority:
64
+ path = ckpt(filename)
65
+ if os.path.exists(path):
66
+ with open(path) as f:
67
+ data = json.load(f)
68
+ return _format_table(data, mode)
69
+ return _empty_table()
70
+
71
+
72
+ def _empty_table():
73
+ return [[None] * 8]
74
+
75
+
76
+ def _format_table(data, mode):
77
+ rows = list(map(lambda item: _format_row(item, mode), data))
78
+ return rows if rows else _empty_table()
79
+
80
+
81
+ def _format_row(item, mode):
82
+ idx = item.get("topic_id", item.get("name", ""))
83
+ label = item.get("label", item.get("name", ""))
84
+
85
+ if mode == "taxonomy":
86
+ evidence = (
87
+ f"β†’ {item.get('pajais_match', 'NOVEL')} "
88
+ f"| conf: {item.get('match_confidence', 0):.2f} "
89
+ f"| {item.get('reasoning', '')}"
90
+ )
91
+ else:
92
+ sentences = item.get("top_sentences", [])
93
+ evidence = sentences[0] if sentences else ""
94
+
95
+ sentences_count = item.get("sentence_count", len(item.get("top_sentences", [])))
96
+ papers = item.get("paper_count", "")
97
+ approve = item.get("approve", "yes")
98
+ rename = item.get("rename_to", label)
99
+ reasoning = item.get("reasoning", "")
100
+
101
+ return [idx, label, evidence, sentences_count, papers, approve, rename, reasoning]
102
+
103
+
104
+ # ── Chart list ────────────────────────────────────────────────────────────────
105
+ def get_chart_choices():
106
+ chart_files = glob.glob(ckpt("*_chart_*.html"))
107
+ choices = list(map(
108
+ lambda f: os.path.basename(f).replace("_", " ").replace(".html", "").title(),
109
+ chart_files,
110
+ ))
111
+ return choices if choices else ["No charts yet"]
112
+
113
+
114
+ def load_chart_html(choice):
115
+ if not choice or choice == "No charts yet":
116
+ return "<p style='color:#6b7280;padding:20px;'>Charts appear after Phase 2 analysis.</p>"
117
+ filename = choice.lower().replace(" ", "_") + ".html"
118
+ path = ckpt(filename)
119
+ if os.path.exists(path):
120
+ with open(path) as f:
121
+ content = f.read()
122
+ return f'<iframe srcdoc="{content.replace(chr(34), "&quot;")}" width="100%" height="600px" frameborder="0"></iframe>'
123
+ return "<p style='color:#ef4444;'>Chart file not found.</p>"
124
+
125
+
126
+ # ── Download file list ─────────────────────��───────────────────────────────────
127
+ def get_download_files():
128
+ patterns = [
129
+ "*.csv", "*.json", "*.txt", "*.npy",
130
+ ]
131
+ files = []
132
+ list(map(lambda p: files.extend(glob.glob(ckpt(p))), patterns))
133
+ files.sort(key=os.path.getmtime, reverse=True)
134
+ return files if files else None
135
+
136
+
137
+ # ── Table-to-theme-map parser ──────────────────────────────────────────────────
138
+ def parse_table_to_message(table_data):
139
+ """Convert review table edits into a structured message for the agent."""
140
+ if not table_data:
141
+ return "Submit Review: No table data provided."
142
+
143
+ approved = list(filter(lambda row: len(row) >= 6 and str(row[5]).lower() in ("yes", "y", "1", "true"), table_data))
144
+ rejected = list(filter(lambda row: len(row) >= 6 and str(row[5]).lower() in ("no", "n", "0", "false"), table_data))
145
+
146
+ theme_groups = {}
147
+ list(map(
148
+ lambda row: theme_groups.setdefault(str(row[6]) if len(row) > 6 and row[6] else str(row[1]), []).append(int(row[0]) if str(row[0]).isdigit() else row[0]),
149
+ approved,
150
+ ))
151
+
152
+ theme_map_str = json.dumps(theme_groups)
153
+
154
+ msg = (
155
+ f"Submit Review received.\n\n"
156
+ f"Approved topics: {len(approved)}\n"
157
+ f"Rejected topics: {len(rejected)}\n\n"
158
+ f"Theme groupings (RENAME TO β†’ [topic_ids]):\n{theme_map_str}\n\n"
159
+ f"Researcher reasoning summary:\n"
160
+ + "\n".join(list(map(
161
+ lambda row: f" - Topic {row[0]} ({row[1]}): {row[7]}" if len(row) > 7 and row[7] else "",
162
+ approved,
163
+ )))
164
+ + "\n\nPlease proceed to the next phase based on these decisions."
165
+ )
166
+ return msg
167
+
168
+
169
+ # ── Main Gradio App ────────────────────────────────────────────────────────────
170
+ def build_app():
171
+ with gr.Blocks(
172
+ title="BERTopic Thematic Analysis Agent",
173
+ theme=gr.themes.Base(
174
+ primary_hue="emerald",
175
+ secondary_hue="slate",
176
+ neutral_hue="slate",
177
+ font=[gr.themes.GoogleFont("IBM Plex Mono"), "monospace"],
178
+ ),
179
+ css="""
180
+ body { background: #0a0f1a !important; }
181
+ .gradio-container { max-width: 1400px !important; background: #0a0f1a !important; }
182
+ .section-box {
183
+ border: 1px solid #1e293b;
184
+ border-radius: 16px;
185
+ padding: 20px;
186
+ background: #0f172a;
187
+ margin-bottom: 16px;
188
+ }
189
+ .section-header {
190
+ font-size: 13px;
191
+ font-weight: 700;
192
+ color: #64748b;
193
+ letter-spacing: 0.12em;
194
+ text-transform: uppercase;
195
+ margin-bottom: 12px;
196
+ padding-bottom: 8px;
197
+ border-bottom: 1px solid #1e293b;
198
+ }
199
+ .gr-button-primary {
200
+ background: #10b981 !important;
201
+ border: none !important;
202
+ font-weight: 700 !important;
203
+ }
204
+ .gr-button-secondary {
205
+ background: #1e293b !important;
206
+ border: 1px solid #334155 !important;
207
+ color: #94a3b8 !important;
208
+ }
209
+ footer { display: none !important; }
210
+ """,
211
+ ) as app:
212
+
213
+ # ── Header ──────────────────────────────────────────────────────────
214
+ gr.HTML("""
215
+ <div style="text-align:center;padding:32px 0 16px;background:linear-gradient(180deg,#0f172a 0%,#0a0f1a 100%);">
216
+ <div style="font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:0.3em;
217
+ color:#10b981;text-transform:uppercase;margin-bottom:8px;">
218
+ Braun &amp; Clarke (2006) Β· BERTopic Β· PAJAIS Taxonomy
219
+ </div>
220
+ <h1 style="font-family:'IBM Plex Mono',monospace;font-size:28px;font-weight:700;
221
+ color:#f1f5f9;margin:0 0 8px;">
222
+ Thematic Analysis Agent
223
+ </h1>
224
+ <p style="color:#475569;font-size:14px;margin:0;">
225
+ Agentic AI Β· LangGraph Β· Mistral LLM Β· AgglomerativeClustering (cosine, 384d)
226
+ </p>
227
+ </div>
228
+ """)
229
+
230
+ # Phase progress bar
231
+ phase_bar = gr.HTML(value=build_phase_bar(), label="Phase Progress")
232
+
233
+ # ── SECTION 1: Data Input ────────────────────────────────────────────
234
+ gr.HTML('<div class="section-header">β‘  DATA INPUT</div>')
235
+ with gr.Row():
236
+ csv_upload = gr.File(
237
+ label="Upload Scopus CSV Export",
238
+ file_types=[".csv"],
239
+ scale=2,
240
+ )
241
+ with gr.Column(scale=1):
242
+ gr.HTML("""
243
+ <div style="background:#1e293b;border-radius:12px;padding:16px;font-size:13px;color:#94a3b8;">
244
+ <b style="color:#f1f5f9;">Required CSV Columns:</b><br>
245
+ Authors Β· Title Β· Abstract<br>
246
+ Author Keywords Β· Cited by<br>
247
+ Source title Β· Year
248
+ </div>
249
+ """)
250
+
251
+ # ── SECTION 2: Agent Conversation ───────────────────────────────────
252
+ gr.HTML('<div class="section-header">β‘‘ AGENT CONVERSATION</div>')
253
+ chatbot = gr.Chatbot(
254
+ label="Thematic Analysis Agent",
255
+ height=500,
256
+ bubble_full_width=False,
257
+ show_copy_button=True,
258
+ render_markdown=True,
259
+ avatar_images=(None, "https://www.anthropic.com/favicon.ico"),
260
+ type="messages",
261
+ )
262
+ with gr.Row():
263
+ user_input = gr.Textbox(
264
+ placeholder="Type 'run abstract', 'run title', or any instruction...",
265
+ label="",
266
+ scale=5,
267
+ lines=1,
268
+ container=False,
269
+ )
270
+ send_btn = gr.Button("Send β–Ά", variant="primary", scale=1)
271
+
272
+ # ── SECTION 3: Results ───────────────────────────────────────────────
273
+ gr.HTML('<div class="section-header">β‘’ RESULTS</div>')
274
+ with gr.Tabs():
275
+
276
+ # Tab 1: Review Table
277
+ with gr.TabItem("πŸ“‹ Review Table"):
278
+ gr.HTML("""
279
+ <p style="color:#94a3b8;font-size:13px;margin-bottom:8px;">
280
+ Edit <b>Approve</b> (yes/no), <b>Rename To</b>, and <b>Reasoning</b> columns.
281
+ Then click <b>Submit Review</b> to send decisions to the agent.
282
+ </p>
283
+ """)
284
+ review_table = gr.Dataframe(
285
+ headers=["#", "Topic Label", "Top Evidence", "Sentences", "Papers", "Approve", "Rename To", "Reasoning"],
286
+ datatype=["str", "str", "str", "number", "str", "str", "str", "str"],
287
+ row_count=(10, "dynamic"),
288
+ col_count=(8, "fixed"),
289
+ interactive=True,
290
+ wrap=True,
291
+ label="",
292
+ )
293
+ submit_review_btn = gr.Button("πŸ“€ Submit Review β†’", variant="primary")
294
+
295
+ # Tab 2: Charts
296
+ with gr.TabItem("πŸ“Š Charts"):
297
+ chart_dropdown = gr.Dropdown(
298
+ choices=get_chart_choices(),
299
+ label="Select Chart",
300
+ interactive=True,
301
+ )
302
+ refresh_charts_btn = gr.Button("πŸ”„ Refresh Chart List", variant="secondary", size="sm")
303
+ chart_display = gr.HTML(
304
+ value="<p style='color:#6b7280;padding:20px;'>Charts appear after Phase 2 BERTopic analysis.</p>"
305
+ )
306
+
307
+ # Tab 3: Downloads
308
+ with gr.TabItem("πŸ“₯ Download Files"):
309
+ gr.HTML("""
310
+ <p style="color:#94a3b8;font-size:13px;margin-bottom:8px;">
311
+ All checkpoint files are listed below. Download for your conference paper.
312
+ </p>
313
+ """)
314
+ download_files = gr.File(
315
+ label="Output Files",
316
+ file_count="multiple",
317
+ interactive=False,
318
+ )
319
+ refresh_downloads_btn = gr.Button("πŸ”„ Refresh Files", variant="secondary", size="sm")
320
+
321
+ # ── State ─────────────────────────────────────────────────────────────
322
+ thread_state = gr.State("default")
323
+
324
+ # ── Event: CSV Upload ─────────────────────────────────────────────────
325
+ def on_csv_upload(file, history, thread_id):
326
+ if file is None:
327
+ return history, build_phase_bar(), load_review_table()
328
+ msg = f"Analyze my Scopus CSV: {file.name}"
329
+ history = history or []
330
+ history.append({"role": "user", "content": msg})
331
+ response = invoke_agent(f"load_scopus_csv filepath={file.name}", thread_id)
332
+ history.append({"role": "assistant", "content": response})
333
+ return history, build_phase_bar(), load_review_table()
334
+
335
+ csv_upload.upload(
336
+ on_csv_upload,
337
+ inputs=[csv_upload, chatbot, thread_state],
338
+ outputs=[chatbot, phase_bar, review_table],
339
+ )
340
+
341
+ # ── Event: Send message ───────────────────────────────────────────────
342
+ def on_send(message, history, thread_id):
343
+ if not message.strip():
344
+ return history, "", build_phase_bar(), load_review_table()
345
+ history = history or []
346
+ history.append({"role": "user", "content": message})
347
+ response = invoke_agent(message, thread_id)
348
+ history.append({"role": "assistant", "content": response})
349
+ return history, "", build_phase_bar(), load_review_table()
350
+
351
+ send_btn.click(
352
+ on_send,
353
+ inputs=[user_input, chatbot, thread_state],
354
+ outputs=[chatbot, user_input, phase_bar, review_table],
355
+ )
356
+ user_input.submit(
357
+ on_send,
358
+ inputs=[user_input, chatbot, thread_state],
359
+ outputs=[chatbot, user_input, phase_bar, review_table],
360
+ )
361
+
362
+ # ── Event: Submit Review ──────────────────────────────────────────────
363
+ def on_submit_review(table_data, history, thread_id):
364
+ msg = parse_table_to_message(table_data)
365
+ history = history or []
366
+ history.append({"role": "user", "content": "πŸ“€ Submit Review (table decisions sent to agent)"})
367
+ response = invoke_agent(msg, thread_id)
368
+ history.append({"role": "assistant", "content": response})
369
+ return history, build_phase_bar(), load_review_table()
370
+
371
+ submit_review_btn.click(
372
+ on_submit_review,
373
+ inputs=[review_table, chatbot, thread_state],
374
+ outputs=[chatbot, phase_bar, review_table],
375
+ )
376
+
377
+ # ── Event: Chart selection ────────────────────────────────────────────
378
+ chart_dropdown.change(
379
+ load_chart_html,
380
+ inputs=[chart_dropdown],
381
+ outputs=[chart_display],
382
+ )
383
+
384
+ def refresh_charts():
385
+ choices = get_chart_choices()
386
+ return gr.update(choices=choices, value=choices[0] if choices else None)
387
+
388
+ refresh_charts_btn.click(
389
+ refresh_charts,
390
+ outputs=[chart_dropdown],
391
+ )
392
+
393
+ # ── Event: Download refresh ───────────────────────────────────────────
394
+ def refresh_downloads():
395
+ files = get_download_files()
396
+ return gr.update(value=files)
397
+
398
+ refresh_downloads_btn.click(
399
+ refresh_downloads,
400
+ outputs=[download_files],
401
+ )
402
+
403
+ # ── Initial load ──────────────────────────────────────────────────────
404
+ app.load(
405
+ lambda: (build_phase_bar(), load_review_table(), get_download_files()),
406
+ outputs=[phase_bar, review_table, download_files],
407
+ )
408
+
409
+ return app
410
+
411
+
412
+ # ── Launch ─────────────────────────────────────────────────────────────────────
413
+ if __name__ == "__main__":
414
+ demo = build_app()
415
+ demo.launch(
416
+ server_name="0.0.0.0",
417
+ server_port=7860,
418
+ ssr_mode=False,
419
+ share=False,
420
+ )
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio
2
+ langchain-core
3
+ langchain-mistralai
4
+ langgraph
5
+ sentence-transformers
6
+ scikit-learn
7
+ bertopic
8
+ plotly
9
+ numpy
10
+ pandas
11
+ hdbscan
12
+ umap-learn
13
+ pynndescent
tools.py ADDED
@@ -0,0 +1,571 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools.py β€” 7 @tool functions for BERTopic Agentic AI Application
3
+ Rules: ZERO if/else, ZERO for/while, ZERO try/except. All decisions by LLM.
4
+ """
5
+
6
+ import os
7
+ import json
8
+ import re
9
+ import numpy as np
10
+ import pandas as pd
11
+ import plotly.graph_objects as go
12
+ import plotly.express as px
13
+ from plotly.subplots import make_subplots
14
+ from langchain_core.tools import tool
15
+ from sentence_transformers import SentenceTransformer
16
+ from sklearn.cluster import AgglomerativeClustering
17
+ from sklearn.metrics.pairwise import cosine_similarity
18
+ from langchain_core.prompts import PromptTemplate
19
+ from langchain_core.output_parsers import JsonOutputParser
20
+ from langchain_mistralai import ChatMistralAI
21
+
22
+ # ── Constants ──────────────────────────────────────────────────────────────────
23
+ NEAREST_K = 5
24
+ MAX_LABEL_TOPICS = 100
25
+ CHECKPOINT_DIR = "checkpoints"
26
+ os.makedirs(CHECKPOINT_DIR, exist_ok=True)
27
+
28
+ RUN_CONFIGS = {
29
+ "abstract": ["Abstract"],
30
+ "title": ["Title"],
31
+ }
32
+
33
+ BOILERPLATE_PATTERNS = [
34
+ r"Β©\s*\d{4}.*",
35
+ r"All rights reserved.*",
36
+ r"Published by Elsevier.*",
37
+ r"doi:.*",
38
+ r"http[s]?://\S+",
39
+ r"www\.\S+",
40
+ r"This article is.*",
41
+ r"Please cite.*",
42
+ r"Correspondence to.*",
43
+ r"E-mail address.*",
44
+ r"Received \d+.*",
45
+ r"Accepted \d+.*",
46
+ r"Available online.*",
47
+ r"Keywords:.*",
48
+ r"Abstract\.?\s*$",
49
+ r"^\s*\d+\s*$",
50
+ r"Springer.*",
51
+ r"Taylor & Francis.*",
52
+ r"Wiley.*",
53
+ r"IEEE.*",
54
+ r"ACM.*",
55
+ r"Sage Publications.*",
56
+ ]
57
+
58
+ PAJAIS_CATEGORIES = [
59
+ "1. Smart Tourism Technologies",
60
+ "2. AI and Machine Learning in Tourism",
61
+ "3. Big Data Analytics in Hospitality",
62
+ "4. Social Media and User-Generated Content",
63
+ "5. Mobile Technologies and Applications",
64
+ "6. Blockchain in Travel and Tourism",
65
+ "7. Internet of Things in Hospitality",
66
+ "8. Robotics and Automation",
67
+ "9. Augmented and Virtual Reality",
68
+ "10. Revenue Management and Pricing",
69
+ "11. Customer Experience and Satisfaction",
70
+ "12. Online Reviews and Reputation Management",
71
+ "13. Digital Marketing and e-Commerce",
72
+ "14. Sharing Economy Platforms",
73
+ "15. Destination Management Systems",
74
+ "16. Sustainable and Green Technologies",
75
+ "17. Crisis Management and Resilience",
76
+ "18. Human-Computer Interaction",
77
+ "19. Recommendation Systems",
78
+ "20. Natural Language Processing in Tourism",
79
+ "21. Computer Vision in Hospitality",
80
+ "22. Cybersecurity and Privacy",
81
+ "23. Supply Chain and Logistics",
82
+ "24. Accessibility and Inclusive Technology",
83
+ "25. Metaverse and Immersive Experiences",
84
+ ]
85
+
86
+ CSV_PATH = os.path.join(CHECKPOINT_DIR, "uploaded.csv")
87
+
88
+
89
+ def _ckpt(name):
90
+ return os.path.join(CHECKPOINT_DIR, name)
91
+
92
+
93
+ def _llm():
94
+ return ChatMistralAI(
95
+ model="mistral-small-latest",
96
+ api_key=os.environ.get("MISTRAL_API_KEY", ""),
97
+ temperature=0.1,
98
+ )
99
+
100
+
101
+ def _clean_sentence(s):
102
+ cleaned = s.strip()
103
+ cleaned = re.sub("|".join(BOILERPLATE_PATTERNS), "", cleaned, flags=re.IGNORECASE)
104
+ return cleaned.strip()
105
+
106
+
107
+ def _split_sentences(text):
108
+ from nltk.tokenize import sent_tokenize
109
+ import nltk
110
+ nltk.download("punkt", quiet=True)
111
+ nltk.download("punkt_tab", quiet=True)
112
+ sentences = sent_tokenize(str(text))
113
+ cleaned = list(map(_clean_sentence, sentences))
114
+ return list(filter(lambda s: len(s) > 20, cleaned))
115
+
116
+
117
+ # ── Tool 1: load_scopus_csv ────────────────────────────────────────────────────
118
+ @tool(handle_tool_error=True)
119
+ def load_scopus_csv(filepath: str) -> str:
120
+ """Load a Scopus CSV export, count papers and sentences, apply boilerplate filtering.
121
+ Returns stats string with paper count, abstract sentence count, title sentence count.
122
+ filepath: path to the uploaded CSV file."""
123
+ df = pd.read_csv(filepath, encoding="utf-8-8-sig")
124
+ df.to_csv(CSV_PATH, index=False)
125
+
126
+ paper_count = len(df)
127
+ abstract_sentences = list(
128
+ filter(None, sum(map(_split_sentences, df["Abstract"].dropna().tolist()), []))
129
+ )
130
+ title_sentences = list(
131
+ filter(None, sum(map(_split_sentences, df["Title"].dropna().tolist()), []))
132
+ )
133
+
134
+ stats = {
135
+ "papers": paper_count,
136
+ "abstract_sentences": len(abstract_sentences),
137
+ "title_sentences": len(title_sentences),
138
+ "columns": list(df.columns),
139
+ "year_range": f"{int(df['Year'].min())} – {int(df['Year'].max())}" if "Year" in df.columns else "N/A",
140
+ }
141
+ with open(_ckpt("stats.json"), "w") as f:
142
+ json.dump(stats, f, indent=2)
143
+
144
+ return (
145
+ f"βœ… CSV loaded successfully.\n"
146
+ f"πŸ“„ Papers: {paper_count}\n"
147
+ f"πŸ“ Abstract sentences (after cleaning): {len(abstract_sentences)}\n"
148
+ f"πŸ”€ Title sentences (after cleaning): {len(title_sentences)}\n"
149
+ f"πŸ“… Year range: {stats['year_range']}\n"
150
+ f"πŸ“Š Columns: {', '.join(stats['columns'])}\n\n"
151
+ f"Data is ready. Please type **'run abstract'** to begin Phase 2 BERTopic analysis on abstracts."
152
+ )
153
+
154
+
155
+ # ── Tool 2: run_bertopic_discovery ────────────────────────────────────────────
156
+ @tool(handle_tool_error=True)
157
+ def run_bertopic_discovery(run_key: str, threshold: float = 0.7) -> str:
158
+ """Embed sentences with all-MiniLM-L6-v2, cluster with AgglomerativeClustering (cosine metric),
159
+ find 5 nearest centroids per cluster, generate 4 Plotly charts. Save summaries.json + emb.npy.
160
+ run_key: 'abstract' or 'title'. threshold: clustering distance threshold (default 0.7)."""
161
+ df = pd.read_csv(CSV_PATH, encoding="utf-8-8-sig")
162
+ columns = RUN_CONFIGS[run_key]
163
+
164
+ texts = sum(
165
+ list(map(lambda col: df[col].dropna().tolist(), columns)), []
166
+ )
167
+ sentences = list(
168
+ filter(lambda s: len(s) > 20,
169
+ sum(list(map(_split_sentences, texts)), []))
170
+ )
171
+
172
+ model = SentenceTransformer("all-MiniLM-L6-v2")
173
+ embeddings = model.encode(sentences, normalize_embeddings=True, show_progress_bar=False)
174
+ np.save(_ckpt(f"{run_key}_emb.npy"), embeddings)
175
+
176
+ clustering = AgglomerativeClustering(
177
+ metric="cosine",
178
+ linkage="average",
179
+ distance_threshold=threshold,
180
+ n_clusters=None,
181
+ )
182
+ labels_arr = clustering.fit_predict(embeddings)
183
+
184
+ unique_labels = list(set(labels_arr.tolist()))
185
+ cluster_data = list(map(lambda lbl: _build_cluster_summary(lbl, labels_arr, sentences, embeddings), unique_labels))
186
+ cluster_data.sort(key=lambda x: x["sentence_count"], reverse=True)
187
+
188
+ with open(_ckpt(f"{run_key}_summaries.json"), "w") as f:
189
+ json.dump(cluster_data, f, indent=2)
190
+
191
+ _generate_charts(cluster_data, run_key, embeddings, labels_arr)
192
+
193
+ return (
194
+ f"βœ… BERTopic discovery complete for **{run_key}** run.\n"
195
+ f"πŸ”’ Topics discovered: {len(unique_labels)}\n"
196
+ f"πŸ“Š Sentences clustered: {len(sentences)}\n"
197
+ f"πŸ“ Saved: {run_key}_summaries.json, {run_key}_emb.npy\n"
198
+ f"🎨 4 Plotly charts generated.\n\n"
199
+ f"Now calling label_topics_with_llm to label the top {MAX_LABEL_TOPICS} topics..."
200
+ )
201
+
202
+
203
+ def _build_cluster_summary(lbl, labels_arr, sentences, embeddings):
204
+ mask = np.array(labels_arr) == lbl
205
+ cluster_sents = [s for s, m in zip(sentences, mask.tolist()) if m]
206
+ cluster_embs = embeddings[mask]
207
+ centroid = cluster_embs.mean(axis=0, keepdims=True)
208
+ sims = cosine_similarity(centroid, cluster_embs)[0]
209
+ top_idxs = np.argsort(sims)[::-1][:NEAREST_K].tolist()
210
+ top_sents = [cluster_sents[i] for i in top_idxs]
211
+ return {
212
+ "topic_id": int(lbl),
213
+ "sentence_count": len(cluster_sents),
214
+ "top_sentences": top_sents,
215
+ "centroid": centroid[0].tolist(),
216
+ "label": f"Topic_{lbl}",
217
+ "category": "",
218
+ "confidence": 0.0,
219
+ "reasoning": "",
220
+ "niche": False,
221
+ }
222
+
223
+
224
+ def _generate_charts(cluster_data, run_key, embeddings, labels_arr):
225
+ top_n = min(30, len(cluster_data))
226
+ top_clusters = cluster_data[:top_n]
227
+ topic_ids = list(map(lambda c: c["topic_id"], top_clusters))
228
+ counts = list(map(lambda c: c["sentence_count"], top_clusters))
229
+ topic_labels = list(map(lambda c: c["label"], top_clusters))
230
+
231
+ # Chart 1: Bar chart β€” top topics by sentence count
232
+ fig_bar = px.bar(
233
+ x=counts, y=topic_labels, orientation="h",
234
+ title=f"Top {top_n} Topics by Sentence Count ({run_key})",
235
+ labels={"x": "Sentences", "y": "Topic"},
236
+ color=counts, color_continuous_scale="Viridis",
237
+ )
238
+ fig_bar.update_layout(height=700, yaxis=dict(autorange="reversed"))
239
+ with open(_ckpt(f"{run_key}_chart_bar.html"), "w") as f:
240
+ f.write(fig_bar.to_html(include_plotlyjs="cdn", full_html=True))
241
+
242
+ # Chart 2: Intertopic map (2D PCA projection of centroids)
243
+ centroids = np.array(list(map(lambda c: c["centroid"], top_clusters)))
244
+ from sklearn.decomposition import PCA
245
+ pca = PCA(n_components=2)
246
+ coords = pca.fit_transform(centroids)
247
+ fig_map = px.scatter(
248
+ x=coords[:, 0], y=coords[:, 1],
249
+ text=topic_labels, size=counts,
250
+ title=f"Intertopic Distance Map ({run_key})",
251
+ labels={"x": "PC1", "y": "PC2"},
252
+ color=counts, color_continuous_scale="Plasma",
253
+ )
254
+ fig_map.update_traces(textposition="top center")
255
+ fig_map.update_layout(height=600)
256
+ with open(_ckpt(f"{run_key}_chart_map.html"), "w") as f:
257
+ f.write(fig_map.to_html(include_plotlyjs="cdn", full_html=True))
258
+
259
+ # Chart 3: Hierarchy (dendrogram-style using sorted counts)
260
+ sorted_data = sorted(zip(topic_labels, counts), key=lambda x: x[1])
261
+ fig_hier = go.Figure(go.Bar(
262
+ x=list(map(lambda d: d[1], sorted_data)),
263
+ y=list(map(lambda d: d[0], sorted_data)),
264
+ orientation="h",
265
+ marker_color="teal",
266
+ ))
267
+ fig_hier.update_layout(
268
+ title=f"Topic Hierarchy ({run_key})",
269
+ height=700,
270
+ xaxis_title="Sentence Count",
271
+ )
272
+ with open(_ckpt(f"{run_key}_chart_hierarchy.html"), "w") as f:
273
+ f.write(fig_hier.to_html(include_plotlyjs="cdn", full_html=True))
274
+
275
+ # Chart 4: Heatmap of top-10 topic co-occurrence (cosine sim of centroids)
276
+ top10 = cluster_data[:10]
277
+ top10_centroids = np.array(list(map(lambda c: c["centroid"], top10)))
278
+ sim_matrix = cosine_similarity(top10_centroids)
279
+ top10_labels = list(map(lambda c: c["label"], top10))
280
+ fig_heat = px.imshow(
281
+ sim_matrix,
282
+ x=top10_labels, y=top10_labels,
283
+ color_continuous_scale="RdBu_r",
284
+ title=f"Topic Similarity Heatmap – Top 10 ({run_key})",
285
+ )
286
+ fig_heat.update_layout(height=500)
287
+ with open(_ckpt(f"{run_key}_chart_heatmap.html"), "w") as f:
288
+ f.write(fig_heat.to_html(include_plotlyjs="cdn", full_html=True))
289
+
290
+
291
+ # ── Tool 3: label_topics_with_llm ─────────────────────────────────────────────
292
+ @tool(handle_tool_error=True)
293
+ def label_topics_with_llm(run_key: str) -> str:
294
+ """Send top MAX_LABEL_TOPICS topics to Mistral for labelling. Each topic gets:
295
+ label, category, confidence, reasoning, niche (true/false).
296
+ Saves labels.json. run_key: 'abstract' or 'title'."""
297
+ with open(_ckpt(f"{run_key}_summaries.json")) as f:
298
+ summaries = json.load(f)
299
+
300
+ top_topics = summaries[:MAX_LABEL_TOPICS]
301
+
302
+ topic_texts = "\n\n".join(list(map(
303
+ lambda t: (
304
+ f"Topic {t['topic_id']} ({t['sentence_count']} sentences):\n"
305
+ + "\n".join(list(map(lambda s: f" - {s}", t["top_sentences"][:3])))
306
+ ),
307
+ top_topics,
308
+ )))
309
+
310
+ prompt = PromptTemplate.from_template(
311
+ """You are a research labelling expert. For each topic below, provide a JSON array.
312
+ Each element must have: topic_id (int), label (research area name, max 6 words),
313
+ category (broad domain), confidence (0.0-1.0), reasoning (1 sentence), niche (true/false).
314
+
315
+ Return ONLY a valid JSON array. No markdown, no explanation.
316
+
317
+ Topics:
318
+ {topics}
319
+
320
+ JSON array:"""
321
+ )
322
+
323
+ parser = JsonOutputParser()
324
+ chain = prompt | _llm() | parser
325
+ labeled = chain.invoke({"topics": topic_texts})
326
+
327
+ labeled_map = {item["topic_id"]: item for item in labeled}
328
+ result = list(map(
329
+ lambda t: {**t, **labeled_map.get(t["topic_id"], {})},
330
+ summaries,
331
+ ))
332
+
333
+ with open(_ckpt(f"{run_key}_labels.json"), "w") as f:
334
+ json.dump(result, f, indent=2)
335
+
336
+ labeled_count = len(labeled)
337
+ return (
338
+ f"βœ… Labelling complete for **{run_key}** run.\n"
339
+ f"🏷️ Topics labeled: {labeled_count}\n"
340
+ f"πŸ“ Saved: {run_key}_labels.json\n\n"
341
+ f"The review table has been populated with {labeled_count} labeled topics.\n"
342
+ f"**Please review the table below:** Edit the **Approve**, **Rename To**, and **Reasoning** columns, "
343
+ f"then click **Submit Review** to proceed to Phase 3."
344
+ )
345
+
346
+
347
+ # ── Tool 4: consolidate_into_themes ───────────────────────────────────────────
348
+ @tool(handle_tool_error=True)
349
+ def consolidate_into_themes(run_key: str, theme_map: str) -> str:
350
+ """Merge researcher-approved topic groups into consolidated themes.
351
+ Recomputes centroids, recounts sentences and papers.
352
+ Saves themes.json.
353
+ run_key: 'abstract' or 'title'.
354
+ theme_map: JSON string mapping theme names to lists of topic_ids,
355
+ e.g. '{"AI Tourism": [0,1,5], "Smart Hotels": [2,3]}'"""
356
+ with open(_ckpt(f"{run_key}_labels.json")) as f:
357
+ labels = json.load(f)
358
+
359
+ theme_mapping = json.loads(theme_map)
360
+ label_lookup = {item["topic_id"]: item for item in labels}
361
+
362
+ themes = list(map(
363
+ lambda kv: _build_theme(kv[0], kv[1], label_lookup),
364
+ theme_mapping.items(),
365
+ ))
366
+ themes.sort(key=lambda t: t["sentence_count"], reverse=True)
367
+
368
+ with open(_ckpt(f"{run_key}_themes.json"), "w") as f:
369
+ json.dump(themes, f, indent=2)
370
+
371
+ return (
372
+ f"βœ… Themes consolidated for **{run_key}** run.\n"
373
+ f"πŸ—‚οΈ Themes created: {len(themes)}\n"
374
+ + "\n".join(list(map(
375
+ lambda t: f" β€’ **{t['name']}**: {t['sentence_count']} sentences, {len(t['topic_ids'])} topics",
376
+ themes,
377
+ )))
378
+ + f"\n\nπŸ“ Saved: {run_key}_themes.json\n\n"
379
+ f"**Please review the consolidated themes in the table.** "
380
+ f"Rename or adjust if needed, then click **Submit Review** to proceed to Phase 4."
381
+ )
382
+
383
+
384
+ def _build_theme(name, topic_ids, label_lookup):
385
+ topics = list(filter(lambda t: t["topic_id"] in topic_ids, label_lookup.values()))
386
+ all_sents = sum(list(map(lambda t: t.get("top_sentences", []), topics)), [])
387
+ all_centroids = list(map(lambda t: t.get("centroid", []), topics))
388
+ centroid = np.mean(all_centroids, axis=0).tolist() if all_centroids else []
389
+ return {
390
+ "name": name,
391
+ "topic_ids": topic_ids,
392
+ "sentence_count": sum(list(map(lambda t: t.get("sentence_count", 0), topics))),
393
+ "top_sentences": all_sents[:NEAREST_K],
394
+ "centroid": centroid,
395
+ "pajais_match": "",
396
+ "match_confidence": 0.0,
397
+ "reasoning": "",
398
+ "is_novel": False,
399
+ }
400
+
401
+
402
+ # ── Tool 5: compare_with_taxonomy ─────────────────────────────────────────────
403
+ @tool(handle_tool_error=True)
404
+ def compare_with_taxonomy(run_key: str) -> str:
405
+ """Map final themes to PAJAIS 25-category taxonomy using Mistral.
406
+ Each theme gets: pajais_match (or NOVEL), match_confidence, reasoning, is_novel.
407
+ Saves taxonomy_map.json. run_key: 'abstract' or 'title'."""
408
+ with open(_ckpt(f"{run_key}_themes.json")) as f:
409
+ themes = json.load(f)
410
+
411
+ theme_text = "\n".join(list(map(
412
+ lambda t: (
413
+ f"Theme: {t['name']}\n"
414
+ f"Evidence: {' | '.join(t.get('top_sentences', [])[:2])}"
415
+ ),
416
+ themes,
417
+ )))
418
+
419
+ pajais_text = "\n".join(PAJAIS_CATEGORIES)
420
+
421
+ prompt = PromptTemplate.from_template(
422
+ """You are a PAJAIS taxonomy expert. Map each research theme to the closest PAJAIS category.
423
+ If no category fits well (similarity < 0.6), mark as NOVEL.
424
+
425
+ PAJAIS Categories:
426
+ {pajais}
427
+
428
+ Themes to map:
429
+ {themes}
430
+
431
+ Return ONLY a JSON array. Each element: theme_name (str), pajais_match (str, exact category name or "NOVEL"),
432
+ match_confidence (float 0-1), reasoning (str, 1 sentence), is_novel (bool).
433
+
434
+ JSON array:"""
435
+ )
436
+
437
+ parser = JsonOutputParser()
438
+ chain = prompt | _llm() | parser
439
+ mapped = chain.invoke({"pajais": pajais_text, "themes": theme_text})
440
+
441
+ mapped_lookup = {item["theme_name"]: item for item in mapped}
442
+ result = list(map(
443
+ lambda t: {**t, **mapped_lookup.get(t["name"], {})},
444
+ themes,
445
+ ))
446
+
447
+ with open(_ckpt(f"{run_key}_taxonomy_map.json"), "w") as f:
448
+ json.dump(result, f, indent=2)
449
+
450
+ novel_count = len(list(filter(lambda t: t.get("is_novel", False), result)))
451
+ mapped_count = len(result) - novel_count
452
+
453
+ return (
454
+ f"βœ… PAJAIS taxonomy mapping complete for **{run_key}** run.\n"
455
+ f"βœ… MAPPED themes: {mapped_count}\n"
456
+ f"πŸ†• NOVEL themes: {novel_count}\n\n"
457
+ f"The review table now shows PAJAIS matches in the **Top Evidence** column.\n"
458
+ f"**Review the mapping in the table.** Novel themes may represent publishable research gaps. "
459
+ f"Click **Submit Review** to proceed to Phase 6."
460
+ )
461
+
462
+
463
+ # ── Tool 6: generate_comparison_csv ───────────────────────────────────────────
464
+ @tool(handle_tool_error=True)
465
+ def generate_comparison_csv() -> str:
466
+ """Load themes from both abstract and title runs, create side-by-side comparison DataFrame.
467
+ Saves comparison.csv showing convergence and divergence between runs."""
468
+ with open(_ckpt("abstract_taxonomy_map.json")) as f:
469
+ abstract_themes = json.load(f)
470
+ with open(_ckpt("title_taxonomy_map.json")) as f:
471
+ title_themes = json.load(f)
472
+
473
+ abstract_rows = list(map(
474
+ lambda t: {
475
+ "Run": "Abstract",
476
+ "Theme": t["name"],
477
+ "Sentences": t.get("sentence_count", 0),
478
+ "PAJAIS Match": t.get("pajais_match", ""),
479
+ "Confidence": t.get("match_confidence", 0),
480
+ "Novel": t.get("is_novel", False),
481
+ "Reasoning": t.get("reasoning", ""),
482
+ },
483
+ abstract_themes,
484
+ ))
485
+ title_rows = list(map(
486
+ lambda t: {
487
+ "Run": "Title",
488
+ "Theme": t["name"],
489
+ "Sentences": t.get("sentence_count", 0),
490
+ "PAJAIS Match": t.get("pajais_match", ""),
491
+ "Confidence": t.get("match_confidence", 0),
492
+ "Novel": t.get("is_novel", False),
493
+ "Reasoning": t.get("reasoning", ""),
494
+ },
495
+ title_themes,
496
+ ))
497
+
498
+ df = pd.DataFrame(abstract_rows + title_rows)
499
+ df.to_csv(_ckpt("comparison.csv"), index=False)
500
+
501
+ return (
502
+ f"βœ… Comparison CSV generated.\n"
503
+ f"πŸ“Š Abstract themes: {len(abstract_themes)}\n"
504
+ f"πŸ“Š Title themes: {len(title_themes)}\n"
505
+ f"πŸ“ Saved: comparison.csv\n\n"
506
+ f"Check the **Download** tab for comparison.csv. "
507
+ f"Click **Submit Review** to confirm and generate the narrative report."
508
+ )
509
+
510
+
511
+ # ── Tool 7: export_narrative ───────────────────────────────────────────────────
512
+ @tool(handle_tool_error=True)
513
+ def export_narrative(run_key: str) -> str:
514
+ """Generate a 500-word Section 7 narrative report for the literature review paper.
515
+ Uses themes and taxonomy mapping via Mistral. Saves narrative.txt.
516
+ run_key: 'abstract' or 'title'."""
517
+ with open(_ckpt(f"{run_key}_taxonomy_map.json")) as f:
518
+ themes = json.load(f)
519
+
520
+ themes_summary = "\n".join(list(map(
521
+ lambda t: (
522
+ f"- {t['name']}: {t.get('sentence_count', 0)} sentences, "
523
+ f"PAJAIS: {t.get('pajais_match', 'NOVEL')}, "
524
+ f"Novel: {t.get('is_novel', False)}"
525
+ ),
526
+ themes,
527
+ )))
528
+
529
+ prompt = PromptTemplate.from_template(
530
+ """You are an academic writing expert. Write a formal 500-word Section 7 (Thematic Analysis Results)
531
+ for a journal literature review paper using the following data.
532
+
533
+ Reference: Braun & Clarke (2006) six-phase thematic analysis methodology.
534
+ Mention: BERTopic clustering, AgglomerativeClustering with cosine metric, Mistral LLM labelling.
535
+ Include: key themes, PAJAIS taxonomy mapping, NOVEL themes as research gaps, limitations.
536
+ Use academic language. Do not use bullet points β€” write in paragraphs.
537
+
538
+ Themes and PAJAIS mapping ({run_key} run):
539
+ {themes}
540
+
541
+ Write Section 7 now (exactly 500 words):"""
542
+ )
543
+
544
+ chain = prompt | _llm()
545
+ narrative = chain.invoke({"run_key": run_key, "themes": themes_summary})
546
+
547
+ text = narrative.content if hasattr(narrative, "content") else str(narrative)
548
+
549
+ with open(_ckpt(f"{run_key}_narrative.txt"), "w") as f:
550
+ f.write(text)
551
+
552
+ return (
553
+ f"βœ… Narrative report generated for **{run_key}** run.\n"
554
+ f"πŸ“ 500-word Section 7 draft saved.\n"
555
+ f"πŸ“ Saved: {run_key}_narrative.txt\n\n"
556
+ f"Check the **Download** tab for all output files.\n\n"
557
+ f"**Phase 6 complete. Thematic analysis finished.**\n"
558
+ f"Download: comparison.csv, taxonomy_map.json, narrative.txt for your conference paper."
559
+ )
560
+
561
+
562
+ # ── Exported tool list ─────────────────────────────────────────────────────────
563
+ ALL_TOOLS = [
564
+ load_scopus_csv,
565
+ run_bertopic_discovery,
566
+ label_topics_with_llm,
567
+ consolidate_into_themes,
568
+ compare_with_taxonomy,
569
+ generate_comparison_csv,
570
+ export_narrative,
571
+ ]