mahir123456 commited on
Commit
cee0e18
Β·
verified Β·
1 Parent(s): 42d236b

Upload 5 files

Browse files
Files changed (5) hide show
  1. README.md +196 -10
  2. agent.py +393 -0
  3. app.py +563 -0
  4. requirements.txt +17 -0
  5. tools.py +783 -0
README.md CHANGED
@@ -1,13 +1,199 @@
 
 
 
1
  ---
2
- title: Topic Modelling
3
- emoji: 🌍
4
- colorFrom: red
5
- colorTo: yellow
6
- sdk: gradio
7
- sdk_version: 6.12.0
8
- app_file: app.py
9
- pinned: false
10
- license: mit
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BERTopic Thematic Analysis Agent
2
+ ### Braun & Clarke (2006) Six-Phase Thematic Analysis β€” Complete Setup Guide
3
+
4
  ---
5
+
6
+ ## πŸ“ Your 4-File Repo
7
+
8
+ ```
9
+ topic_modelling/
10
+ β”œβ”€β”€ app.py (~370 lines) β€” Gradio UI
11
+ β”œβ”€β”€ agent.py (~250 lines) β€” LangGraph brain + SYSTEM_PROMPT
12
+ β”œβ”€β”€ tools.py (~400 lines) β€” 7 @tool functions
13
+ └── requirements.txt (13 packages) β€” Python dependencies
14
+ ```
15
+
16
  ---
17
 
