Spaces:
Sleeping
Sleeping
Upload 5 files
Browse files- README.md +196 -10
- agent.py +393 -0
- app.py +563 -0
- requirements.txt +17 -0
- tools.py +783 -0
README.md
CHANGED
|
@@ -1,13 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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('"', """)
|
| 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 & Clarke (2006) Six-Phase Thematic Analysis Β·
|
| 360 |
+
Mistral LLM Β· LangGraph Β· 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 |
+
]
|