18
+ ## PART 1 β€” Get Your Mistral API Key (FREE)
19
+
20
+ 1. Go to β†’ **https://console.mistral.ai**
21
+ 2. Sign up for a free account
22
+ 3. Click **API Keys** β†’ **Create new key**
23
+ 4. Copy the key β€” looks like `sk-...`
24
+ ⚠️ Save it immediately β€” it only shows once
25
+
26
+ ---
27
+
28
+ ## PART 2 β€” Deploy on HuggingFace Spaces
29
+
30
+ ### Step 1 β€” Create a HuggingFace account
31
+ - Go to β†’ **https://huggingface.co**
32
+ - Click **Sign Up** β†’ create free account
33
+
34
+ ### Step 2 β€” Create a new Space
35
+ 1. Click your avatar (top right) β†’ **New Space**
36
+ 2. Fill in:
37
+ - **Space name**: `topic_modelling`
38
+ - **License**: MIT
39
+ - **SDK**: **Gradio** ← very important
40
+ - **Hardware**: CPU Basic (free)
41
+ 3. Click **Create Space**
42
+
43
+ ### Step 3 β€” Add your Mistral API Key as a Secret
44
+ 1. In your Space β†’ click **Settings** tab
45
+ 2. Scroll to **Repository Secrets** β†’ click **New Secret**
46
+ 3. Set:
47
+ - **Name**: `MISTRAL_API_KEY`
48
+ - **Value**: paste your key from Step 1
49
+ 4. Click **Save**
50
+
51
+ ### Step 4 β€” Upload your 4 files
52
+ 1. In your Space β†’ click **Files** tab
53
+ 2. Click **Add file** β†’ **Upload files**
54
+ 3. Upload all 4 files at once:
55
+ - `app.py`
56
+ - `agent.py`
57
+ - `tools.py`
58
+ - `requirements.txt`
59
+ 4. Click **Commit changes to main**
60
+
61
+ ### Step 5 β€” Wait for build (2–4 minutes)
62
+ - Click the **App** tab to watch logs
63
+ - HuggingFace reads `requirements.txt` and auto-installs all 13 packages
64
+ - When you see `Running on local URL: http://0.0.0.0:7860`, you're live!
65
+
66
+ ### Step 6 β€” Your app URL
67
+ ```
68
+ https://huggingface.co/spaces/YOUR_USERNAME/topic_modelling
69
+ ```
70
+
71
+ ---
72
+
73
+ ## PART 3 β€” Prepare Your Scopus CSV
74
+
75
+ ### Export from Scopus
76
+ 1. Go to **scopus.com** β†’ search for your journal
77
+ - Example: `SOURCE-ID(21100830939)` for PAJAIS
78
+ 2. Select **all results** β†’ click **Export**
79
+ 3. Choose **CSV** format
80
+ 4. Select these columns:
81
+ - βœ… Authors
82
+ - βœ… Title
83
+ - βœ… Abstract
84
+ - βœ… Author Keywords
85
+ - βœ… Cited by
86
+ - βœ… Source title
87
+ - βœ… Year
88
+ 5. Click **Export**
89
+ 6. Rename file to: `YourJournal_TopicModelling_Export.csv`
90
+
91
+ ---
92
+
93
+ ## PART 4 β€” Run Your Analysis (Step by Step)
94
+
95
+ ### Abstract Run (Primary Analysis)
96
+
97
+ | Step | You Type | What Happens |
98
+ |------|----------|--------------|
99
+ | 1 | Upload CSV in Section 1 | Stats appear β€” paper count, columns, year range |
100
+ | 2 | `run abstract` | **Phase 1** β€” Corpus loads, sentences extracted & filtered |
101
+ | 3 | `proceed` | **Phase 2** β€” BERTopic clusters + Mistral labels ~100 topics |
102
+ | 4 | *(Review table populates)* | Agent **STOPs** β€” table shows 100 rows |
103
+ | 5 | Edit table: set Approve=yes/no, fill Rename To + Reasoning | Your expert judgment |
104
+ | 6 | Click **Submit Review** button | **Phase 3** β€” Approved topics consolidated into themes |
105
+ | 7 | `proceed` | **Phase 4** β€” Saturation check: coverage %, overlapping themes |
106
+ | 8 | `yes continue` | **Phase 5** β€” Theme names finalised with definitions |
107
+ | 9 | `proceed` | **Phase 5.5** β€” PAJAIS taxonomy mapping (MAPPED vs NOVEL) |
108
+ | 10 | Review NOVEL themes β†’ `confirmed` | Agent **STOPs** for your review |
109
+ | 11 | `generate report` | **Phase 6** β€” comparison.csv + narrative.txt generated |
110
+ | 12 | Click **Refresh Downloads** | Download all output files |
111
+
112
+ ### Title Run (Cross-Validation)
113
+ Repeat steps 2–12 but type `run title` instead of `run abstract`.
114
+
115
+ ---
116
+
117
+ ## PART 5 β€” Troubleshooting
118
+
119
+ | Problem | Fix |
120
+ |---------|-----|
121
+ | Build fails in HuggingFace | Check **Logs** tab for exact error |
122
+ | `ModuleNotFoundError` | Add missing package to `requirements.txt`, re-upload |
123
+ | `MISTRAL_API_KEY not set` | Settings β†’ Repository Secrets β†’ check key is saved |
124
+ | `Memory exceeded` | Switch to **CPU Upgrade** hardware (still free tier) |
125
+ | Review table empty | Wait for Phase 2 to fully complete before checking table |
126
+ | `AgglomerativeClustering` error | Ensure corpus has 50+ sentences (small CSVs may fail) |
127
+ | Gradio version mismatch | Pin: `gradio==4.44.1` in requirements.txt |
128
+ | Charts not rendering | Check `{run_type}_topic_size.html` exists in Space files |
129
+
130
+ ---
131
+
132
+ ## PART 6 β€” Assignment Checklist
133
+
134
+ ### Part A β€” Files to submit
135
+ - [ ] `app.py` β€” Gradio UI
136
+ - [ ] `agent.py` β€” LangGraph agent + SYSTEM_PROMPT
137
+ - [ ] `tools.py` β€” 7 @tool functions
138
+ - [ ] `requirements.txt` β€” 13 packages
139
+ - [ ] HuggingFace Space URL (must be live when graded)
140
+
141
+ ### Part B β€” Runs to complete
142
+ - [ ] Abstract analysis β€” all 6 B&C phases
143
+ - [ ] Title analysis β€” all 6 B&C phases
144
+
145
+ ### Part C β€” Output files to download
146
+ - [ ] `comparison.csv` β€” abstract vs title theme convergence
147
+ - [ ] `taxonomy_map.json` β€” PAJAIS gap analysis
148
+ - [ ] `narrative.txt` β€” 500-word Section 7 draft
149
+ - [ ] `abstract_summaries.json` β€” BERTopic cluster data
150
+ - [ ] Screenshots: Phase 2 review table (98+ topics)
151
+ - [ ] Screenshots: Phase 5.5 PAJAIS mapping
152
+
153
+ ### Part C β€” Written components
154
+ - [ ] All Claude prompts used to generate each file (copy from chat)
155
+ - [ ] 250-word reflection: unexpected discoveries, NOVEL themes, abstract vs title differences
156
+
157
+ ---
158
+
159
+ ## PART 7 β€” Agentic Coding Rules (for your submission)
160
+
161
+ Your `tools.py` follows these 10 rules β€” verify before submitting:
162
+
163
+ ```bash
164
+ # Check rule compliance in your tools.py
165
+ grep -c "if\|elif\|else" tools.py # Should be 0 (or very close)
166
+ grep -c "for \|while " tools.py # Should be 0 (replaced with map/filter)
167
+ grep -c "try:\|except " tools.py # Should be 0 (handle_tool_error=True)
168
+ grep -c "handle_tool_error=True" tools.py # Should be 7 (one per tool)
169
+ ```
170
+
171
+ ---
172
+
173
+ ## PART 8 β€” SYSTEM_PROMPT Quick Reference
174
+
175
+ The 4 STOP gates in `agent.py`:
176
+
177
+ ```
178
+ Phase 2 β†’ STOP after label_topics_with_llm (researcher reviews topics)
179
+ Phase 3 β†’ STOP after consolidate_into_themes (researcher confirms themes)
180
+ Phase 4 β†’ STOP after saturation check (researcher confirms coverage)
181
+ Phase 5.5β†’ STOP after compare_with_taxonomy (researcher reviews NOVEL themes)
182
+ ```
183
+
184
+ ---
185
+
186
+ ## Tech Stack Summary
187
+
188
+ | Component | Technology | Purpose |
189
+ |-----------|-----------|---------|
190
+ | UI | Gradio 4.x | Web interface |
191
+ | LLM Brain | Mistral-small-latest | Labelling, mapping, narrative |
192
+ | Agent Framework | LangGraph (ReAct) | Tool orchestration |
193
+ | Memory | LangGraph MemorySaver | Full conversation history |
194
+ | Embeddings | all-MiniLM-L6-v2 (SBERT) | 384-dim sentence vectors |
195
+ | Clustering | AgglomerativeClustering | Cosine, threshold=0.7, no UMAP |
196
+ | Charts | Plotly | 4 interactive HTML charts |
197
+ | Methodology | Braun & Clarke (2006) | 6-phase thematic analysis |
198
+ | Taxonomy | PAJAIS-25 | Research gap identification |
199
+ | Deployment | HuggingFace Spaces | Free cloud hosting |
agent.py ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agent.py β€” Mistral LangGraph Agent for BERTopic Thematic Analysis
3
+ Implements Braun & Clarke (2006) Six-Phase Thematic Analysis as agentic workflow.
4
+ Brain: Mistral-small-latest (ChatMistralAI)
5
+ Memory: LangGraph MemorySaver (full conversation history)
6
+ Tools: 7 @tool functions from tools.py
7
+ Exposes: run_agent(), get_agent_state(), reset_agent() ← called by app.py
8
+ """
9
+
10
+ import os
11
+ import re
12
+ import json
13
+ import uuid
14
+ from typing import Any
15
+
16
+ from langchain_mistralai import ChatMistralAI
17
+ from langchain_core.messages import HumanMessage, SystemMessage
18
+ from langgraph.prebuilt import create_react_agent
19
+ from langgraph.checkpoint.memory import MemorySaver
20
+
21
+ from tools import ALL_TOOLS, _load_json
22
+
23
+ # ─────────────────────────────────────────────────────────────────
24
+ # SYSTEM PROMPT β€” ALL workflow intelligence lives here
25
+ # ─────────────────────────────────────────────────────────────────
26
+ # Rules encoded in the prompt:
27
+ # β€’ ONE phase per message
28
+ # β€’ 4 mandatory STOP gates (Phase 2, 3, 4, 5.5)
29
+ # β€’ ALL approvals through the structured review table
30
+ # β€’ Researcher is always in the loop
31
+
32
+ SYSTEM_PROMPT = """
33
+ ╔══════════════════════════════════════════════════════════════════════╗
34
+ β•‘ BERTOPIC THEMATIC ANALYSIS AGENT β€” SYSTEM PROMPT β•‘
35
+ β•‘ Braun & Clarke (2006) Six-Phase Thematic Analysis β•‘
36
+ β•‘ Mistral LLM Β· LangGraph Β· Sentence-BERT Β· AgglomerativeClustering β•‘
37
+ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
38
+
39
+ ═══════════════════════════════════════
40
+ ROLE
41
+ ═══════════════════════════════════════
42
+ You are an expert computational thematic analysis agent specialising in
43
+ bibliometric and corpus analysis of Information Systems (IS) research.
44
+ You implement Braun & Clarke's (2006) six-phase thematic analysis pipeline
45
+ using BERTopic (semantic clustering) on Scopus academic abstracts.
46
+
47
+ Your primary collaborator is a human researcher who reviews and approves
48
+ each analytical phase before you proceed. You never skip phases, never
49
+ proceed past a STOP gate without explicit researcher approval, and never
50
+ make final decisions without researcher input.
51
+
52
+ ═══════════════════════════════════════
53
+ CRITICAL OPERATING RULES
54
+ ═══════════════════════════════════════
55
+ 1. ONE PHASE PER MESSAGE: Execute exactly ONE phase per response. Never
56
+ combine phases. After completing a phase, STOP and wait.
57
+
58
+ 2. STOP GATES ARE MANDATORY: At each β›” STOP gate, halt completely.
59
+ Do not call any further tools. Wait for researcher approval.
60
+ STOP gates are after: Phase 2 (topic review), Phase 3 (theme consolidation),
61
+ Phase 4 (saturation check), Phase 5.5 (PAJAIS mapping).
62
+
63
+ 3. ALL APPROVALS THROUGH THE REVIEW TABLE: Never accept "approve" or
64
+ "rename" instructions from chat text alone. The review table (gr.Dataframe)
65
+ is the only valid approval mechanism. When the researcher submits
66
+ [RESEARCHER REVIEW SUBMITTED] in the message, that is the approval signal.
67
+
68
+ 4. TOOL CALLS ONLY β€” NO HALLUCINATION: Never describe what a tool would
69
+ return. Always call the actual tool and report its real output.
70
+
71
+ 5. PHASE MARKERS: End every response with the current phase marker:
72
+ [PHASE:1], [PHASE:2], [PHASE:3], [PHASE:4], [PHASE:5], [PHASE:5.5], [PHASE:6]
73
+ This allows the UI to track and display progress.
74
+
75
+ 6. RUN TYPE AWARENESS: Track whether the current run is 'abstract' or 'title'.
76
+ When the researcher says "run abstract" β†’ run_type="abstract".
77
+ When the researcher says "run title" β†’ run_type="title".
78
+
79
+ ═══════════════════════════════════════
80
+ YOUR 7 TOOLS
81
+ ═══════════════════════════════════════
82
+ 1. load_scopus_csv(run_type)
83
+ β†’ Loads CSV from SCOPUS_CSV_PATH env var. Splits text into sentences.
84
+ Applies boilerplate filter. Saves corpus checkpoint.
85
+ Call this FIRST in Phase 1.
86
+
87
+ 2. run_bertopic_discovery(run_type)
88
+ β†’ Embeds sentences with all-MiniLM-L6-v2 (384-dim).
89
+ Clusters with AgglomerativeClustering(metric='cosine', threshold=0.7).
90
+ Finds 5 evidence sentences per cluster (nearest to centroid).
91
+ Generates 4 Plotly charts. Saves summaries.json + emb.npy.
92
+ Call this in Phase 2.
93
+
94
+ 3. label_topics_with_llm(run_type)
95
+ β†’ Sends top 100 clusters to Mistral for labelling via PromptTemplate.
96
+ Populates the review table with 8-column data.
97
+ Call this IMMEDIATELY after run_bertopic_discovery in Phase 2.
98
+ After this, STOP and wait for researcher to Submit Review.
99
+
100
+ 4. consolidate_into_themes(approved_json, run_type)
101
+ β†’ Merges approved topics into themes, applies researcher renames.
102
+ approved_json = the JSON from [RESEARCHER REVIEW SUBMITTED] message.
103
+ Call this in Phase 3.
104
+
105
+ 5. compare_with_taxonomy(run_type)
106
+ β†’ Maps themes to PAJAIS 25-category taxonomy via Mistral.
107
+ Identifies MAPPED (existing) vs NOVEL (publishable gaps) themes.
108
+ Saves taxonomy_map.json. Call this in Phase 5.5.
109
+ After this, STOP for researcher review of NOVEL themes.
110
+
111
+ 6. generate_comparison_csv(dummy="run")
112
+ β†’ Creates comparison.csv: abstract themes vs title themes side-by-side.
113
+ Shows convergent, abstract-only, and title-only themes.
114
+ Requires BOTH abstract and title runs to be completed first.
115
+ Call this in Phase 6.
116
+
117
+ 7. export_narrative(run_type)
118
+ β†’ Generates 500-word Section 7 academic narrative using Mistral.
119
+ Synthesises themes, PAJAIS mapping, and convergence findings.
120
+ Saves narrative.txt. Call this at end of Phase 6.
121
+
122
+ ═══════════════════════════════════════
123
+ BRAUN & CLARKE (2006) SIX-PHASE WORKFLOW
124
+ ═══════════════════════════════════════
125
+
126
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
127
+ β”‚ PHASE 1 β€” FAMILIARISATION β”‚
128
+ β”‚ Trigger: researcher says "run abstract" or "run title" β”‚
129
+ β”‚ Action: Call load_scopus_csv(run_type=...) β”‚
130
+ β”‚ Report: paper count, sentence count, year range, β”‚
131
+ β”‚ top journals, boilerplate sentences removed. β”‚
132
+ β”‚ Output: Corpus statistics displayed in chat. β”‚
133
+ β”‚ STOP: Yes β€” confirm corpus is correct before BERTopic. β”‚
134
+ β”‚ Marker: [PHASE:1] β”‚
135
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
136
+
137
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
138
+ β”‚ PHASE 2 β€” GENERATING INITIAL CODES β”‚
139
+ β”‚ Trigger: researcher says "proceed" or "run clustering" β”‚
140
+ β”‚ Action: Step A β€” Call run_bertopic_discovery(run_type) β”‚
141
+ β”‚ Report: clusters found, chart summary. β”‚
142
+ β”‚ Step B β€” IMMEDIATELY call label_topics_with_llm(run_type)β”‚
143
+ β”‚ Report: first 10 labelled topics preview. β”‚
144
+ β”‚ Populate review table with ALL labelled topics. β”‚
145
+ β”‚ β›” STOP GATE 1: β”‚
146
+ β”‚ DO NOT PROCEED. Display the review table. β”‚
147
+ β”‚ Instruct researcher to: β”‚
148
+ β”‚ β€’ Review Approve column (yes/no per topic) β”‚
149
+ β”‚ β€’ Fill Rename To column (optional better label) β”‚
150
+ β”‚ β€’ Add Reasoning for each decision β”‚
151
+ β”‚ β€’ Click 'Submit Review' button β”‚
152
+ β”‚ Marker: [PHASE:2] β”‚
153
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
154
+
155
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
156
+ β”‚ PHASE 3 β€” SEARCHING FOR THEMES β”‚
157
+ β”‚ Trigger: [RESEARCHER REVIEW SUBMITTED] message received β”‚
158
+ β”‚ Action: Extract approved_json from the submitted message. β”‚
159
+ β”‚ Call consolidate_into_themes(approved_json, run_type) β”‚
160
+ β”‚ Report: number of themes, theme names, coverage stats. β”‚
161
+ β”‚ β›” STOP GATE 2: β”‚
162
+ β”‚ Ask researcher: "Are you satisfied with theme coverage? β”‚
163
+ β”‚ Any themes to merge, split, or rename further?" β”‚
164
+ β”‚ Wait for explicit "proceed" or "yes continue". οΏ½οΏ½
165
+ β”‚ Marker: [PHASE:3] β”‚
166
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
167
+
168
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
169
+ β”‚ PHASE 4 β€” REVIEWING THEMES (SATURATION CHECK) β”‚
170
+ β”‚ Trigger: researcher confirms Phase 3 themes are satisfactory β”‚
171
+ β”‚ Action: Compute coverage statistics from themes.json: β”‚
172
+ β”‚ β€” % sentences covered by approved themes β”‚
173
+ β”‚ β€” % papers covered by approved themes β”‚
174
+ β”‚ β€” Any clusters with <5 sentences (suggest discarding) β”‚
175
+ β”‚ β€” Any overlapping themes (suggest merging) β”‚
176
+ β”‚ Report findings in structured format. β”‚
177
+ β”‚ β›” STOP GATE 3: β”‚
178
+ β”‚ Present saturation report. Ask researcher: β”‚
179
+ β”‚ "Has thematic saturation been reached? Shall I proceed β”‚
180
+ β”‚ to finalise theme names (Phase 5)?" β”‚
181
+ β”‚ Wait for explicit confirmation. β”‚
182
+ β”‚ Marker: [PHASE:4] β”‚
183
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
184
+
185
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
186
+ β”‚ PHASE 5 β€” DEFINING AND NAMING THEMES β”‚
187
+ β”‚ Trigger: researcher confirms saturation is reached β”‚
188
+ β”‚ Action: Present the final theme list from themes.json. β”‚
189
+ β”‚ For each theme provide: β”‚
190
+ β”‚ β€” Final theme name (from Rename To or original label) β”‚
191
+ β”‚ β€” 1-sentence theme definition (synthesised from evidence)β”‚
192
+ β”‚ β€” Key evidence sentence (centroid-nearest) β”‚
193
+ β”‚ β€” Paper count and sentence coverage β”‚
194
+ β”‚ Ask researcher to confirm or suggest further renaming. β”‚
195
+ β”‚ Marker: [PHASE:5] β”‚
196
+ β”‚ (No formal STOP gate β€” proceed to Phase 5.5 after confirmation) β”‚
197
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
198
+
199
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
200
+ β”‚ PHASE 5.5 β€” PAJAIS TAXONOMY MAPPING β”‚
201
+ β”‚ Trigger: researcher confirms Phase 5 theme names β”‚
202
+ β”‚ Action: Call compare_with_taxonomy(run_type) β”‚
203
+ β”‚ Report: β”‚
204
+ β”‚ β€” MAPPED themes: which PAJAIS category they match β”‚
205
+ β”‚ β€” NOVEL themes: themes NOT in PAJAIS (publishable gaps)β”‚
206
+ β”‚ β€” NOVEL themes are the key academic contribution β”‚
207
+ β”‚ β›” STOP GATE 4: β”‚
208
+ β”‚ Present full MAPPED/NOVEL table. β”‚
209
+ β”‚ Ask: "Do these classifications look correct? β”‚
210
+ β”‚ Any NOVEL themes to reclassify as MAPPED (or v.v.)?" β”‚
211
+ β”‚ Wait for researcher confirmation before Phase 6. β”‚
212
+ β”‚ Marker: [PHASE:5.5] β”‚
213
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
214
+
215
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
216
+ β”‚ PHASE 6 β€” PRODUCING THE REPORT β”‚
217
+ β”‚ Trigger: researcher confirms PAJAIS mapping β”‚
218
+ β”‚ Action: Step A β€” Call generate_comparison_csv() β”‚
219
+ β”‚ (only if BOTH abstract and title runs are done) β”‚
220
+ β”‚ If only one run done, skip this step. β”‚
221
+ β”‚ Step B β€” Call export_narrative(run_type) β”‚
222
+ β”‚ Report: narrative preview + download confirmation. β”‚
223
+ β”‚ Inform researcher of all downloadable files: β”‚
224
+ β”‚ comparison.csv Β· taxonomy_map.json Β· β”‚
225
+ β”‚ narrative.txt Β· {run_type}_summaries.json β”‚
226
+ β”‚ Marker: [PHASE:6] βœ… COMPLETE β”‚
227
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
228
+
229
+ ═══════════════════════════════════════
230
+ ERROR HANDLING
231
+ ═══════════════════════════════════════
232
+ β€’ If a tool returns an error message, report it clearly to the researcher.
233
+ β€’ Suggest the most likely fix (e.g., "MISTRAL_API_KEY may not be set").
234
+ β€’ Ask researcher to confirm before retrying.
235
+ β€’ Never silently skip a tool call that returned an error.
236
+
237
+ ═══════════════════════════════════════
238
+ CONVERSATION STYLE
239
+ ═══════════════════════════════════════
240
+ β€’ Be concise but informative. No filler phrases.
241
+ β€’ Use emoji sparingly for phase headers: βœ… β›” πŸ“Š πŸ” β˜…
242
+ β€’ Always end with the current [PHASE:X] marker.
243
+ β€’ When displaying theme lists, use numbered format (not bullets).
244
+ β€’ When citing evidence, truncate to 150 characters + "…"
245
+ """
246
+
247
+ # ─────────────────────────────────────────────────────────────────
248
+ # AGENT STATE (global β€” single-user HuggingFace Spaces deployment)
249
+ # ─────────────────────────────────────────────────────────────────
250
+
251
+ _SESSION_ID = str(uuid.uuid4())
252
+ _AGENT = None # LangGraph agent executor
253
+ _MEMORY = None # MemorySaver instance
254
+ _AGENT_STATE = {
255
+ "phase": 0,
256
+ "run_type": None,
257
+ "review_table": None,
258
+ "history": [],
259
+ }
260
+
261
+
262
+ def _build_agent():
263
+ """Initialise the LangGraph ReAct agent with Mistral + MemorySaver."""
264
+ global _AGENT, _MEMORY, _SESSION_ID
265
+
266
+ llm = ChatMistralAI(
267
+ model="mistral-small-latest",
268
+ api_key=os.environ.get("MISTRAL_API_KEY", ""),
269
+ temperature=0.1,
270
+ )
271
+
272
+ _MEMORY = MemorySaver()
273
+ _AGENT = create_react_agent(
274
+ model=llm,
275
+ tools=ALL_TOOLS,
276
+ checkpointer=_MEMORY,
277
+ state_modifier=SYSTEM_PROMPT,
278
+ )
279
+ _SESSION_ID = str(uuid.uuid4())
280
+
281
+
282
+ def _parse_phase(text: str) -> int:
283
+ """Extract [PHASE:X] marker from agent reply. Returns 0 if not found."""
284
+ match = re.search(r"\[PHASE:([\d.]+)\]", text)
285
+ return int(float(match.group(1))) if match else _AGENT_STATE["phase"]
286
+
287
+
288
+ def _parse_review_table(run_type: str) -> list | None:
289
+ """
290
+ Load review table rows from labeled_topics.json checkpoint.
291
+ Returns None if the checkpoint doesn't exist yet.
292
+ """
293
+ path = f"{run_type}_labeled_topics.json"
294
+ return (
295
+ _load_json(path).get("review_rows", None)
296
+ if os.path.exists(path)
297
+ else None
298
+ )
299
+
300
+
301
+ def _extract_run_type(message: str) -> str | None:
302
+ """Detect run_type from user message."""
303
+ msg_lower = message.lower()
304
+ return (
305
+ "abstract" if "abstract" in msg_lower else
306
+ "title" if "title" in msg_lower else
307
+ None
308
+ )
309
+
310
+
311
+ def _extract_approved_json(message: str) -> str:
312
+ """
313
+ Pull the review JSON payload out of a [RESEARCHER REVIEW SUBMITTED] message.
314
+ Falls back to an empty approved_topics list if not parseable.
315
+ """
316
+ json_match = re.search(r"Review data:\s*(\{.*\})", message, re.DOTALL)
317
+ return json_match.group(1) if json_match else json.dumps({"approved_topics": []})
318
+
319
+
320
+ # ─────────────────────────────────────────────────────────────────
321
+ # PUBLIC API (called by app.py)
322
+ # ─────────────────────────────────────────────────────────────────
323
+
324
+ def run_agent(user_message: str) -> dict:
325
+ """
326
+ Send user_message to the LangGraph agent and return:
327
+ {
328
+ "reply": str, # agent's response text
329
+ "phase": int, # current B&C phase (0-6)
330
+ "review_table": list | None # rows for gr.Dataframe, or None
331
+ }
332
+ """
333
+ global _AGENT, _AGENT_STATE
334
+
335
+ # Lazy init
336
+ _build_agent() if _AGENT is None else None
337
+
338
+ # Detect run_type from message (update state)
339
+ detected_rt = _extract_run_type(user_message)
340
+ _AGENT_STATE["run_type"] = detected_rt or _AGENT_STATE["run_type"]
341
+ run_type = _AGENT_STATE["run_type"] or "abstract"
342
+
343
+ config = {"configurable": {"thread_id": _SESSION_ID}}
344
+
345
+ # Invoke LangGraph agent
346
+ result = _AGENT.invoke(
347
+ {"messages": [HumanMessage(content=user_message)]},
348
+ config=config,
349
+ )
350
+
351
+ # Extract last AI message
352
+ ai_messages = list(filter(
353
+ lambda m: m.__class__.__name__ in ("AIMessage", "AIMessageChunk"),
354
+ result.get("messages", []),
355
+ ))
356
+ reply = ai_messages[-1].content if ai_messages else "⚠️ Agent returned no response."
357
+
358
+ # Parse phase from reply
359
+ new_phase = _parse_phase(reply)
360
+ _AGENT_STATE["phase"] = new_phase
361
+
362
+ # Load review table if Phase 2 completed
363
+ review_table = (
364
+ _parse_review_table(run_type)
365
+ if "[REVIEW_TABLE_READY:" in reply or new_phase == 2
366
+ else _AGENT_STATE.get("review_table")
367
+ )
368
+ _AGENT_STATE["review_table"] = review_table
369
+
370
+ return {
371
+ "reply": reply,
372
+ "phase": new_phase,
373
+ "review_table": review_table,
374
+ }
375
+
376
+
377
+ def get_agent_state() -> dict:
378
+ """Return the current agent state (phase, run_type, etc.)."""
379
+ return dict(_AGENT_STATE)
380
+
381
+
382
+ def reset_agent() -> None:
383
+ """Reset agent, memory, and session state for a new analysis."""
384
+ global _AGENT, _MEMORY, _SESSION_ID, _AGENT_STATE
385
+ _AGENT = None
386
+ _MEMORY = None
387
+ _SESSION_ID = str(uuid.uuid4())
388
+ _AGENT_STATE = {
389
+ "phase": 0,
390
+ "run_type": None,
391
+ "review_table": None,
392
+ "history": [],
393
+ }
app.py ADDED
@@ -0,0 +1,563 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py β€” Gradio UI for BERTopic Thematic Analysis Agent
3
+ Braun & Clarke (2006) Six-Phase Thematic Analysis
4
+ Agentic AI Application with Mistral LLM + LangGraph
5
+ """
6
+
7
+ import gradio as gr
8
+ import pandas as pd
9
+ import json
10
+ import os
11
+ from agent import run_agent, get_agent_state, reset_agent
12
+
13
+
14
+ # ─────────────────────────────────────────────────────────────
15
+ # PHASE PROGRESS BAR
16
+ # ─────────────────────────────────────────────────────────────
17
+
18
+ PHASES = [
19
+ "Phase 1: Familiarisation",
20
+ "Phase 2: Initial Codes",
21
+ "Phase 3: Themes",
22
+ "Phase 4: Saturation",
23
+ "Phase 5: Names",
24
+ "Phase 5.5: PAJAIS Mapping",
25
+ "Phase 6: Report",
26
+ ]
27
+
28
+ PHASE_DESCRIPTIONS = {
29
+ 0: "Upload your Scopus CSV to begin.",
30
+ 1: "Familiarisation β€” Loading corpus, computing statistics.",
31
+ 2: "Initial Codes β€” BERTopic clustering + LLM labelling. Review table below.",
32
+ 3: "Themes β€” Consolidating approved topic groups into themes.",
33
+ 4: "Saturation β€” Checking coverage and theme completeness.",
34
+ 5: "Names β€” Finalising theme names and definitions.",
35
+ 6: "PAJAIS Mapping β€” Mapping themes to taxonomy (MAPPED vs NOVEL).",
36
+ 7: "Report β€” Generating comparison CSV + 500-word Section 7 narrative.",
37
+ }
38
+
39
+
40
+ def build_progress_bar(current_phase: int) -> str:
41
+ """Render an HTML phase progress bar."""
42
+ total = len(PHASES)
43
+ pct = int((current_phase / total) * 100)
44
+ steps_html = ""
45
+ for i, label in enumerate(PHASES, start=1):
46
+ if i < current_phase:
47
+ cls = "step done"
48
+ icon = "βœ”"
49
+ elif i == current_phase:
50
+ cls = "step active"
51
+ icon = str(i)
52
+ else:
53
+ cls = "step"
54
+ icon = str(i)
55
+ steps_html += f"""
56
+ <div class="{cls}">
57
+ <div class="circle">{icon}</div>
58
+ <div class="label">{label}</div>
59
+ </div>"""
60
+
61
+ description = PHASE_DESCRIPTIONS.get(current_phase, "")
62
+ return f"""
63
+ <style>
64
+ .progress-wrap {{
65
+ font-family: 'Segoe UI', sans-serif;
66
+ padding: 16px 8px 8px 8px;
67
+ background: #f8f9fa;
68
+ border-radius: 12px;
69
+ border: 1px solid #dee2e6;
70
+ margin-bottom: 8px;
71
+ }}
72
+ .steps-row {{
73
+ display: flex;
74
+ align-items: flex-start;
75
+ justify-content: space-between;
76
+ position: relative;
77
+ }}
78
+ .steps-row::before {{
79
+ content: '';
80
+ position: absolute;
81
+ top: 18px;
82
+ left: 5%;
83
+ right: 5%;
84
+ height: 3px;
85
+ background: #dee2e6;
86
+ z-index: 0;
87
+ }}
88
+ .step {{
89
+ display: flex;
90
+ flex-direction: column;
91
+ align-items: center;
92
+ width: {100 // total}%;
93
+ z-index: 1;
94
+ }}
95
+ .circle {{
96
+ width: 36px;
97
+ height: 36px;
98
+ border-radius: 50%;
99
+ background: #dee2e6;
100
+ color: #6c757d;
101
+ display: flex;
102
+ align-items: center;
103
+ justify-content: center;
104
+ font-weight: bold;
105
+ font-size: 13px;
106
+ border: 2px solid #adb5bd;
107
+ }}
108
+ .step.done .circle {{
109
+ background: #198754;
110
+ color: white;
111
+ border-color: #198754;
112
+ }}
113
+ .step.active .circle {{
114
+ background: #0d6efd;
115
+ color: white;
116
+ border-color: #0d6efd;
117
+ box-shadow: 0 0 0 4px rgba(13,110,253,0.2);
118
+ }}
119
+ .label {{
120
+ font-size: 10px;
121
+ color: #495057;
122
+ margin-top: 6px;
123
+ text-align: center;
124
+ line-height: 1.3;
125
+ }}
126
+ .step.active .label {{ color: #0d6efd; font-weight: 600; }}
127
+ .step.done .label {{ color: #198754; }}
128
+ .phase-desc {{
129
+ margin-top: 10px;
130
+ font-size: 13px;
131
+ color: #495057;
132
+ text-align: center;
133
+ padding: 6px 12px;
134
+ background: #e9f3ff;
135
+ border-radius: 8px;
136
+ border-left: 4px solid #0d6efd;
137
+ }}
138
+ .progress-bar-outer {{
139
+ height: 6px;
140
+ background: #dee2e6;
141
+ border-radius: 4px;
142
+ margin: 10px 0 4px 0;
143
+ }}
144
+ .progress-bar-inner {{
145
+ height: 6px;
146
+ width: {pct}%;
147
+ background: linear-gradient(90deg, #0d6efd, #198754);
148
+ border-radius: 4px;
149
+ transition: width 0.5s ease;
150
+ }}
151
+ </style>
152
+ <div class="progress-wrap">
153
+ <div class="steps-row">{steps_html}</div>
154
+ <div class="progress-bar-outer">
155
+ <div class="progress-bar-inner"></div>
156
+ </div>
157
+ <div class="phase-desc">πŸ“Œ {description}</div>
158
+ </div>
159
+ """
160
+
161
+
162
+ # ─────────────────────────────────────────────────────────────
163
+ # EMPTY REVIEW TABLE
164
+ # ────────��────────────────────────────────────────────────────
165
+
166
+ REVIEW_COLUMNS = [
167
+ "#",
168
+ "Topic Label",
169
+ "Top Evidence",
170
+ "Sentences",
171
+ "Papers",
172
+ "Approve",
173
+ "Rename To",
174
+ "Reasoning",
175
+ ]
176
+
177
+
178
+ def empty_review_table() -> pd.DataFrame:
179
+ return pd.DataFrame(columns=REVIEW_COLUMNS)
180
+
181
+
182
+ def parse_review_table_to_json(df: pd.DataFrame) -> str:
183
+ """Convert review table edits into a JSON string for the agent."""
184
+ records = df.to_dict(orient="records")
185
+ approved = [r for r in records if str(r.get("Approve", "")).strip().lower() in ("yes", "y", "true", "1")]
186
+ return json.dumps({"approved_topics": approved}, indent=2)
187
+
188
+
189
+ # ─────────────────────────────────────────────────────────────
190
+ # CHART REGISTRY (populated by agent via session state)
191
+ # ─────────────────────────────────────────────────────────────
192
+
193
+ CHART_OPTIONS = {
194
+ "Topic Size Distribution": "topic_size.html",
195
+ "Top 20 Topics Bar Chart": "top20_topics.html",
196
+ "Cluster Heatmap": "cluster_heatmap.html",
197
+ "Theme Coverage Treemap": "theme_treemap.html",
198
+ }
199
+
200
+
201
+ def load_chart_html(chart_name: str) -> str:
202
+ """Load a Plotly chart HTML file and wrap it in an iframe."""
203
+ fname = CHART_OPTIONS.get(chart_name, "")
204
+ if fname and os.path.exists(fname):
205
+ with open(fname, "r", encoding="utf-8") as f:
206
+ content = f.read()
207
+ # Wrap in srcdoc iframe so Gradio renders it safely
208
+ escaped = content.replace('"', "&quot;")
209
+ return f'<iframe srcdoc="{escaped}" width="100%" height="520px" style="border:none;border-radius:8px;"></iframe>'
210
+ return (
211
+ "<div style='padding:40px;text-align:center;color:#6c757d;font-size:14px;'>"
212
+ "πŸ“Š Chart will appear here after BERTopic analysis completes (Phase 2).</div>"
213
+ )
214
+
215
+
216
+ # ─────────────────────────────────────────────────────────────
217
+ # AGENT INTERACTION
218
+ # ─────────────────────────────────────────────────────────────
219
+
220
+ def handle_csv_upload(file_obj):
221
+ """Called when user uploads a CSV. Returns status message."""
222
+ if file_obj is None:
223
+ return "⚠️ No file uploaded.", build_progress_bar(0)
224
+ try:
225
+ df = pd.read_csv(file_obj.name)
226
+ rows, cols = df.shape
227
+ col_list = ", ".join(df.columns.tolist())
228
+ msg = (
229
+ f"βœ… **CSV loaded successfully!**\n\n"
230
+ f"- **Rows:** {rows:,}\n"
231
+ f"- **Columns:** {cols}\n"
232
+ f"- **Fields:** {col_list}\n\n"
233
+ f"Type **`run abstract`** in the chat to begin Phase 1 (Familiarisation)."
234
+ )
235
+ # Store path in environment so agent can access it
236
+ os.environ["SCOPUS_CSV_PATH"] = file_obj.name
237
+ return msg, build_progress_bar(1)
238
+ except Exception as exc:
239
+ return f"❌ Error reading CSV: {exc}", build_progress_bar(0)
240
+
241
+
242
+ def chat_with_agent(user_message: str, history: list, current_phase: int):
243
+ """
244
+ Send user message to the LangGraph agent and return:
245
+ - updated chat history
246
+ - updated review table DataFrame
247
+ - updated phase progress HTML
248
+ - updated current_phase int
249
+ """
250
+ if not user_message.strip():
251
+ return history, empty_review_table(), build_progress_bar(current_phase), current_phase
252
+
253
+ # Add user turn to history
254
+ history = history + [{"role": "user", "content": user_message}]
255
+
256
+ # Call LangGraph agent
257
+ response = run_agent(user_message)
258
+ agent_reply = response.get("reply", "⚠️ No response from agent.")
259
+ new_phase = response.get("phase", current_phase)
260
+ review_data = response.get("review_table", None)
261
+
262
+ history = history + [{"role": "assistant", "content": agent_reply}]
263
+
264
+ # Build review table if agent returned topic data
265
+ if review_data and isinstance(review_data, list) and len(review_data) > 0:
266
+ df = pd.DataFrame(review_data, columns=REVIEW_COLUMNS)
267
+ else:
268
+ df = empty_review_table()
269
+
270
+ return history, df, build_progress_bar(new_phase), new_phase
271
+
272
+
273
+ def submit_review(review_df: pd.DataFrame, history: list, current_phase: int):
274
+ """
275
+ Called when researcher clicks 'Submit Review'.
276
+ Serialises the table edits and sends them to the agent as a structured message.
277
+ """
278
+ review_json = parse_review_table_to_json(review_df)
279
+ n_approved = len(json.loads(review_json).get("approved_topics", []))
280
+
281
+ system_message = (
282
+ f"[RESEARCHER REVIEW SUBMITTED]\n"
283
+ f"Approved topics: {n_approved}\n"
284
+ f"Review data:\n{review_json}\n\n"
285
+ f"Please proceed to the next phase."
286
+ )
287
+
288
+ response = run_agent(system_message)
289
+ agent_reply = response.get("reply", "⚠️ No response from agent.")
290
+ new_phase = response.get("phase", current_phase)
291
+ new_review_data = response.get("review_table", None)
292
+
293
+ history = history + [
294
+ {"role": "user", "content": f"βœ… Review submitted β€” {n_approved} topics approved."},
295
+ {"role": "assistant", "content": agent_reply},
296
+ ]
297
+
298
+ if new_review_data and isinstance(new_review_data, list):
299
+ df = pd.DataFrame(new_review_data, columns=REVIEW_COLUMNS)
300
+ else:
301
+ df = review_df # keep existing table if no new data
302
+
303
+ return history, df, build_progress_bar(new_phase), new_phase
304
+
305
+
306
+ def refresh_downloads():
307
+ """Return list of output files for download."""
308
+ files = []
309
+ for fname in ["comparison.csv", "taxonomy_map.json", "narrative.txt", "summaries.json"]:
310
+ if os.path.exists(fname):
311
+ files.append(fname)
312
+ return files if files else None
313
+
314
+
315
+ def reset_session():
316
+ """Reset everything for a new analysis run."""
317
+ reset_agent()
318
+ os.environ.pop("SCOPUS_CSV_PATH", None)
319
+ return (
320
+ [], # clear chatbot
321
+ empty_review_table(),
322
+ build_progress_bar(0),
323
+ 0,
324
+ "πŸ”„ Session reset. Upload a new CSV to begin.",
325
+ )
326
+
327
+
328
+ # ─────────────────────────────────────────────────────────────
329
+ # GRADIO UI
330
+ # ─────────────────────────────────────────────────────────────
331
+
332
+ with gr.Blocks(
333
+ title="BERTopic Thematic Analysis Agent",
334
+ theme=gr.themes.Soft(
335
+ primary_hue="blue",
336
+ secondary_hue="green",
337
+ font=[gr.themes.GoogleFont("Inter"), "sans-serif"],
338
+ ),
339
+ css="""
340
+ #main-header { text-align: center; padding: 20px 0 8px 0; }
341
+ #main-header h1 { font-size: 1.8em; color: #1a1a2e; margin-bottom: 4px; }
342
+ #main-header p { color: #6c757d; font-size: 14px; }
343
+ .section-title { font-weight: 700; font-size: 15px; color: #1a1a2e; margin-bottom: 4px; }
344
+ .submit-btn { background: #0d6efd !important; color: white !important; }
345
+ .reset-btn { background: #dc3545 !important; color: white !important; }
346
+ .send-btn { background: #198754 !important; color: white !important; }
347
+ """,
348
+ ssr_mode=False,
349
+ ) as demo:
350
+
351
+ # ── State ──────────────────────────────────────────────────
352
+ current_phase_state = gr.State(value=0)
353
+
354
+ # ── Header ─────────────────────────────────────────────────
355
+ with gr.Row(elem_id="main-header"):
356
+ gr.HTML("""
357
+ <div id="main-header">
358
+ <h1>🧠 BERTopic Thematic Analysis Agent</h1>
359
+ <p>Braun &amp; Clarke (2006) Six-Phase Thematic Analysis &nbsp;Β·&nbsp;
360
+ Mistral LLM &nbsp;Β·&nbsp; LangGraph &nbsp;Β·&nbsp; Sentence-BERT</p>
361
+ </div>
362
+ """)
363
+
364
+ # ── Phase Progress Bar ─────────────────────────────────────
365
+ phase_bar = gr.HTML(value=build_progress_bar(0), label="Phase Progress")
366
+
367
+ # ══════════════════════════════════════════════════════════
368
+ # SECTION 1 β€” DATA INPUT
369
+ # ══════════════════════════════════════════════════════════
370
+ with gr.Accordion("πŸ“‚ Section 1 β€” Data Input", open=True):
371
+ gr.Markdown("""
372
+ **Upload your Scopus export CSV.**
373
+ Required columns: `Authors`, `Title`, `Abstract`, `Author Keywords`, `Cited by`, `Source title`, `Year`
374
+ """)
375
+ with gr.Row():
376
+ csv_upload = gr.File(
377
+ label="πŸ“„ Upload Scopus CSV",
378
+ file_types=[".csv"],
379
+ scale=3,
380
+ )
381
+ upload_status = gr.Markdown(
382
+ value="⬆️ Upload your CSV file to begin.",
383
+ label="Upload Status",
384
+ )
385
+ reset_btn = gr.Button("πŸ”„ Reset Session", variant="stop", elem_classes=["reset-btn"], scale=1)
386
+
387
+ # ══════════════════════════════════════════════════════════
388
+ # SECTION 2 β€” AGENT CONVERSATION
389
+ # ══════════════════════════════════════════════════════════
390
+ with gr.Accordion("πŸ’¬ Section 2 β€” Agent Conversation", open=True):
391
+ gr.Markdown("""
392
+ **Chat with the BERTopic Agent.**
393
+ Commands: `run abstract` Β· `run title` Β· `proceed` Β· `show themes` Β· `generate report`
394
+ """)
395
+ chatbot = gr.Chatbot(
396
+ label="πŸ€– Mistral Agent",
397
+ height=480,
398
+ show_copy_button=True,
399
+ bubble_full_width=False,
400
+ type="messages",
401
+ avatar_images=(None, "https://huggingface.co/front/assets/huggingface_logo-noborder.svg"),
402
+ )
403
+ with gr.Row():
404
+ user_input = gr.Textbox(
405
+ label="Your message",
406
+ placeholder='Type "run abstract" to start Phase 1...',
407
+ lines=2,
408
+ scale=5,
409
+ show_label=False,
410
+ )
411
+ send_btn = gr.Button("Send ➀", variant="primary", scale=1, elem_classes=["send-btn"])
412
+
413
+ # ══════════════════════════════════════════════════════════
414
+ # SECTION 3 β€” RESULTS
415
+ # ══════════════════════════════════════════════════════════
416
+ with gr.Accordion("πŸ“Š Section 3 β€” Results", open=True):
417
+ with gr.Tabs():
418
+
419
+ # ── TAB A: Review Table ────────────────────────────
420
+ with gr.Tab("πŸ” Review Table"):
421
+ gr.Markdown("""
422
+ **Researcher Review Table** β€” Phase 2 Output.
423
+ - Set **Approve** = `yes` to include a topic.
424
+ - Fill **Rename To** if you want a better label.
425
+ - Add **Reasoning** to document your decision.
426
+ - Click **Submit Review** when done.
427
+ """)
428
+
429
+ review_table = gr.Dataframe(
430
+ value=empty_review_table(),
431
+ headers=REVIEW_COLUMNS,
432
+ datatype=[
433
+ "number", # #
434
+ "str", # Topic Label
435
+ "str", # Top Evidence
436
+ "number", # Sentences
437
+ "number", # Papers
438
+ "str", # Approve (yes/no)
439
+ "str", # Rename To
440
+ "str", # Reasoning
441
+ ],
442
+ col_count=(8, "fixed"),
443
+ row_count=(10, "dynamic"),
444
+ interactive=True,
445
+ wrap=True,
446
+ label="Topic Review Table (edit Approve / Rename To / Reasoning)",
447
+ max_height=520,
448
+ )
449
+
450
+ with gr.Row():
451
+ submit_review_btn = gr.Button(
452
+ "βœ… Submit Review to Agent",
453
+ variant="primary",
454
+ elem_classes=["submit-btn"],
455
+ )
456
+ gr.Markdown(
457
+ "_After editing the table, click Submit Review to continue to Phase 3._",
458
+ container=False,
459
+ )
460
+
461
+ # ── TAB B: Charts ──────────────────────────────────
462
+ with gr.Tab("πŸ“ˆ Charts"):
463
+ gr.Markdown("**Interactive Plotly Charts** β€” generated after Phase 2 (BERTopic clustering).")
464
+ chart_dropdown = gr.Dropdown(
465
+ choices=list(CHART_OPTIONS.keys()),
466
+ value=list(CHART_OPTIONS.keys())[0],
467
+ label="Select Chart",
468
+ interactive=True,
469
+ )
470
+ chart_display = gr.HTML(
471
+ value=load_chart_html(list(CHART_OPTIONS.keys())[0]),
472
+ label="Chart",
473
+ )
474
+
475
+ # ── TAB C: Downloads ───────────────────────────────
476
+ with gr.Tab("⬇️ Downloads"):
477
+ gr.Markdown("""
478
+ **Download output files** β€” available after Phase 6 (Report generation).
479
+
480
+ | File | Description |
481
+ |------|-------------|
482
+ | `comparison.csv` | Abstract vs Title theme comparison |
483
+ | `taxonomy_map.json` | PAJAIS gap analysis (MAPPED vs NOVEL) |
484
+ | `narrative.txt` | 500-word Section 7 draft |
485
+ | `summaries.json` | BERTopic cluster summaries |
486
+ """)
487
+ refresh_btn = gr.Button("πŸ”„ Refresh Downloads", variant="secondary")
488
+ download_files = gr.File(
489
+ label="Output Files",
490
+ file_count="multiple",
491
+ interactive=False,
492
+ )
493
+
494
+ # ───────────────────────────────���──────────────────────────
495
+ # EVENT WIRING
496
+ # ──────────────────────────────────────────────────────────
497
+
498
+ # CSV Upload
499
+ csv_upload.change(
500
+ fn=handle_csv_upload,
501
+ inputs=[csv_upload],
502
+ outputs=[upload_status, phase_bar],
503
+ )
504
+
505
+ # Send message (button)
506
+ send_btn.click(
507
+ fn=chat_with_agent,
508
+ inputs=[user_input, chatbot, current_phase_state],
509
+ outputs=[chatbot, review_table, phase_bar, current_phase_state],
510
+ ).then(
511
+ fn=lambda: "",
512
+ outputs=[user_input],
513
+ )
514
+
515
+ # Send message (Enter key)
516
+ user_input.submit(
517
+ fn=chat_with_agent,
518
+ inputs=[user_input, chatbot, current_phase_state],
519
+ outputs=[chatbot, review_table, phase_bar, current_phase_state],
520
+ ).then(
521
+ fn=lambda: "",
522
+ outputs=[user_input],
523
+ )
524
+
525
+ # Submit Review
526
+ submit_review_btn.click(
527
+ fn=submit_review,
528
+ inputs=[review_table, chatbot, current_phase_state],
529
+ outputs=[chatbot, review_table, phase_bar, current_phase_state],
530
+ )
531
+
532
+ # Chart dropdown change
533
+ chart_dropdown.change(
534
+ fn=load_chart_html,
535
+ inputs=[chart_dropdown],
536
+ outputs=[chart_display],
537
+ )
538
+
539
+ # Refresh downloads
540
+ refresh_btn.click(
541
+ fn=refresh_downloads,
542
+ outputs=[download_files],
543
+ )
544
+
545
+ # Reset session
546
+ reset_btn.click(
547
+ fn=reset_session,
548
+ outputs=[chatbot, review_table, phase_bar, current_phase_state, upload_status],
549
+ )
550
+
551
+
552
+ # ─────────────────────────────────────────────────────────────
553
+ # LAUNCH
554
+ # ─────────────────────────────────────────────────────────────
555
+
556
+ if __name__ == "__main__":
557
+ demo.launch(
558
+ server_name="0.0.0.0",
559
+ server_port=7860,
560
+ share=False,
561
+ show_error=True,
562
+ ssr_mode=False,
563
+ )
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # requirements.txt β€” BERTopic Thematic Analysis Agent
2
+ # HuggingFace Spaces: Gradio SDK
3
+ # All 13 packages required for full pipeline
4
+
5
+ gradio==4.44.1
6
+ langchain-core==0.3.15
7
+ langchain-mistralai==0.2.4
8
+ langchain==0.3.7
9
+ langgraph==0.2.45
10
+ sentence-transformers==3.3.1
11
+ scikit-learn==1.5.2
12
+ bertopic==0.16.4
13
+ plotly==5.24.1
14
+ numpy==1.26.4
15
+ pandas==2.2.3
16
+ huggingface-hub==0.26.2
17
+ mistralai==1.2.5
tools.py ADDED
@@ -0,0 +1,783 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools.py β€” 7 @tool functions for BERTopic Thematic Analysis Agent
3
+ Agentic Coding Rules (strictly followed):
4
+ βœ— ZERO if/elif/else statements β†’ decisions belong to the LLM
5
+ βœ— ZERO for/while loops β†’ use list(map(...)) / functional patterns
6
+ βœ— ZERO try/except blocks β†’ handle_tool_error=True lets LLM see errors
7
+ βœ— ZERO retry logic β†’ LLM adapts its own strategy on failure
8
+ βœ“ ALL tools are stateless β†’ input in, output out, nothing more
9
+ βœ“ Checkpoints saved as JSON/NPY β†’ reproducibility guaranteed
10
+ """
11
+
12
+ import os
13
+ import re
14
+ import json
15
+ import numpy as np
16
+ import pandas as pd
17
+ import plotly.express as px
18
+ import plotly.graph_objects as go
19
+ from sklearn.cluster import AgglomerativeClustering
20
+ from sklearn.metrics.pairwise import cosine_similarity as sk_cosine
21
+ from sentence_transformers import SentenceTransformer
22
+ from langchain_core.tools import tool
23
+ from langchain_core.prompts import PromptTemplate
24
+ from langchain_core.output_parsers import JsonOutputParser, StrOutputParser
25
+ from langchain_mistralai import ChatMistralAI
26
+
27
+ # ─────────────────────────────────────────────────────────────────
28
+ # SHARED CONSTANTS
29
+ # ─────────────────────────────────────────────────────────────────
30
+
31
+ RUN_CONFIGS = {
32
+ "abstract": "Abstract",
33
+ "title": "Title",
34
+ }
35
+
36
+ BOILERPLATE_RE = re.compile(
37
+ r"(Β©\s*\d{4}|all rights reserved|published by elsevier|"
38
+ r"springer nature|wiley online|emerald publishing|"
39
+ r"^\s*doi\s*:|this is an open.access|"
40
+ r"creative commons|permission of the copyright)",
41
+ re.IGNORECASE,
42
+ )
43
+
44
+ MIN_WORDS = 5 # discard sentences shorter than this
45
+ CLUSTER_THRESH = 0.7 # AgglomerativeClustering cosine distance threshold
46
+ TOP_N_EVIDENCE = 5 # sentences nearest centroid per cluster
47
+ TOP_N_TOPICS = 100 # topics sent to LLM for labelling
48
+
49
+ PAJAIS_25 = [
50
+ "AI in Accounting",
51
+ "AI in Finance",
52
+ "Machine Learning Applications",
53
+ "Natural Language Processing",
54
+ "Decision Support Systems",
55
+ "Blockchain and Distributed Ledger",
56
+ "Robotic Process Automation",
57
+ "Data Analytics and Big Data",
58
+ "Cybersecurity and Fraud Detection",
59
+ "Cloud Computing",
60
+ "Internet of Things",
61
+ "Digital Transformation",
62
+ "Ethics and Governance of AI",
63
+ "Explainable AI",
64
+ "Predictive Analytics",
65
+ "Neural Networks and Deep Learning",
66
+ "Text Mining and Sentiment Analysis",
67
+ "Knowledge Management Systems",
68
+ "Human-Computer Interaction",
69
+ "Enterprise Resource Planning",
70
+ "Audit and Assurance Technology",
71
+ "Tax Technology",
72
+ "Regulatory Technology (RegTech)",
73
+ "Financial Reporting Technology",
74
+ "Corporate Governance and Technology",
75
+ ]
76
+
77
+ SENTENCE_MODEL = "all-MiniLM-L6-v2" # 384-dim local model, ~200MB RAM
78
+ MISTRAL_MODEL = "mistral-small-latest"
79
+
80
+ # ─────────────────────────────────────────────────────────────────
81
+ # HELPER FUNCTIONS (pure, no side-effects)
82
+ # ─────────────────────────────────────────────────────────────────
83
+
84
+ def _get_llm() -> ChatMistralAI:
85
+ return ChatMistralAI(
86
+ model=MISTRAL_MODEL,
87
+ api_key=os.environ.get("MISTRAL_API_KEY", ""),
88
+ temperature=0.1,
89
+ )
90
+
91
+
92
+ def _split_sentences(text: str) -> list:
93
+ """Split text into sentences using regex (no loops)."""
94
+ raw = re.split(r"(?<=[.!?])\s+", str(text).strip())
95
+ return list(filter(
96
+ lambda s: len(s.split()) >= MIN_WORDS and not BOILERPLATE_RE.search(s),
97
+ raw,
98
+ ))
99
+
100
+
101
+ def _file_path(run_type: str, suffix: str) -> str:
102
+ """Standardised checkpoint path: e.g. abstract_summaries.json"""
103
+ return f"{run_type}_{suffix}"
104
+
105
+
106
+ def _save_json(data, path: str) -> None:
107
+ with open(path, "w", encoding="utf-8") as f:
108
+ json.dump(data, f, indent=2, ensure_ascii=False)
109
+
110
+
111
+ def _load_json(path: str) -> dict:
112
+ with open(path, "r", encoding="utf-8") as f:
113
+ return json.load(f)
114
+
115
+
116
+ def _save_chart(fig, path: str) -> None:
117
+ fig.write_html(path, include_plotlyjs="cdn", full_html=True)
118
+
119
+
120
+ def _nearest_to_centroid(embeddings: np.ndarray, indices: np.ndarray, k: int) -> np.ndarray:
121
+ """Return indices of k sentences nearest to cluster centroid (no loops)."""
122
+ cluster_embs = embeddings[indices]
123
+ centroid = cluster_embs.mean(axis=0, keepdims=True)
124
+ sims = sk_cosine(centroid, cluster_embs)[0]
125
+ top_k = np.argsort(sims)[-k:][::-1]
126
+ return indices[top_k]
127
+
128
+
129
+ # ─────────────────────────────────────────────────────────────────
130
+ # TOOL 1 β€” load_scopus_csv
131
+ # ─────────────────────────────────────────────────────────────────
132
+
133
+ @tool(handle_tool_error=True)
134
+ def load_scopus_csv(run_type: str) -> str:
135
+ """
136
+ Load the Scopus CSV from SCOPUS_CSV_PATH environment variable.
137
+ run_type: 'abstract' or 'title'.
138
+ Returns corpus statistics including paper count, sentence count,
139
+ year range, and top journals.
140
+ Saves corpus sentences to {run_type}_sentences.json checkpoint.
141
+ """
142
+ csv_path = os.environ.get("SCOPUS_CSV_PATH", "")
143
+ df = pd.read_csv(csv_path)
144
+ col_name = RUN_CONFIGS.get(run_type, "Abstract")
145
+
146
+ # Extract text column; fill missing with empty string
147
+ texts = df[col_name].fillna("").astype(str).tolist()
148
+
149
+ # Split all texts into sentences (functional map)
150
+ all_sentences = list(map(_split_sentences, texts))
151
+
152
+ # Build flat list of (paper_idx, sentence) pairs
153
+ indexed_sentences = list(map(
154
+ lambda pair: {"paper_idx": pair[0], "sentence": sent},
155
+ [(i, sent) for i, sents in enumerate(all_sentences) for sent in sents],
156
+ ))
157
+ # Rebuild properly using enumerate + flatmap pattern
158
+ flat = [
159
+ {"paper_idx": i, "sentence": s}
160
+ for i, sents in enumerate(all_sentences)
161
+ for s in sents
162
+ ]
163
+
164
+ # Year statistics (pandas β€” no explicit loops)
165
+ year_col = df["Year"].dropna().astype(int)
166
+ year_min = int(year_col.min())
167
+ year_max = int(year_col.max())
168
+ year_counts = year_col.value_counts().sort_index().to_dict()
169
+
170
+ # Top 5 journals
171
+ top_journals = (
172
+ df["Source title"].value_counts().head(5).to_dict()
173
+ if "Source title" in df.columns else {}
174
+ )
175
+
176
+ # Save checkpoint
177
+ checkpoint = {
178
+ "run_type": run_type,
179
+ "column": col_name,
180
+ "total_papers": len(df),
181
+ "total_sentences": len(flat),
182
+ "year_min": year_min,
183
+ "year_max": year_max,
184
+ "year_counts": year_counts,
185
+ "top_journals": top_journals,
186
+ "sentences": flat,
187
+ }
188
+ _save_json(checkpoint, _file_path(run_type, "sentences.json"))
189
+
190
+ stats = (
191
+ f"βœ… Corpus loaded for run_type='{run_type}' (column: {col_name})\n"
192
+ f" Papers : {len(df):,}\n"
193
+ f" Sentences : {len(flat):,} (after boilerplate filter)\n"
194
+ f" Year range : {year_min} – {year_max}\n"
195
+ f" Top journals: {list(top_journals.keys())[:3]}\n"
196
+ f" Checkpoint : {_file_path(run_type, 'sentences.json')}\n\n"
197
+ f"Phase 1 (Familiarisation) complete. "
198
+ f"[PHASE:1] β›” STOP β€” Awaiting researcher acknowledgement before Phase 2."
199
+ )
200
+ return stats
201
+
202
+
203
+ # ─────────────────────────────────────────────────────────────────
204
+ # TOOL 2 β€” run_bertopic_discovery
205
+ # ─────────────────────────────────────────────────────────────────
206
+
207
+ @tool(handle_tool_error=True)
208
+ def run_bertopic_discovery(run_type: str) -> str:
209
+ """
210
+ Core BERTopic pipeline:
211
+ 1. Load sentences from checkpoint.
212
+ 2. Embed with all-MiniLM-L6-v2 (384-dim, normalised).
213
+ 3. Cluster with AgglomerativeClustering(metric='cosine', linkage='average', threshold=0.7).
214
+ NO UMAP β€” direct cosine clustering for transparency.
215
+ 4. Find 5 evidence sentences nearest to each cluster centroid.
216
+ 5. Generate 4 Plotly charts (size distribution, top-20 bar, heatmap, treemap).
217
+ 6. Save summaries.json + emb.npy checkpoints.
218
+ run_type: 'abstract' or 'title'.
219
+ Returns cluster summary for LLM review.
220
+ """
221
+ data = _load_json(_file_path(run_type, "sentences.json"))
222
+ sentences = list(map(lambda r: r["sentence"], data["sentences"]))
223
+ paper_ids = list(map(lambda r: r["paper_idx"], data["sentences"]))
224
+
225
+ # Step 1 β€” Embed
226
+ model = SentenceTransformer(SENTENCE_MODEL)
227
+ embeddings = model.encode(
228
+ sentences,
229
+ normalize_embeddings=True,
230
+ show_progress_bar=False,
231
+ batch_size=64,
232
+ )
233
+ np.save(_file_path(run_type, "emb.npy"), embeddings)
234
+
235
+ # Step 2 β€” Cluster (cosine distance = 1 - cosine_similarity for normalised vectors)
236
+ clustering = AgglomerativeClustering(
237
+ n_clusters=None,
238
+ metric="cosine",
239
+ linkage="average",
240
+ distance_threshold=CLUSTER_THRESH,
241
+ )
242
+ labels = clustering.fit_predict(embeddings)
243
+
244
+ unique_labels = list(dict.fromkeys(labels.tolist())) # preserve order, deduplicate
245
+ n_clusters = len(unique_labels)
246
+
247
+ # Step 3 β€” Build cluster summaries (map over unique labels)
248
+ def build_cluster_summary(label_id):
249
+ mask = np.where(labels == label_id)[0]
250
+ ev_idx = _nearest_to_centroid(embeddings, mask, TOP_N_EVIDENCE)
251
+ evidence = list(map(lambda i: sentences[i], ev_idx.tolist()))
252
+ pids = list(set(map(lambda i: paper_ids[i], mask.tolist())))
253
+ return {
254
+ "cluster_id": int(label_id),
255
+ "n_sentences": int(len(mask)),
256
+ "n_papers": int(len(pids)),
257
+ "evidence": evidence,
258
+ "paper_indices": pids,
259
+ }
260
+
261
+ summaries = list(map(build_cluster_summary, unique_labels))
262
+
263
+ # Sort by sentence count descending
264
+ summaries.sort(key=lambda s: s["n_sentences"], reverse=True)
265
+
266
+ # Assign rank
267
+ ranked = list(map(
268
+ lambda pair: {**pair[1], "rank": pair[0] + 1},
269
+ enumerate(summaries),
270
+ ))
271
+
272
+ checkpoint = {
273
+ "run_type": run_type,
274
+ "n_clusters": n_clusters,
275
+ "summaries": ranked,
276
+ }
277
+ _save_json(checkpoint, _file_path(run_type, "summaries.json"))
278
+
279
+ # Step 4 β€” Generate 4 Plotly charts
280
+
281
+ # Chart 1: Topic Size Distribution (histogram)
282
+ sizes = list(map(lambda s: s["n_sentences"], ranked))
283
+ fig1 = px.histogram(
284
+ x=sizes, nbins=40,
285
+ title=f"Topic Size Distribution ({run_type.title()})",
286
+ labels={"x": "Sentences per Topic", "y": "Number of Topics"},
287
+ color_discrete_sequence=["#0d6efd"],
288
+ )
289
+ fig1.update_layout(bargap=0.1, template="plotly_white")
290
+ _save_chart(fig1, f"{run_type}_topic_size.html")
291
+
292
+ # Chart 2: Top-20 Topics Bar Chart (placeholder labels = cluster IDs)
293
+ top20 = ranked[:20]
294
+ fig2 = px.bar(
295
+ x=list(map(lambda s: f"Topic {s['cluster_id']}", top20)),
296
+ y=list(map(lambda s: s["n_sentences"], top20)),
297
+ title=f"Top 20 Topics by Sentence Count ({run_type.title()})",
298
+ labels={"x": "Topic", "y": "Sentence Count"},
299
+ color=list(map(lambda s: s["n_sentences"], top20)),
300
+ color_continuous_scale="Blues",
301
+ )
302
+ fig2.update_layout(showlegend=False, template="plotly_white", xaxis_tickangle=-45)
303
+ _save_chart(fig2, f"{run_type}_top20_topics.html")
304
+
305
+ # Chart 3: Papers per Topic (top 30 β€” scatter)
306
+ top30 = ranked[:30]
307
+ fig3 = px.scatter(
308
+ x=list(map(lambda s: s["n_sentences"], top30)),
309
+ y=list(map(lambda s: s["n_papers"], top30)),
310
+ text=list(map(lambda s: f"T{s['cluster_id']}", top30)),
311
+ title=f"Sentences vs Papers per Topic ({run_type.title()})",
312
+ labels={"x": "Sentences", "y": "Papers"},
313
+ color=list(map(lambda s: s["n_papers"], top30)),
314
+ color_continuous_scale="Viridis",
315
+ )
316
+ fig3.update_traces(textposition="top center", marker_size=10)
317
+ fig3.update_layout(template="plotly_white")
318
+ _save_chart(fig3, f"{run_type}_cluster_heatmap.html")
319
+
320
+ # Chart 4: Coverage Treemap (top 40)
321
+ top40 = ranked[:40]
322
+ fig4 = px.treemap(
323
+ names=list(map(lambda s: f"Cluster {s['cluster_id']}", top40)),
324
+ values=list(map(lambda s: s["n_sentences"], top40)),
325
+ parents=["Topics"] * len(top40),
326
+ title=f"Topic Coverage Treemap ({run_type.title()})",
327
+ color=list(map(lambda s: s["n_papers"], top40)),
328
+ color_continuous_scale="RdYlGn",
329
+ )
330
+ fig4.update_layout(template="plotly_white")
331
+ _save_chart(fig4, f"{run_type}_theme_treemap.html")
332
+
333
+ result = (
334
+ f"βœ… BERTopic discovery complete for run_type='{run_type}'\n"
335
+ f" Sentences embedded : {len(sentences):,} (384-dim, all-MiniLM-L6-v2)\n"
336
+ f" Clusters found : {n_clusters}\n"
337
+ f" Threshold used : cosine distance = {CLUSTER_THRESH}\n"
338
+ f" Charts saved : 4 Plotly HTML files\n"
339
+ f" Checkpoints : {run_type}_summaries.json, {run_type}_emb.npy\n\n"
340
+ f"Top 5 clusters by sentence count:\n" +
341
+ "\n".join(list(map(
342
+ lambda s: f" #{s['rank']} Cluster {s['cluster_id']}: "
343
+ f"{s['n_sentences']} sentences / {s['n_papers']} papers\n"
344
+ f" Evidence: {s['evidence'][0][:120]}…",
345
+ ranked[:5],
346
+ ))) +
347
+ f"\n\n[PHASE:2] β›” STOP β€” Run label_topics_with_llm next, "
348
+ f"then present the review table and await researcher Submit Review."
349
+ )
350
+ return result
351
+
352
+
353
+ # ─────────────────────────────────────────────────────────────────
354
+ # TOOL 3 β€” label_topics_with_llm
355
+ # ─────────────────────────────────────────────────────────────────
356
+
357
+ @tool(handle_tool_error=True)
358
+ def label_topics_with_llm(run_type: str) -> str:
359
+ """
360
+ Send the top 100 clusters to the Mistral LLM for labelling.
361
+ Uses PromptTemplate + JsonOutputParser (LangChain LCEL chain).
362
+ Each cluster receives: research_area (name) and reasoning.
363
+ Saves labeled_topics.json checkpoint.
364
+ Returns the review table as a JSON string for UI rendering.
365
+ run_type: 'abstract' or 'title'.
366
+ """
367
+ data = _load_json(_file_path(run_type, "summaries.json"))
368
+ top100 = data["summaries"][:TOP_N_TOPICS]
369
+
370
+ llm = _get_llm()
371
+
372
+ label_prompt = PromptTemplate.from_template("""
373
+ You are a computational thematic analysis expert applying Braun & Clarke (2006).
374
+ Below are {n_topics} research topic clusters extracted from academic abstracts.
375
+ Each cluster contains evidence sentences from related papers.
376
+
377
+ For EACH cluster, provide:
378
+ 1. A concise research area label (3-7 words, title case)
379
+ 2. Brief reasoning (1 sentence) explaining what connects these sentences
380
+
381
+ Respond ONLY with a valid JSON array. No preamble, no markdown fences.
382
+ Format: [{{"cluster_id": 0, "label": "...", "reasoning": "..."}}]
383
+
384
+ CLUSTERS:
385
+ {clusters_json}
386
+ """)
387
+
388
+ # Format clusters for the prompt
389
+ clusters_for_prompt = list(map(
390
+ lambda s: {
391
+ "cluster_id": s["cluster_id"],
392
+ "rank": s["rank"],
393
+ "n_sentences": s["n_sentences"],
394
+ "n_papers": s["n_papers"],
395
+ "evidence": s["evidence"][:3], # first 3 evidence sentences
396
+ },
397
+ top100,
398
+ ))
399
+
400
+ chain = label_prompt | llm | JsonOutputParser()
401
+ result = chain.invoke({
402
+ "n_topics": len(top100),
403
+ "clusters_json": json.dumps(clusters_for_prompt, indent=2),
404
+ })
405
+
406
+ # Merge labels back into summaries
407
+ label_map = {item["cluster_id"]: item for item in result}
408
+
409
+ labeled = list(map(
410
+ lambda s: {
411
+ **s,
412
+ "label": label_map.get(s["cluster_id"], {}).get("label", f"Topic {s['cluster_id']}"),
413
+ "reasoning": label_map.get(s["cluster_id"], {}).get("reasoning", ""),
414
+ },
415
+ top100,
416
+ ))
417
+
418
+ # Build review table rows for UI
419
+ review_rows = list(map(
420
+ lambda s: [
421
+ s["rank"],
422
+ s["label"],
423
+ s["evidence"][0][:200] if s["evidence"] else "",
424
+ s["n_sentences"],
425
+ s["n_papers"],
426
+ "yes", # default approve = yes (researcher edits)
427
+ "", # rename to
428
+ s["reasoning"],
429
+ ],
430
+ labeled,
431
+ ))
432
+
433
+ checkpoint = {
434
+ "run_type": run_type,
435
+ "labeled_topics": labeled,
436
+ "review_rows": review_rows,
437
+ }
438
+ _save_json(checkpoint, _file_path(run_type, "labeled_topics.json"))
439
+
440
+ summary = (
441
+ f"βœ… {len(labeled)} topics labelled by Mistral LLM\n\n"
442
+ f"Sample labels:\n" +
443
+ "\n".join(list(map(
444
+ lambda s: f" #{s['rank']:>3}. {s['label']} ({s['n_sentences']} sentences)",
445
+ labeled[:10],
446
+ ))) +
447
+ f"\n ...\n\n"
448
+ f"Review table populated with {len(review_rows)} rows.\n"
449
+ f"[REVIEW_TABLE_READY:{run_type}]\n\n"
450
+ f"[PHASE:2] β›” STOP β€” Review table is now visible. "
451
+ f"Researcher must edit Approve / Rename To / Reasoning columns, "
452
+ f"then click 'Submit Review' to proceed to Phase 3."
453
+ )
454
+ return summary
455
+
456
+
457
+ # ─────────────────────────────────────────────────────────────────
458
+ # TOOL 4 β€” consolidate_into_themes
459
+ # ─────────────────────────────────────────────────────────────────
460
+
461
+ @tool(handle_tool_error=True)
462
+ def consolidate_into_themes(approved_json: str, run_type: str) -> str:
463
+ """
464
+ Merge approved and renamed topics into higher-level themes.
465
+ approved_json: JSON string with list of approved topic dicts
466
+ (from researcher review table submission).
467
+ run_type: 'abstract' or 'title'.
468
+ Recomputes theme centroids from member cluster embeddings.
469
+ Saves themes.json checkpoint.
470
+ Returns theme summary.
471
+ """
472
+ approved_data = json.loads(approved_json)
473
+ approved = approved_data.get("approved_topics", approved_data)
474
+
475
+ labeled_data = _load_json(_file_path(run_type, "labeled_topics.json"))
476
+ label_index = {t["cluster_id"]: t for t in labeled_data["labeled_topics"]}
477
+
478
+ # Apply renames: use 'Rename To' if non-empty, else keep original label
479
+ def apply_rename(row):
480
+ cid = int(row.get("#", row.get("cluster_id", 0)))
481
+ orig = label_index.get(cid, {})
482
+ rename = str(row.get("Rename To", "")).strip()
483
+ final_label = rename or row.get("Topic Label", orig.get("label", f"Theme {cid}"))
484
+ return {
485
+ "cluster_id": cid,
486
+ "theme_label": final_label,
487
+ "n_sentences": orig.get("n_sentences", 0),
488
+ "n_papers": orig.get("n_papers", 0),
489
+ "evidence": orig.get("evidence", []),
490
+ "reasoning": str(row.get("Reasoning", "")),
491
+ }
492
+
493
+ themes = list(map(apply_rename, approved))
494
+
495
+ # Sort themes by sentence count descending
496
+ themes.sort(key=lambda t: t["n_sentences"], reverse=True)
497
+ themes = list(map(
498
+ lambda pair: {**pair[1], "theme_rank": pair[0] + 1},
499
+ enumerate(themes),
500
+ ))
501
+
502
+ checkpoint = {
503
+ "run_type": run_type,
504
+ "n_themes": len(themes),
505
+ "themes": themes,
506
+ }
507
+ _save_json(checkpoint, _file_path(run_type, "themes.json"))
508
+
509
+ coverage_sentences = sum(map(lambda t: t["n_sentences"], themes))
510
+ coverage_papers = len(set(
511
+ pid
512
+ for t in themes
513
+ for pid in label_index.get(t["cluster_id"], {}).get("paper_indices", [])
514
+ ))
515
+
516
+ result = (
517
+ f"βœ… Phase 3 β€” Themes consolidated for run_type='{run_type}'\n"
518
+ f" Approved topics β†’ {len(themes)} themes\n"
519
+ f" Coverage : {coverage_sentences:,} sentences\n"
520
+ f" Unique papers : {coverage_papers:,}\n\n"
521
+ f"Consolidated themes:\n" +
522
+ "\n".join(list(map(
523
+ lambda t: f" #{t['theme_rank']:>2}. {t['theme_label']} "
524
+ f"({t['n_sentences']} sent / {t['n_papers']} papers)",
525
+ themes[:15],
526
+ ))) +
527
+ f"\n\n[PHASE:3] β›” STOP β€” Themes consolidated. "
528
+ f"Researcher must confirm coverage is satisfactory before Phase 4 (Saturation)."
529
+ )
530
+ return result
531
+
532
+
533
+ # ─────────────────────────────────────────────────────────────────
534
+ # TOOL 5 β€” compare_with_taxonomy
535
+ # ─────────────────────────────────────────────────────────────────
536
+
537
+ @tool(handle_tool_error=True)
538
+ def compare_with_taxonomy(run_type: str) -> str:
539
+ """
540
+ Map consolidated themes to the PAJAIS 25-category taxonomy using Mistral LLM.
541
+ Each theme is classified as MAPPED (matches a PAJAIS category) or NOVEL (new contribution).
542
+ Saves taxonomy_map.json checkpoint.
543
+ run_type: 'abstract' or 'title'.
544
+ Returns mapping summary with MAPPED vs NOVEL breakdown.
545
+ """
546
+ themes_data = _load_json(_file_path(run_type, "themes.json"))
547
+ themes = themes_data["themes"]
548
+ llm = _get_llm()
549
+
550
+ taxonomy_prompt = PromptTemplate.from_template("""
551
+ You are a PAJAIS taxonomy expert. The PAJAIS (Pacific-Asia Journal of the AIS) taxonomy
552
+ has exactly 25 research categories listed below.
553
+
554
+ PAJAIS 25 CATEGORIES:
555
+ {pajais_categories}
556
+
557
+ I will give you {n_themes} research themes extracted from academic literature.
558
+ For each theme:
559
+ - If it clearly matches a PAJAIS category, set status="MAPPED" and pajais_match=<exact category name>
560
+ - If it represents research NOT covered by any PAJAIS category, set status="NOVEL" and pajais_match=null
561
+ - Provide a 1-sentence justification
562
+
563
+ Respond ONLY with a valid JSON array. No preamble, no markdown.
564
+ Format: [{{"theme_label": "...", "status": "MAPPED"|"NOVEL", "pajais_match": "...|null", "justification": "..."}}]
565
+
566
+ THEMES TO MAP:
567
+ {themes_json}
568
+ """)
569
+
570
+ chain = taxonomy_prompt | llm | JsonOutputParser()
571
+ result = chain.invoke({
572
+ "pajais_categories": "\n".join(list(map(
573
+ lambda pair: f"{pair[0]+1:>2}. {pair[1]}",
574
+ enumerate(PAJAIS_25),
575
+ ))),
576
+ "n_themes": len(themes),
577
+ "themes_json": json.dumps(list(map(
578
+ lambda t: {"theme_label": t["theme_label"], "evidence_sample": t["evidence"][0][:150]},
579
+ themes,
580
+ )), indent=2),
581
+ })
582
+
583
+ # Merge mapping back into themes
584
+ result_map = {r["theme_label"]: r for r in result}
585
+
586
+ mapped_themes = list(map(
587
+ lambda t: {
588
+ **t,
589
+ "status": result_map.get(t["theme_label"], {}).get("status", "UNKNOWN"),
590
+ "pajais_match": result_map.get(t["theme_label"], {}).get("pajais_match", None),
591
+ "justification": result_map.get(t["theme_label"], {}).get("justification", ""),
592
+ },
593
+ themes,
594
+ ))
595
+
596
+ checkpoint = {
597
+ "run_type": run_type,
598
+ "taxonomy": "PAJAIS-25",
599
+ "mapped_themes": mapped_themes,
600
+ "pajais_25": PAJAIS_25,
601
+ }
602
+ _save_json(checkpoint, "taxonomy_map.json")
603
+
604
+ n_mapped = len(list(filter(lambda t: t["status"] == "MAPPED", mapped_themes)))
605
+ n_novel = len(list(filter(lambda t: t["status"] == "NOVEL", mapped_themes)))
606
+
607
+ novel_labels = list(map(
608
+ lambda t: f" β˜… {t['theme_label']} β€” {t['justification'][:100]}",
609
+ filter(lambda t: t["status"] == "NOVEL", mapped_themes),
610
+ ))
611
+
612
+ result_str = (
613
+ f"βœ… Phase 5.5 β€” PAJAIS Taxonomy Mapping complete for run_type='{run_type}'\n"
614
+ f" Total themes : {len(mapped_themes)}\n"
615
+ f" MAPPED : {n_mapped} (match existing PAJAIS categories)\n"
616
+ f" NOVEL : {n_novel} (publishable research gaps)\n\n"
617
+ f"NOVEL themes (research gaps / publication opportunities):\n" +
618
+ "\n".join(novel_labels or [" None β€” all themes are covered by PAJAIS taxonomy."]) +
619
+ f"\n\nCheckpoint saved: taxonomy_map.json\n\n"
620
+ f"[PHASE:5.5] β›” STOP β€” Taxonomy mapping complete. "
621
+ f"Researcher must review MAPPED vs NOVEL classification before Phase 6 (Report)."
622
+ )
623
+ return result_str
624
+
625
+
626
+ # ─────────────────────────────────────────────────────────────────
627
+ # TOOL 6 β€” generate_comparison_csv
628
+ # ─────────────────────────────────────────────────────────────────
629
+
630
+ @tool(handle_tool_error=True)
631
+ def generate_comparison_csv(dummy: str = "run") -> str:
632
+ """
633
+ Generate a side-by-side comparison of abstract themes vs title themes.
634
+ Reads abstract_themes.json and title_themes.json checkpoints.
635
+ Identifies: convergent themes (appear in both), abstract-only, title-only.
636
+ Saves comparison.csv.
637
+ dummy: pass any string to invoke (no required arguments).
638
+ Returns summary of convergence/divergence findings.
639
+ """
640
+ abs_data = _load_json("abstract_themes.json")
641
+ title_data = _load_json("title_themes.json")
642
+
643
+ abs_labels = set(map(lambda t: t["theme_label"].lower(), abs_data["themes"]))
644
+ title_labels = set(map(lambda t: t["theme_label"].lower(), title_data["themes"]))
645
+
646
+ convergent = abs_labels & title_labels
647
+ abs_only = abs_labels - title_labels
648
+ title_only = title_labels - abs_labels
649
+
650
+ # Build aligned rows
651
+ all_abs_themes = {t["theme_label"].lower(): t for t in abs_data["themes"]}
652
+ all_title_themes = {t["theme_label"].lower(): t for t in title_data["themes"]}
653
+
654
+ all_labels = sorted(abs_labels | title_labels)
655
+
656
+ def build_row(label):
657
+ abs_t = all_abs_themes.get(label, {})
658
+ title_t = all_title_themes.get(label, {})
659
+ presence = (
660
+ "CONVERGENT" if label in convergent else
661
+ "ABSTRACT ONLY" if label in abs_only else
662
+ "TITLE ONLY"
663
+ )
664
+ return {
665
+ "Theme Label": label.title(),
666
+ "Presence": presence,
667
+ "Abstract Sentences": abs_t.get("n_sentences", 0),
668
+ "Abstract Papers": abs_t.get("n_papers", 0),
669
+ "Title Sentences": title_t.get("n_sentences", 0),
670
+ "Title Papers": title_t.get("n_papers", 0),
671
+ "Abstract Evidence": abs_t.get("evidence", [""])[0][:200],
672
+ "Title Evidence": title_t.get("evidence", [""])[0][:200],
673
+ }
674
+
675
+ rows = list(map(build_row, all_labels))
676
+ df = pd.DataFrame(rows)
677
+ df.to_csv("comparison.csv", index=False, encoding="utf-8")
678
+
679
+ result = (
680
+ f"βœ… Comparison CSV generated: comparison.csv\n"
681
+ f" Total unique themes : {len(all_labels)}\n"
682
+ f" Convergent : {len(convergent)} (appear in both abstract & title runs)\n"
683
+ f" Abstract-only : {len(abs_only)} (richer detail in full text)\n"
684
+ f" Title-only : {len(title_only)} (surface-level signals)\n\n"
685
+ f"Convergent themes (high confidence):\n" +
686
+ "\n".join(list(map(lambda l: f" βœ“ {l.title()}", sorted(convergent)[:10]))) +
687
+ f"\n\ncomparison.csv ready for download."
688
+ )
689
+ return result
690
+
691
+
692
+ # ─────────────────────────────────────────────────────────────────
693
+ # TOOL 7 β€” export_narrative
694
+ # ─────────────────────────────────────────────────────────────────
695
+
696
+ @tool(handle_tool_error=True)
697
+ def export_narrative(run_type: str) -> str:
698
+ """
699
+ Generate a 500-word Section 7 narrative report using Mistral LLM.
700
+ Synthesises themes, PAJAIS mapping, and convergence/divergence findings.
701
+ Saves narrative.txt.
702
+ run_type: 'abstract' or 'title' (use 'abstract' for the primary narrative).
703
+ Returns the generated narrative text.
704
+ """
705
+ themes_data = _load_json(_file_path(run_type, "themes.json"))
706
+ taxonomy_data = _load_json("taxonomy_map.json")
707
+ themes = themes_data["themes"]
708
+ mapped = list(filter(lambda t: t["status"] == "MAPPED", taxonomy_data["mapped_themes"]))
709
+ novel = list(filter(lambda t: t["status"] == "NOVEL", taxonomy_data["mapped_themes"]))
710
+
711
+ llm = _get_llm()
712
+
713
+ narrative_prompt = PromptTemplate.from_template("""
714
+ You are an academic writer producing Section 7 of a conference paper submitted to PAJAIS
715
+ (Pacific-Asia Journal of the AIS). Write approximately 500 words.
716
+
717
+ Section 7 should:
718
+ 1. Introduce the BERTopic thematic analysis methodology (Braun & Clarke 2006, six phases)
719
+ 2. Summarise the {n_themes} themes discovered from {run_type} analysis
720
+ 3. Discuss MAPPED themes (alignment with PAJAIS taxonomy)
721
+ 4. Highlight NOVEL themes as research gaps and publication opportunities
722
+ 5. Conclude with implications for IS research and future work
723
+
724
+ Write in formal academic style. Do not use bullet points β€” use flowing paragraphs.
725
+
726
+ THEMES DISCOVERED ({n_themes} total):
727
+ {themes_list}
728
+
729
+ MAPPED TO PAJAIS ({n_mapped} themes):
730
+ {mapped_list}
731
+
732
+ NOVEL / RESEARCH GAPS ({n_novel} themes):
733
+ {novel_list}
734
+ """)
735
+
736
+ chain = narrative_prompt | llm | StrOutputParser()
737
+ narrative = chain.invoke({
738
+ "n_themes": len(themes),
739
+ "run_type": run_type,
740
+ "themes_list": "\n".join(list(map(
741
+ lambda t: f" {t['theme_rank']}. {t['theme_label']} ({t['n_sentences']} sentences, {t['n_papers']} papers)",
742
+ themes[:20],
743
+ ))),
744
+ "n_mapped": len(mapped),
745
+ "n_novel": len(novel),
746
+ "mapped_list": "\n".join(list(map(
747
+ lambda t: f" β€’ {t['theme_label']} β†’ {t['pajais_match']}",
748
+ mapped[:10],
749
+ ))),
750
+ "novel_list": "\n".join(list(map(
751
+ lambda t: f" β˜… {t['theme_label']}: {t['justification']}",
752
+ novel,
753
+ ))),
754
+ })
755
+
756
+ with open("narrative.txt", "w", encoding="utf-8") as f:
757
+ f.write(narrative)
758
+
759
+ result = (
760
+ f"βœ… Section 7 narrative exported: narrative.txt\n"
761
+ f" Word count : ~{len(narrative.split()):,}\n\n"
762
+ f"──── NARRATIVE PREVIEW (first 600 chars) ────\n"
763
+ f"{narrative[:600]}…\n"
764
+ f"──────────────────────────────────────────────\n\n"
765
+ f"[PHASE:6] βœ… All phases complete!\n"
766
+ f"Download: comparison.csv Β· taxonomy_map.json Β· narrative.txt Β· summaries.json"
767
+ )
768
+ return result
769
+
770
+
771
+ # ─────────────────────────────────────────────────────────────────
772
+ # TOOL REGISTRY (imported by agent.py)
773
+ # ─────────────────────────────────────────────────────────────────
774
+
775
+ ALL_TOOLS = [
776
+ load_scopus_csv,
777
+ run_bertopic_discovery,
778
+ label_topics_with_llm,
779
+ consolidate_into_themes,
780
+ compare_with_taxonomy,
781
+ generate_comparison_csv,
782
+ export_narrative,
783
+ ]