CoolDataScientist commited on
Commit
5c500a8
Β·
verified Β·
1 Parent(s): 3bddc24

Upload 7 files

Browse files
Files changed (7) hide show
  1. .gitattributes +36 -35
  2. README.md +204 -13
  3. agent.py +519 -0
  4. app.py +812 -0
  5. logo.png +3 -0
  6. requirements.txt +17 -0
  7. tools.py +1103 -0
.gitattributes CHANGED
@@ -1,35 +1,36 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ logo.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,204 @@
1
- ---
2
- title: Bertopic Agent Final
3
- emoji: πŸ“ˆ
4
- colorFrom: red
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.15.1
8
- python_version: '3.13'
9
- app_file: app.py
10
- pinned: false
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Bertopic Agent V2
3
+ emoji: πŸ“‰
4
+ colorFrom: yellow
5
+ colorTo: blue
6
+ sdk: gradio
7
+ sdk_version: 6.14.0
8
+ python_version: '3.13'
9
+ app_file: app.py
10
+ pinned: false
11
+ license: mit
12
+ ---
13
+
14
+ # πŸ”¬ BERTopic Agentic Topic Modelling
15
+
16
+ ### *Computational Thematic Analysis powered by Braun & Clarke (2006)*
17
+
18
+ ![BERTopic Agent Logo](logo.png)
19
+
20
+ ---
21
+
22
+ ## 🌟 Overview
23
+
24
+ **BERTopic Agentic Topic Modelling** is a state-of-the-art research tool designed to automate and enhance the process of **Thematic Analysis** for academic literature. By integrating **BERTopic**'s transformer-based clustering with a **LangGraph-driven agentic workflow**, this application guides researchers through the rigorous 6-phase framework of Braun & Clarke (2006).
25
+
26
+ It doesn't just cluster text; it *reasons* about it. Featuring a unique **"AI Council"** where multiple Large Language Models (Mistral & Groq) debate and reach consensus on topic labels, the tool ensures high-fidelity, publishable results.
27
+
28
+ ---
29
+
30
+ ## 🧠 Theoretical Foundation: Braun & Clarke (2006)
31
+
32
+ This tool is strictly mapped to the six phases of thematic analysis as defined in the seminal work:
33
+
34
+ 1. **Familiarisation with data**: Automatic cleaning, boilerplate removal, and dataset profiling.
35
+ 2. **Generating initial codes**: BERTopic discovery and AI-assisted initial labeling.
36
+ 3. **Searching for themes**: LLM-driven consolidation of topics into overarching themes.
37
+ 4. **Reviewing potential themes**: Saturation checks and coverage analysis.
38
+ 5. **Defining and naming themes**: Generation of academic definitions and core narratives.
39
+ 6. **Producing the report**: Narrative writing (Section 7 draft) and PAJAIS taxonomy mapping.
40
+
41
+ ---
42
+
43
+ ## ✨ Key Features
44
+
45
+ - **πŸ€– Agentic Workflow**: A LangGraph agent manages the entire pipeline, maintaining memory and ensuring a step-by-step scientific process.
46
+ - **βš–οΈ AI Council**: Real-time debates between **Mistral-Large** and **Llama-3 (Groq)** to determine the most accurate thematic labels.
47
+ - **πŸ“Š Dynamic Visualizations**: 8+ interactive Plotly charts (Intertopic maps, Frequency bars, Heatmaps, Treemaps, and DBSCAN scatter plots).
48
+ - **πŸ›‘οΈ Multi-Model Analysis**: Run separate analyses on **Abstracts** vs. **Titles** and generate a side-by-side convergence CSV.
49
+ - **πŸ” Density Refinement**: Optional **DBSCAN** clustering to complement traditional hierarchical methods and handle noise points elegantly.
50
+ - **🏷️ PAJAIS Taxonomy Mapping**: Automated gap analysis by mapping themes to the standard 25 PAJAIS Information Systems categories.
51
+ - **πŸ“₯ One-Click Export**: Download structured JSON, side-by-side CSVs, PNG charts, and a 500-word academic narrative report.
52
+
53
+ ---
54
+
55
+ ## πŸ› οΈ Architecture
56
+
57
+ ```mermaid
58
+ graph TD
59
+ A[Scopus CSV Upload] --> B{Agentic Workflow}
60
+ B -->|Phase 1| C[Data Loading & Cleaning]
61
+ C -->|Phase 2| D[BERTopic / DBSCAN Discovery]
62
+ D --> E[AI Council Labeling]
63
+ E -->|Phase 3| F[Theme Consolidation]
64
+ F -->|Phase 4| G[Saturation Check]
65
+ G -->|Phase 5| H[Definition & Naming]
66
+ H -->|Phase 5.5| I[PAJAIS Taxonomy Mapping]
67
+ I -->|Phase 6| J[Report Generation]
68
+
69
+ subgraph "AI Council"
70
+ E1[Mistral-Large] <--> E2[Groq Llama-3]
71
+ end
72
+
73
+ subgraph "Outputs"
74
+ J --> K[narrative.txt]
75
+ J --> L[comparison.csv]
76
+ J --> M[Interactive Charts]
77
+ end
78
+ ```
79
+
80
+ ---
81
+
82
+ ## πŸ–₯️ App Navigation & Expected UI
83
+
84
+ The interface is divided into three logical zones for a streamlined user experience:
85
+
86
+ ### 1. Control Center (Top & Left)
87
+ - **Phase Progress Bar**: A visual indicator of your progress through Braun & Clarke’s 6 phases.
88
+ - **Data Input (Left)**: The upload zone for your Scopus CSV. Once uploaded, Phase 1 triggers automatically.
89
+
90
+ ### 2. The Agent Laboratory (Center)
91
+ - **Chatbot Interface**: Your main point of interaction. The agent will ask questions, provide stats, and guide you. You can type commands like "run abstract" or "Continue".
92
+ - **AI Council Feedback**: Every time a label is generated, look for the reasoning block. It shows the consensus score between models.
93
+
94
+ ### 3. Results Dashboard (Bottom Tabs)
95
+ - **πŸ“‹ Review Table**: The "Heart" of the app. This is where you approve, rename, and refine the AI's findings. You MUST click **"Submit Review"** to move past STOP GATES.
96
+ - **πŸ“ˆ Charts Tab**: Switch between **Intertopic Map**, **Frequency Bars**, **Hierarchy (Treemap)**, and **Similarity Heatmap**.
97
+ - **βš–οΈ AI Council Tab**: A dedicated view showing the full transcript of debates between Mistral and Groq.
98
+ - **πŸ’Ύ Download Tab**: Your final repository. All files are generated in real-time and appear here for one-click downloading.
99
+
100
+ ### πŸ“€ Expected Output Preview
101
+ - **In Chat**: Summary tables, saturation percentages (e.g., "92.4% Coverage"), and phase completion checkmarks.
102
+ - **In Files**:
103
+ - `narrative.txt`: Academic prose with structured headings.
104
+ - `comparison.csv`: Columns for `Abstract Theme`, `Title Theme`, and `Convergence` (marked with βœ“).
105
+ - `taxonomy_map.json`: A mapping showing each theme's link to the PAJAIS framework and its **Novelty score**.
106
+
107
+ ---
108
+
109
+
110
+ ### 1. Prerequisites
111
+ - Python 3.9+
112
+ - API Keys for **Mistral AI** and **Groq** (optional but recommended for the Council feature).
113
+
114
+ ### 2. Installation
115
+
116
+ Clone the repository and install the dependencies:
117
+
118
+ ```bash
119
+ # Clone the repo
120
+ git clone https://github.com/ShivamKadam63s/BERT_Topic_Modelling.git
121
+ cd BERT_Topic_Modelling
122
+
123
+ # Install dependencies
124
+ pip install -r requirements.txt
125
+ ```
126
+
127
+ ### 3. Environment Setup
128
+
129
+ Create a `.env` file or export your API keys in your terminal:
130
+
131
+ ```powershell
132
+ $env:MISTRAL_API_KEY="your_mistral_key"
133
+ $env:GROQ_API_KEY="your_groq_key"
134
+ ```
135
+
136
+ ### 4. Running the App
137
+
138
+ Start the Gradio interface:
139
+
140
+ ```bash
141
+ python app.py
142
+ ```
143
+
144
+ Open your browser at `http://localhost:7860`.
145
+
146
+ ---
147
+
148
+ ## πŸ“– User Guide: Phase-by-Phase Walkthrough
149
+
150
+ ### Step 1: Data Input
151
+ Upload your **Scopus CSV** file. The agent will immediately scan the file, remove boilerplate text (Copyright notices, DOIs, etc.), and provide a dataset profile including paper counts and year ranges.
152
+
153
+ ### Step 2: Discovery & Coding
154
+ - Click **"run abstract"** or **"run title"**.
155
+ - The system will generate clusters and invoke the **AI Council**.
156
+ - **Navigation**: Check the **"βš–οΈ AI Council"** tab to see the reasoning behind each label.
157
+ - **Action**: In the **"πŸ“‹ Review Table"**, tick **Approve** for clusters you accept or provide a custom name in **Rename To**. Click **"Submit Review"**.
158
+
159
+ ### Step 3: Themes & Saturation
160
+ The agent combines approved codes into 4-8 themes. It will report **Thematic Saturation** (e.g., "Themes cover 92% of the corpus").
161
+
162
+ ### Step 4: Taxonomy Mapping
163
+ The tool automatically maps your themes to the **PAJAIS Taxonomy**.
164
+ - Themes marked with 🌟 **NOVEL** are identified as potential new research contributions not found in standard taxonomies.
165
+
166
+ ### Step 5: Final Report
167
+ The agent generates a **500-word Section 7 draft**. Check the **"πŸ’Ύ Download"** tab for your full suite of results.
168
+
169
+ ---
170
+
171
+ ## πŸ“ˆ Expected Outputs
172
+
173
+ | Output File | Description |
174
+ | :--- | :--- |
175
+ | `narrative.txt` | A complete Section 7 draft following academic standards. |
176
+ | `comparison.csv` | Side-by-side comparison of Abstract and Title themes. |
177
+ | `taxonomy_map.json` | JSON mapping of themes to PAJAIS categories. |
178
+ | `chart_*.html` | Interactive Plotly visualizations for intertopic distance and hierarchy. |
179
+ | `*.png` | High-resolution static exports of all charts. |
180
+
181
+ ---
182
+
183
+ ## πŸ› οΈ Built With
184
+
185
+ - **Gradio**: Modern UI Framework
186
+ - **LangGraph**: Agentic Multi-Model Workflows
187
+ - **BERTopic**: Advanced Topic Modeling
188
+ - **Sentence-Transformers**: `all-MiniLM-L6-v2` embeddings
189
+ - **Mistral Large**: Primary Reasoning LLM
190
+ - **Groq (Llama-3)**: Secondary Council LLM
191
+ - **Plotly**: Dynamic Data Science Charts
192
+
193
+ ---
194
+
195
+ ## βš–οΈ License & Citation
196
+
197
+ If you use this tool in your research, please cite:
198
+ *Shivam Kadam, "BERTopic Agentic Topic Modelling for Systematic Literature Reviews," 2026.*
199
+
200
+ Based on:
201
+ *Braun, V., & Clarke, V. (2006). Using thematic analysis in psychology. Qualitative Research in Psychology, 3(2), 77-101.*
202
+
203
+ ---
204
+ <p align="center">Made with ❀️ for the Research Community</p>
agent.py ADDED
@@ -0,0 +1,519 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # agent.py β€” Braun & Clarke Thematic Analysis Agent
2
+ # LangGraph ReAct agent with ChatMistralAI and MemorySaver checkpointer.
3
+ # Verified: exactly 4 STOP gates implemented (after Phase 2, 3, 4, 5.5)
4
+
5
+ from langchain_mistralai import ChatMistralAI
6
+ from langgraph.prebuilt import create_react_agent
7
+ from langgraph.checkpoint.memory import MemorySaver
8
+ from tools import (
9
+ load_scopus_csv,
10
+ run_bertopic_discovery,
11
+ label_topics_with_llm,
12
+ consolidate_into_themes,
13
+ compare_with_taxonomy,
14
+ generate_comparison_csv,
15
+ export_narrative,
16
+ # ── New additive tools (DBSCAN + AI Council) ──
17
+ run_dbscan_clustering,
18
+ refine_large_clusters,
19
+ run_ai_council,
20
+ )
21
+
22
+ # ─────────────────────────────────────────────────────────────────────────────
23
+ # SYSTEM PROMPT (~500 lines) β€” Braun & Clarke (2006) Thematic Analysis Agent
24
+ # ─────────────────────────────────────────────────────────────────────────────
25
+ SYSTEM_PROMPT = """
26
+ ================================================================================
27
+ IDENTITY & ROLE
28
+ ================================================================================
29
+ You are a computational thematic analysis agent implementing the Braun & Clarke
30
+ (2006) six-phase thematic analysis framework on academic literature corpora
31
+ exported from Scopus. You are embedded in a Gradio web application that
32
+ provides the researcher with a chat interface, a review table, charts, and file
33
+ downloads.
34
+
35
+ You have memory across the entire conversation via LangGraph MemorySaver.
36
+ You are powered by Mistral LLM and have access to 10 specialised tools.
37
+ Tools 1–7 implement the core Braun & Clarke pipeline (unchanged).
38
+ Tools 8–10 provide optional DBSCAN clustering and AI Council labelling.
39
+
40
+ Your purpose: guide the researcher through all 6 Braun & Clarke phases to
41
+ produce publishable thematic analysis results, including a PAJAIS taxonomy
42
+ mapping and a written narrative for Section 7 of their paper.
43
+
44
+ ================================================================================
45
+ CRITICAL OPERATING RULES β€” OBEY EVERY ONE, EVERY TIME
46
+ ================================================================================
47
+
48
+ RULE 1 β€” ONE PHASE PER MESSAGE:
49
+ Execute exactly one phase per response. Never jump ahead, never combine
50
+ phases, never rush. Respect the researcher's pace.
51
+
52
+ RULE 2 β€” 4 STOP GATES ARE ABSOLUTE:
53
+ There are exactly 4 STOP gates in this pipeline:
54
+ STOP GATE 1: After Phase 2 (wait for Submit Review from table)
55
+ STOP GATE 2: After Phase 3 (wait for "Continue" or Submit Review)
56
+ STOP GATE 3: After Phase 4 (wait for "Continue" or Submit Review)
57
+ STOP GATE 4: After Phase 5.5 (wait for "Continue" or Submit Review)
58
+ At each gate: display "β›” STOP GATE [N]", summarise what was done,
59
+ and explicitly state what you are waiting for. DO NOT proceed until received.
60
+
61
+ RULE 3 β€” ALL APPROVALS VIA REVIEW TABLE:
62
+ Never ask the researcher to approve topics, themes, or mappings via chat.
63
+ All approvals, renames, and reasoning belong in the Review Table.
64
+ The researcher clicks "Submit Review to Agent" when ready.
65
+
66
+ RULE 4 β€” NEVER HALLUCINATE DATA:
67
+ Every number, label, or topic you mention must come from a tool's return
68
+ value. Do not invent statistics, topic names, or paper counts.
69
+
70
+ RULE 5 β€” COLUMN USAGE:
71
+ RUN_CONFIGS = { "abstract": ["Abstract"], "title": ["Title"] }
72
+ Never use Author Keywords, Index Keywords, Source Title, or any other
73
+ column for BERTopic clustering. These columns introduce bias.
74
+
75
+ RULE 6 β€” TOOL CALL ORDER:
76
+ Only call tools in the order specified per phase. Never call a tool from
77
+ a later phase while in an earlier phase.
78
+
79
+ RULE 7 β€” TRANSPARENCY:
80
+ After every tool call, explain in plain English what the tool did,
81
+ what the key numbers mean, and what the researcher should do next.
82
+
83
+ RULE 8 β€” ERROR RECOVERY:
84
+ If a tool returns an error message, report it clearly to the researcher,
85
+ suggest a likely fix (e.g., wrong column name, missing file), and wait
86
+ for the researcher to confirm before retrying.
87
+
88
+ RULE 9 β€” PROGRESS BAR UPDATES:
89
+ After completing each phase, output a line in the exact format:
90
+ PHASE_STATUS: 1=βœ…,2=⬜,3=⬜,4=⬜,5=⬜,5.5=⬜,6=⬜
91
+ (with the completed phases marked βœ…). The UI parses this line.
92
+
93
+ RULE 10 β€” NO AUTO-ADVANCE:
94
+ Never say "I will now proceed to Phase N" without explicit user approval.
95
+ The word "Continue" or a Submit Review action is required at each gate.
96
+
97
+ RULE 11 β€” STRICT TOOL CALLS:
98
+ When calling a tool, use ONLY the tool name and arguments. Never prefix or
99
+ suffix the tool call with exploratory conversational text (e.g., "I will
100
+ now call..." or garbage tokens like "onderlinge"). Output the tool call
101
+ precisely as defined.
102
+
103
+ ================================================================================
104
+ TOOLS β€” DESCRIPTIONS AND WHEN TO USE EACH
105
+ ================================================================================
106
+
107
+ ────────────────────────────────────────────────────────────────────────────────
108
+ TOOL 1: load_scopus_csv(file_path: str)
109
+ ────────────────────────────────────────────────────────────────────────────────
110
+ Purpose : Load and validate the uploaded Scopus CSV file.
111
+ When : Phase 1 ONLY. Immediately when the researcher uploads a file.
112
+ Returns : papers, abstract_sentences, title_sentences, year_range, columns,
113
+ coverage percentages, sample_titles.
114
+ Action : Display all statistics. Ask researcher to confirm run_key.
115
+ Save loaded_data.csv (tool does this automatically).
116
+
117
+ ────────────────────────────────────────────────────────────────────────────────
118
+ TOOL 2: run_bertopic_discovery(run_key: str, threshold: float = 0.7)
119
+ ────────────────────────────────────────────────────────────────────────────────
120
+ Purpose : Core clustering. Splits text to sentences β†’ embeds with
121
+ all-MiniLM-L6-v2 β†’ AgglomerativeClustering (cosine, average,
122
+ threshold=0.7) β†’ NO UMAP β†’ finds 5 nearest sentences per centroid
123
+ β†’ generates 4 Plotly HTML charts β†’ saves summaries_{run_key}.json
124
+ and emb_{run_key}.npy.
125
+ When : After Phase 1.
126
+ Returns : n_topics, chart files, data preview.
127
+ Action : Report topic counts. Tell researcher the Intertopic Map and local
128
+ Frequency Bars are ready.
129
+ NEW: Explicitly tell the user: "You can now optionally run DBSCAN
130
+ clustering to compare these results with a density-based method
131
+ by typing 'run dbscan'."
132
+ Ask for approval to proceed to Phase 3.
133
+ STOP : Wait for "Continue" before Phase 3.
134
+
135
+ ────────────────────────────────────────────────────────────────────────────────
136
+ TOOL 3: label_topics_with_llm(run_key: str)
137
+ ────────────────────────────────────────────────────────────────────────────────
138
+ Purpose : Send top 100 topics to Mistral (PromptTemplate + JsonOutputParser).
139
+ Each topic gets: label, category, confidence, reasoning, niche.
140
+ Saves labels_{run_key}.json.
141
+ When : Phase 2 ONLY. Immediately after run_bertopic_discovery.
142
+ Returns : total_labelled, preview of first 5 labelled topics.
143
+ Action : Populate Review Table with labelled topics.
144
+ Trigger STOP GATE 1.
145
+
146
+ ────────────────────────────────────────────────────────────────────────────────
147
+ TOOL 4: consolidate_into_themes(run_key: str, theme_map: str)
148
+ ────────────────────────────────────────────────────────────────────────────────
149
+ Purpose : Merge approved topic clusters into 4–8 overarching themes.
150
+ Recomputes centroids and recounts sentences/papers per theme.
151
+ Saves themes_{run_key}.json and themes.json (canonical).
152
+ When : Phase 3 ONLY. After STOP GATE 1 is cleared.
153
+ Input : theme_map = JSON string {"Theme Name": [topic_id, ...]} from table.
154
+ If empty, LLM auto-consolidates.
155
+ Returns : total_themes, themes_preview.
156
+ Action : Display themes. Populate Review Table with theme-level rows.
157
+ Trigger STOP GATE 2.
158
+
159
+ ────────────────────────────────────────────────────────────────────────────────
160
+ TOOL 5: compare_with_taxonomy(run_key: str)
161
+ ────────────��───────────────────────────────────────────────────────────────────
162
+ Purpose : Map each theme to PAJAIS 25 categories. Returns MAPPED or NOVEL
163
+ per theme. Saves taxonomy_map.json.
164
+ When : Phase 5.5 ONLY. After Phase 5 naming is confirmed.
165
+ Returns : total_themes_mapped, novel_themes count, mapped_themes count, mapping.
166
+ Action : Populate Review Table β€” "Top Evidence" column shows:
167
+ "β†’ PAJAIS MATCH: [category] | [reasoning]" or
168
+ "β†’ NOVEL | [reasoning]"
169
+ Trigger STOP GATE 4.
170
+
171
+ ────────────────────────────────────────────────────────────────────────────────
172
+ TOOL 6: generate_comparison_csv()
173
+ ────────────────────────────────────────────────────────────────────────────────
174
+ Purpose : Load themes from both abstract and title runs, create side-by-side
175
+ comparison DataFrame. Requires themes_abstract.json and
176
+ themes_title.json. Saves comparison.csv.
177
+ When : Phase 6 ONLY. After STOP GATE 4 is cleared.
178
+ Returns : output file path, row count, preview.
179
+ Action : Tell researcher to check Download tab for comparison.csv.
180
+
181
+ ────────────────────────────────────────────────────────────────────────────────
182
+ TOOL 7: export_narrative(run_key: str)
183
+ ────────────────────────────────────────────────────────────────────────────────
184
+ Purpose : Generate a 500-word Section 7 narrative using Mistral LLM.
185
+ Covers methodology, themes, PAJAIS alignment, limitations, implications.
186
+ Saves narrative.txt.
187
+ When : Phase 6 ONLY. After generate_comparison_csv.
188
+ Returns : output file path, word count, 500-char preview.
189
+ Action : Display preview in chat. Add narrative.txt to Download tab.
190
+ Mark all phases complete. Display final success message.
191
+
192
+ ────────────────────────────────────────────────────────────────────────────────
193
+ TOOL 8: run_dbscan_clustering(run_key: str, eps: float = 0.3, min_samples: int = 3)
194
+ ────────────────────────────────────────────────────────────────────────────────
195
+ Purpose : Run DBSCAN on the SAME embeddings from run_bertopic_discovery.
196
+ Works in 384-dim cosine space (no UMAP). Parallel to agglomerative
197
+ clustering β€” outputs stored SEPARATELY (dbscan_summaries_{run_key}.json).
198
+ Generates 2 charts: DBSCAN scatter and cluster-count comparison.
199
+ When : OPTIONAL. After Phase 2 completes (emb_{run_key}.npy must exist).
200
+ Researcher triggers with: "run dbscan" or "compare clustering methods".
201
+ Returns : n_clusters, noise_points, largest_cluster, chart files.
202
+ Action : Report DBSCAN stats vs agglomerative in chat. Tell researcher the
203
+ new DBSCAN charts are available in the Charts tab.
204
+ Do NOT interrupt the main Braun & Clarke pipeline.
205
+
206
+ ────────────────────────────────────────────────────────────────────────────────
207
+ TOOL 9: refine_large_clusters(run_key: str, size_threshold: int = 200)
208
+ ────────────────────────────────────────────────────────────────────────────────
209
+ Purpose : Splits DBSCAN clusters larger than size_threshold into sub-clusters
210
+ using tighter AgglomerativeClustering (threshold=0.45).
211
+ Does NOT modify any existing agglomerative or DBSCAN outputs.
212
+ Saves refined_clusters_{run_key}.json.
213
+ When : OPTIONAL. After run_dbscan_clustering has completed.
214
+ Researcher triggers with: "refine large clusters" or similar.
215
+ Returns : n_large_refined, total_subclusters, chart file.
216
+ Action : Report which clusters were refined and how many sub-clusters created.
217
+
218
+ ──────────────��─────────────────────────────────────────────────────────────────
219
+ TOOL 10: run_ai_council(run_key: str)
220
+ ────────────────────────────────────────────────────────────────────────────────
221
+ Purpose : Two genuinely different LLMs independently label each DBSCAN cluster:
222
+ - Model A: Mistral Large (temperature=0.2) β€” analytical, precise
223
+ - Model B: Groq Llama-3.3-70b-versatile β€” genuinely independent model,
224
+ providing a Karpathy-style second opinion from a different architecture.
225
+ A Jaccard-based consensus step resolves agreements (β‰₯0.4 word overlap
226
+ β†’ agreed, use Model A label) vs divergences (Model A selected as primary).
227
+ Saves council_labels_{run_key}.json (PAJAIS-compatible: has 'label' field).
228
+ When : OPTIONAL. After run_dbscan_clustering has completed.
229
+ Researcher triggers with: "run ai council" or "council labels".
230
+ Returns : total_labelled, agreement_rate, output_file.
231
+ Action : Report agreement rate and a table of label_a vs label_b in chat.
232
+ Mention that council_labels_{run_key}.json is in the Download tab.
233
+
234
+ IMPORTANT: Tools 8–10 are SUPPLEMENTARY. They must NEVER block or delay the
235
+ main Braun & Clarke pipeline (Tools 1–7). If a researcher asks about DBSCAN
236
+ during Phase 3–6, offer to run it AFTER the current phase gate is cleared.
237
+
238
+ ================================================================================
239
+ RUN CONFIGURATIONS
240
+ ================================================================================
241
+ run_key = "abstract" β†’ columns: ["Abstract"]
242
+ run_key = "title" β†’ columns: ["Title"]
243
+ run_key = "combined" β†’ columns: ["Combined"] (Title + Abstract)
244
+
245
+ At the start of Phase 2, if the researcher has not already specified a
246
+ run_key, ask them: "Which run would you like to start with: 'abstract',
247
+ 'title', or 'combined'?" Default to "combined" if no response.
248
+
249
+ Author Keywords, Index Keywords, Source Title: NEVER used for clustering.
250
+
251
+ ================================================================================
252
+ PAJAIS TAXONOMY β€” 25 CATEGORIES (Phase 5.5 reference)
253
+ ================================================================================
254
+ 1. Artificial Intelligence Methods 14. Text Mining & Analytics
255
+ 2. Natural Language Processing 15. Sentiment Analysis
256
+ 3. Machine Learning 16. Social Media Analysis
257
+ 4. Deep Learning 17. Business Intelligence
258
+ 5. Knowledge Representation 18. Process Automation & RPA
259
+ 6. Ontologies & Semantic Web 19. Computer Vision
260
+ 7. Information Retrieval 20. Speech & Audio Processing
261
+ 8. Recommender Systems 21. Multi-Agent Systems
262
+ 9. Decision Support Systems 22. Robotics & Autonomous Systems
263
+ 10. Human-Computer Interaction 23. Healthcare & Biomedical AI
264
+ 11. Explainability & Transparency 24. Finance & Risk Analytics
265
+ 12. Fairness, Accountability & Ethics 25. Education & E-Learning
266
+ 13. Data Management & Integration
267
+
268
+ A theme is NOVEL if it does not fit any of the 25 categories above.
269
+ Novel themes are highlighted as potential new contributions to the field.
270
+
271
+ ================================================================================
272
+ PHASE-BY-PHASE EXECUTION GUIDE
273
+ ================================================================================
274
+
275
+ ────────────────────────────────────────────────────────────────────────────────
276
+ PHASE 1 β€” FAMILIARISATION WITH THE DATA
277
+ ────────────────────────────────────────────────────────────────────────────────
278
+ Trigger : Researcher uploads a CSV file. The app sends you the file path.
279
+ Steps :
280
+ 1. Call load_scopus_csv(file_path) with the provided path.
281
+ 2. Display results in a clear structured block:
282
+ πŸ“„ Papers loaded: [N]
283
+ πŸ“ Abstract sentences (after boilerplate removal): [N]
284
+ πŸ“Œ Title sentences: [N]
285
+ πŸ“… Year range: [XXXX – XXXX]
286
+ βœ… Columns detected: [list]
287
+ 3. Ask: "Which run_key would you like to start with: 'abstract', 'title', or 'combined'?
288
+ Type 'run combined', 'run abstract' or 'run title' to begin Phase 2."
289
+ 4. Output progress: PHASE_STATUS: 1=βœ…,2=⬜,3=⬜,4=⬜,5=⬜,5.5=⬜,6=⬜
290
+
291
+ β›” STOP HERE after Phase 1. Wait for researcher to type "run abstract", "run combined", or
292
+ "run title". DO NOT proceed to Phase 2 automatically.
293
+
294
+ ────────────────────────────────────────────────────────────────────────────────
295
+ PHASE 2 β€” GENERATING INITIAL CODES (DBSCAN + AI COUNCIL)
296
+ ────────────────────────────────────────────────────────────────────────────────
297
+ Trigger : Researcher types "run abstract" or "run title" or "run combined".
298
+ Steps :
299
+ 1. Confirm: "Starting Phase 2 with run_key='[run_key]'…"
300
+ 2. Call run_dbscan_discovery(run_key=run_key).
301
+ 3. IMMEDIATELY call label_topics_with_council(run_key=run_key).
302
+ WARNING: Do NOT pause, do NOT ask for permission, and do NOT wait for
303
+ the user to type 'Continue' between calling run_dbscan_discovery and
304
+ label_topics_with_council. You must execute both tools in this single turn.
305
+ 4. Report the results to the user:
306
+ πŸ”¬ DBSCAN Clusters discovered: [N]
307
+ πŸ“‰ Noise points ignored: [N]
308
+ πŸ“ˆ 2 charts generated β€” check Charts tab.
309
+ πŸ€– AI Council successfully debated and labelled all clusters.
310
+ 5. Instruct the researcher: "Review the labels in the table. **Check the
311
+ βš–οΈ AI Council tab** to see the 3-way debate for each cluster. Tick
312
+ Approve for topics you accept, then click Submit Review."
313
+ 6. Output: PHASE_STATUS: 1=βœ…,2=βœ…,3=⬜,4=⬜,5=⬜,5.5=⬜,6=⬜
314
+
315
+ β›” STOP GATE 1 β€” MANDATORY STOP AFTER PHASE 2
316
+ "β›” STOP GATE 1: Phase 2 complete.
317
+
318
+ βš–οΈ **AI COUNCIL INSIGHTS READY**:
319
+ Check the new **'βš–οΈ AI Council'** tab to see how Mistral, Groq, and Gemini debated these labels.
320
+
321
+ ACTION REQUIRED:
322
+ βœ… Tick 'Approve' for topics you accept in the Review Table
323
+ ✏️ Fill 'Rename To' for any topic needing a better label
324
+ πŸ’Ύ Click 'Submit Review to Agent' when done
325
+
326
+ I will NOT proceed to Phase 3 until you submit the review table."
327
+
328
+ ────────────────────────────────────────────────────────────────────────────────
329
+ PHASE 3 β€” SEARCHING FOR THEMES
330
+ ────────────────────────────────────────────────────────────────────────────────
331
+ Trigger : Researcher clicks "Submit Review to Agent" (app sends approved labels).
332
+ Steps :
333
+ 1. Parse the submitted review data to extract:
334
+ - Approved topic IDs and their final labels (Rename To override if provided)
335
+ - Build theme_map: {"Theme Name": [topic_ids]} if researcher grouped any
336
+ If no grouping provided, pass empty theme_map (LLM will auto-consolidate)
337
+ 2. Call consolidate_into_themes(run_key=run_key, theme_map=theme_map_json).
338
+ 3. Report each theme:
339
+ 🎯 Theme: [name] β€” [N] sentences, topics: [list of constituent labels]
340
+ 4. Populate Review Table with theme-level rows.
341
+ 5. Output: PHASE_STATUS: 1=βœ…,2=βœ…,3=βœ…,4=⬜,5=⬜,5.5=⬜,6=⬜
342
+
343
+ β›” STOP GATE 2 β€” MANDATORY STOP AFTER PHASE 3
344
+ "β›” STOP GATE 2: Phase 3 complete. [N] themes identified.
345
+
346
+ Review the consolidated themes in the table above.
347
+ - Are any themes too broad or too narrow?
348
+ - Are any topics misclassified?
349
+ Type 'Continue' or click Submit Review to proceed to Phase 4: Theme Review."
350
+
351
+ ────────────────────────────────────────────────────────────────────────────────
352
+ PHASE 4 β€” REVIEWING THEMES (SATURATION CHECK)
353
+ ────────────────────────────────────────────────────────────────────────────────
354
+ Trigger : Researcher types "Continue" or submits review.
355
+ Steps :
356
+ 1. Assess saturation: do the [N] themes cover the data adequately?
357
+ Report coverage: total sentences covered / total sentences in corpus.
358
+ 2. List each theme with:
359
+ Theme [N]: [name] β€” [sentence_count] sentences
360
+ Largest topic cluster: [label]
361
+ Coverage: [X]% of corpus
362
+ 3. Confirm saturation status:
363
+ "Saturation confirmed: [N] themes cover [X]% of the [total] sentences."
364
+ (If coverage < 80%, flag: "Coverage may be low β€” consider lowering threshold.")
365
+ 4. Output: PHASE_STATUS: 1=βœ…,2=βœ…,3=βœ…,4=βœ…,5=⬜,5.5=⬜,6=⬜
366
+
367
+ β›” STOP GATE 3 β€” MANDATORY STOP AFTER PHASE 4
368
+ "β›” STOP GATE 3: Phase 4 complete. Saturation check done.
369
+
370
+ Themes cover [X]% of the corpus.
371
+ Type 'Continue' to proceed to Phase 5: Defining and Naming Themes."
372
+
373
+ ────────────────────────────────────────────────────────────────────────────────
374
+ PHASE 5 β€” DEFINING AND NAMING THEMES
375
+ ────────────────────────────────────────────────────────────────────────────────
376
+ Trigger : Researcher types "Continue".
377
+ Steps :
378
+ 1. For each theme, present a definition block:
379
+ ## Theme [N]: [Name]
380
+ **Definition**: [One paragraph capturing the essence of this theme]
381
+ **Core narrative**: [What story does this theme tell about the corpus?]
382
+ **Key evidence**: "[Quote from nearest_sentences]"
383
+ 2. Invite refinements: "Edit Rename To in the table if any theme needs a
384
+ final name adjustment, then click Submit Review."
385
+ 3. Apply any name changes from Submit Review to themes.json silently.
386
+ 4. Output: PHASE_STATUS: 1=βœ…,2=βœ…,3=βœ…,4=βœ…,5=βœ…,5.5=⬜,6=⬜
387
+
388
+ (No extra STOP gate after Phase 5 β€” flow directly into Phase 5.5)
389
+ Announce: "Proceeding to Phase 5.5: PAJAIS Taxonomy Mapping…"
390
+
391
+ ────────────────────────────────────────────────────────────────────────────────
392
+ PHASE 5.5 β€” PAJAIS TAXONOMY MAPPING
393
+ ────────────────────────────────────────────────────────────────────────────────
394
+ Steps :
395
+ 1. Call compare_with_taxonomy(run_key=run_key).
396
+ 2. Display a mapping table:
397
+ Theme β†’ PAJAIS Category β†’ Confidence β†’ Novel?
398
+ 3. Highlight NOVEL themes (is_novel=true) with 🌟 marker.
399
+ 4. Populate Review Table β€” "Top Evidence Sentence" column now shows:
400
+ "β†’ [PAJAIS MATCH: category] | [reasoning]"
401
+ or
402
+ "β†’ NOVEL | [reasoning]"
403
+ 5. Explain novel themes: "These themes are potential new contributions
404
+ not yet represented in the PAJAIS taxonomy."
405
+ 6. Output: PHASE_STATUS: 1=βœ…,2=βœ…,3=βœ…,4=βœ…,5=βœ…,5.5=βœ…,6=⬜
406
+
407
+ β›” STOP GATE 4 β€” MANDATORY STOP AFTER PHASE 5.5
408
+ "β›” STOP GATE 4: Phase 5.5 complete. Taxonomy mapping done.
409
+
410
+ πŸ“Š Themes mapped to PAJAIS: [N]
411
+ 🌟 Novel themes (not in taxonomy): [M]
412
+
413
+ Review the taxonomy mapping in the table.
414
+ - Do you agree with the PAJAIS assignments?
415
+ - Are the NOVEL themes genuinely new contributions?
416
+ Edit Approve column for any mappings you disagree with.
417
+ Type 'Continue' or click Submit Review to proceed to Phase 6: Report."
418
+
419
+ DO NOT CALL ANY TOOL until researcher confirms.
420
+
421
+ ────────────────────────────────────────────────────────────────────────────────
422
+ PHASE 6 β€” PRODUCING THE REPORT
423
+ ────────────────────────────────────────────────────────────────────────────────
424
+ Trigger : Researcher types "Continue" or submits final review.
425
+ Steps :
426
+ 1. Check if both themes_abstract.json and themes_title.json exist.
427
+ If BOTH exist:
428
+ Call generate_comparison_csv().
429
+ Report: "comparison.csv generated with [N] rows β€” check Download tab."
430
+ If only ONE run exists:
431
+ Report: "Only [run_key] run available. Run the other run_key to get
432
+ a comparison. Skipping comparison.csv for now."
433
+ 2. Call export_narrative(run_key=run_key).
434
+ 3. Display the narrative preview (first 500 characters) in chat.
435
+ 4. List all available download files:
436
+ πŸ“₯ narrative.txt β€” 500-word Section 7 draft
437
+ πŸ“₯ comparison.csv β€” abstract vs title theme comparison
438
+ πŸ“₯ themes.json β€” consolidated themes data
439
+ πŸ“₯ taxonomy_map.json β€” PAJAIS gap analysis
440
+ πŸ“₯ labels_{run_key}.json β€” all labelled topic codes
441
+ 5. Final message:
442
+ "πŸŽ‰ Analysis complete! Your Braun & Clarke thematic analysis of
443
+ [N] papers ([run_key] run) has produced [T] themes.
444
+ [M] themes are MAPPED to PAJAIS; [K] are NOVEL contributions.
445
+ All files are ready in the Download tab."
446
+ 6. Output: PHASE_STATUS: 1=βœ…,2=βœ…,3=βœ…,4=βœ…,5=βœ…,5.5=βœ…,6=βœ…
447
+
448
+ To run the second analysis (title run or abstract run), the researcher
449
+ types "run title" or "run abstract" β€” the pipeline restarts from Phase 2
450
+ while keeping memory of Phase 1 data.
451
+
452
+ ================================================================================
453
+ REVIEW TABLE COLUMN GUIDE
454
+ ================================================================================
455
+ The Review Table has these 8 columns:
456
+ # : Row number (topic or theme ID)
457
+ Topic Label : LLM-generated label (editable)
458
+ Top Evidence : Best representative sentence β€” at Phase 5.5, shows PAJAIS mapping
459
+ Sent. : Sentence count in this cluster
460
+ Papers : Estimated paper count (sentences Γ· 10, rounded)
461
+ Approve : Researcher ticks this to accept the row
462
+ Rename To : Researcher fills this to override the label
463
+ Reasoning : Researcher's notes on their decision
464
+
465
+ ================================================================================
466
+ PHASE PROGRESS BAR β€” STATUS LINE FORMAT
467
+ ================================================================================
468
+ After completing each phase, always output a single line in this exact format:
469
+ PHASE_STATUS: 1=βœ…,2=⬜,3=⬜,4=⬜,5=⬜,5.5=⬜,6=⬜
470
+ The app.py UI parses this line to update the phase progress bar automatically.
471
+ Use βœ… for completed phases and ⬜ for pending phases.
472
+
473
+ ================================================================================
474
+ CONVERSATION STYLE GUIDELINES
475
+ ================================================================================
476
+ - Use ## headers to mark each phase start
477
+ - Use πŸ“„ πŸ“Š πŸ”¬ 🎯 β›” βœ… ⬜ 🌟 πŸ“₯ πŸŽ‰ emoji purposefully for clarity
478
+ - Keep explanations concise: one paragraph maximum per concept
479
+ - Use markdown tables for structured comparisons
480
+ - Acknowledge every researcher message before responding
481
+ - If the researcher asks a question mid-analysis, answer it completely,
482
+ then restate current phase and next step
483
+ - Never use jargon without a brief plain-English explanation
484
+
485
+ ================================================================================
486
+ END OF SYSTEM PROMPT
487
+ ================================================================================
488
+ """
489
+
490
+ # ─────────────────────────────────────────────────────────────────────────────
491
+ # Agent instantiation
492
+ # ─────────────────────────────────────────────────────────────────────────────
493
+ _llm = ChatMistralAI(
494
+ model="mistral-large-latest",
495
+ temperature=0.2,
496
+ )
497
+
498
+ from tools import (
499
+ load_scopus_csv, run_dbscan_discovery, label_topics_with_council,
500
+ consolidate_into_themes, compare_with_taxonomy,
501
+ generate_comparison_csv, export_narrative
502
+ )
503
+
504
+ _tools = [
505
+ load_scopus_csv, run_dbscan_discovery, label_topics_with_council,
506
+ consolidate_into_themes, compare_with_taxonomy,
507
+ generate_comparison_csv, export_narrative
508
+ ]
509
+
510
+ _checkpointer = MemorySaver()
511
+
512
+ agent = create_react_agent(
513
+ model=_llm,
514
+ tools=_tools,
515
+ checkpointer=_checkpointer,
516
+ prompt=SYSTEM_PROMPT,
517
+ )
518
+
519
+ # Verified: exactly 4 STOP gates implemented (Tools 8-10 are additive, do not add gates)
app.py ADDED
@@ -0,0 +1,812 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py β€” BERTopic Thematic Analysis Agent
2
+ # Built specifically for Gradio 6.11.0.
3
+ #
4
+ # KEY FIXES in this version:
5
+ # FIX-A: call_agent detects INVALID_CHAT_HISTORY (dangling tool call in
6
+ # MemorySaver after a mid-tool 429) and rotates to a fresh thread_id.
7
+ # FIX-B: Rate-limit back-off extended to 30 / 60 / 90 s (was 10/20/30 s).
8
+ # FIX-C: on_clear() now deletes all checkpoint files so Phase 1 truly resets.
9
+ # FIX-D: All UI handlers return the (possibly rotated) sid_state.
10
+ # FIX-E: stdout/stderr reconfigured to UTF-8 so Mistral emoji (βœ…πŸ“„β¬œ) don't
11
+ # crash print() on Windows cp1252 consoles.
12
+
13
+ import sys
14
+ import shutil
15
+
16
+ # FIX-E: Reconfigure console to UTF-8 BEFORE any print() calls.
17
+ # Windows default (cp1252) cannot encode Mistral's emoji responses,
18
+ # causing UnicodeEncodeError inside log_error() which propagated to the UI.
19
+ try:
20
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
21
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
22
+ except AttributeError:
23
+ pass # Non-TTY environments (HuggingFace Spaces) don't need this
24
+
25
+ import gradio as gr
26
+ import json
27
+ import os
28
+ import uuid
29
+ import glob
30
+ import pandas as pd
31
+ import traceback
32
+ import datetime
33
+ import time
34
+ import plotly.io as pio
35
+ from agent import agent
36
+
37
+ # Check for API Keys
38
+ if not os.environ.get("MISTRAL_API_KEY"):
39
+ print("\n" + "!"*80)
40
+ print("CRITICAL WARNING: MISTRAL_API_KEY environment variable is NOT set.")
41
+ print("!"*80 + "\n")
42
+
43
+ if not os.environ.get("GROQ_API_KEY"):
44
+ print("\n" + "!"*80)
45
+ print("CRITICAL WARNING: GROQ_API_KEY environment variable is NOT set.")
46
+ print("!"*80 + "\n")
47
+
48
+ if not os.environ.get("GOOGLE_API_KEY"):
49
+ print("\n" + "!"*80)
50
+ print("CRITICAL WARNING: GOOGLE_API_KEY environment variable is NOT set.")
51
+ print("!"*80 + "\n")
52
+
53
+ print(f"[app.py] Starting with Gradio {gr.__version__}")
54
+
55
+ # ─────────────────────────────────────────────────────────────────────────────
56
+ # Constants
57
+ # ─────────────────────────────────────────────────────────────────────────────
58
+ REVIEW_COLUMNS = [
59
+ "#", "Topic Label", "Top Evidence Sentence",
60
+ "Sent.", "Papers", "Approve", "Rename To",
61
+ ]
62
+
63
+ EMPTY_REVIEW_DF = pd.DataFrame(
64
+ columns=REVIEW_COLUMNS,
65
+ data=[["", "", "", 0, 0, False, ""]],
66
+ )
67
+
68
+ DOWNLOAD_FILES = [
69
+ "narrative.txt", "comparison.csv", "themes.json",
70
+ "taxonomy_map.json", "labels_abstract.json", "labels_title.json",
71
+ # ── New DBSCAN + AI Council outputs ──
72
+ "dbscan_summaries_abstract.json", "dbscan_summaries_title.json",
73
+ "refined_clusters_abstract.json", "refined_clusters_title.json",
74
+ "council_labels_abstract.json", "council_labels_title.json",
75
+ # PNG chart exports
76
+ "chart_abstract_intertopic.png", "chart_abstract_bars.png",
77
+ "chart_abstract_hierarchy.png", "chart_abstract_heatmap.png",
78
+ "chart_title_intertopic.png", "chart_title_bars.png",
79
+ "chart_title_hierarchy.png", "chart_title_heatmap.png",
80
+ "chart_abstract_dbscan_scatter.png", "chart_abstract_dbscan_comparison.png",
81
+ "chart_title_dbscan_scatter.png", "chart_title_dbscan_comparison.png",
82
+ "chart_abstract_refined.png", "chart_title_refined.png",
83
+ ]
84
+
85
+ # Files to wipe when the user resets the session
86
+ CHECKPOINT_FILES = [
87
+ "loaded_data.csv",
88
+ "summaries_abstract.json", "summaries_title.json",
89
+ "emb_abstract.npy", "emb_title.npy",
90
+ "labels_abstract.json", "labels_title.json",
91
+ "themes.json", "themes_abstract.json", "themes_title.json",
92
+ "taxonomy_map.json", "comparison.csv", "narrative.txt",
93
+ "chart_abstract_intertopic.html", "chart_abstract_bars.html",
94
+ "chart_abstract_hierarchy.html", "chart_abstract_heatmap.html",
95
+ "chart_title_intertopic.html", "chart_title_bars.html",
96
+ "chart_title_hierarchy.html", "chart_title_heatmap.html",
97
+ # ── New DBSCAN + AI Council files ──
98
+ "dbscan_summaries_abstract.json", "dbscan_summaries_title.json",
99
+ "refined_clusters_abstract.json", "refined_clusters_title.json",
100
+ "council_labels_abstract.json", "council_labels_title.json",
101
+ "chart_abstract_dbscan_scatter.html", "chart_abstract_dbscan_comparison.html",
102
+ "chart_title_dbscan_scatter.html", "chart_title_dbscan_comparison.html",
103
+ "chart_abstract_refined.html", "chart_title_refined.html",
104
+ # PNG exports (cleared on reset too)
105
+ "chart_abstract_intertopic.png", "chart_abstract_bars.png",
106
+ "chart_abstract_hierarchy.png", "chart_abstract_heatmap.png",
107
+ "chart_title_intertopic.png", "chart_title_bars.png",
108
+ "chart_title_hierarchy.png", "chart_title_heatmap.png",
109
+ "chart_abstract_dbscan_scatter.png", "chart_abstract_dbscan_comparison.png",
110
+ "chart_title_dbscan_scatter.png", "chart_title_dbscan_comparison.png",
111
+ "chart_abstract_refined.png", "chart_title_refined.png",
112
+ ]
113
+
114
+ CHART_OPTIONS = [
115
+ ("DBSCAN Cluster Scatter β€” Combined", "chart_combined_dbscan_scatter.html"),
116
+ ("DBSCAN Cluster Sizes β€” Combined", "chart_combined_dbscan_bars.html"),
117
+ ("DBSCAN Cluster Scatter β€” Abstract", "chart_abstract_dbscan_scatter.html"),
118
+ ("DBSCAN Cluster Sizes β€” Abstract", "chart_abstract_dbscan_bars.html"),
119
+ ("DBSCAN Cluster Scatter β€” Title", "chart_title_dbscan_scatter.html"),
120
+ ("DBSCAN Cluster Sizes β€” Title", "chart_title_dbscan_bars.html"),
121
+ ]
122
+
123
+ PHASE_LABELS = [
124
+ ("1","β‘  Load"), ("2","β‘‘ Codes"), ("3","β‘’ Themes"),
125
+ ("4","β‘£ Review"), ("5","β‘€ Names"), ("5.5","β‘€Β½ PAJAIS"), ("6","β‘₯ Report"),
126
+ ]
127
+
128
+ # Error strings that indicate a corrupted MemorySaver thread
129
+ # (dangling AIMessage with tool_call but no ToolMessage)
130
+ CORRUPT_HISTORY_SIGNALS = [
131
+ "INVALID_CHAT_HISTORY",
132
+ "ToolMessage",
133
+ "tool_calls that do not have a corresponding",
134
+ ]
135
+
136
+ CSS = """
137
+ body, .gradio-container {
138
+ background: #0d0d1a !important;
139
+ font-family: 'Inter', 'Segoe UI', sans-serif !important;
140
+ }
141
+ .gradio-container { max-width: 1280px !important; margin: 0 auto !important; }
142
+ .section-hdr {
143
+ background: linear-gradient(90deg, #1a2a4a, #0d1a2e);
144
+ color: #7fb3f5 !important; font-weight: 800 !important; font-size: 0.8rem !important;
145
+ letter-spacing: 0.1em; text-transform: uppercase;
146
+ padding: 7px 14px; border-radius: 6px 6px 0 0;
147
+ border-left: 3px solid #4a90d9; margin-bottom: 4px;
148
+ }
149
+ footer { display: none !important; }
150
+
151
+ /* ── Resizeable review table ── */
152
+ .resizeable-table-wrap {
153
+ overflow: auto;
154
+ resize: vertical;
155
+ min-height: 220px;
156
+ max-height: 80vh;
157
+ border: 1px solid #2a2a4a;
158
+ border-radius: 6px;
159
+ padding-bottom: 4px;
160
+ }
161
+ .resizeable-table-wrap table { min-width: 100%; }
162
+
163
+ /* Make Gradio dataframe container resizeable */
164
+ #review_table_wrap .svelte-1o8r8wm,
165
+ #review_table_wrap .table-wrap {
166
+ resize: vertical;
167
+ overflow: auto;
168
+ min-height: 220px;
169
+ max-height: 75vh;
170
+ }
171
+ """
172
+
173
+
174
+ # ─────────────────────────────────────────────────────────────────────────────
175
+ # Message helpers
176
+ # Gradio 6.11 ALWAYS needs: {"role": "user"|"assistant", "content": str}
177
+ # ─────────────────────────────────────────────────────────────────────────────
178
+ def _msg(role: str, content: str) -> dict:
179
+ return {"role": role, "content": str(content)}
180
+
181
+
182
+ def append_msgs(history: list, user_text: str, bot_text: str) -> list:
183
+ """Append a user+assistant exchange to chat history."""
184
+ return history + [_msg("user", user_text), _msg("assistant", bot_text)]
185
+
186
+
187
+ def empty_history() -> list:
188
+ return []
189
+
190
+
191
+ # ─────────────────────────────────────────────────────────────────────────────
192
+ # Utilities
193
+ # ─────────────────────────────────────────────────────────────────────────────
194
+ def log_error(msg: str, ctx: str = "") -> None:
195
+ ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
196
+ with open("error.txt", "a", encoding="utf-8") as f:
197
+ f.write(f"\n{'='*60}\nTIME: {ts}\nCONTEXT: {ctx}\n"
198
+ f"ERROR: {msg}\nTRACEBACK:\n{traceback.format_exc()}\n")
199
+ # Secondary safety net: if stdout reconfigure didn't work, don't crash
200
+ try:
201
+ print(f"[ERROR] {ctx}: {str(msg)[:120]}")
202
+ except UnicodeEncodeError:
203
+ print(f"[ERROR] {ctx}: (non-ASCII chars in message β€” see error.txt)")
204
+
205
+
206
+ def safe_str(val) -> str:
207
+ """Convert any LangGraph output to plain str safely."""
208
+ if val is None:
209
+ return ""
210
+ if isinstance(val, str):
211
+ return val
212
+ if isinstance(val, list):
213
+ parts = []
214
+ for item in val:
215
+ if isinstance(item, str):
216
+ parts.append(item)
217
+ elif isinstance(item, dict):
218
+ parts.append(str(item.get("content", item.get("text", ""))))
219
+ elif hasattr(item, "content"):
220
+ parts.append(safe_str(item.content))
221
+ else:
222
+ parts.append(str(item))
223
+ return "\n".join(filter(None, parts))
224
+ if isinstance(val, dict):
225
+ return str(val.get("content", val.get("text", str(val))))
226
+ if hasattr(val, "content"):
227
+ return safe_str(val.content)
228
+ return str(val)
229
+
230
+
231
+ def detect_phase_status() -> dict:
232
+ return {
233
+ "1": os.path.exists("loaded_data.csv"),
234
+ "2": os.path.exists("labels_abstract.json") or os.path.exists("labels_title.json"),
235
+ "3": os.path.exists("themes.json"),
236
+ "4": os.path.exists("themes.json"),
237
+ "5": os.path.exists("themes.json"),
238
+ "5.5": os.path.exists("taxonomy_map.json"),
239
+ "6": os.path.exists("narrative.txt"),
240
+ }
241
+
242
+
243
+ def build_phase_bar(status: dict) -> str:
244
+ items = ""
245
+ for key, label in PHASE_LABELS:
246
+ done = status.get(key, False)
247
+ bg = "#2ecc71" if done else "#2a2a3e"
248
+ col = "#000" if done else "#888"
249
+ bdr = "#2ecc71" if done else "#444"
250
+ items += (
251
+ f'<span style="display:inline-block;padding:4px 11px;margin:2px;'
252
+ f'background:{bg};border:1.5px solid {bdr};border-radius:18px;'
253
+ f'font-size:0.75rem;font-weight:700;color:{col};white-space:nowrap;">'
254
+ f'{"βœ… " if done else ""}{label}</span>'
255
+ )
256
+ return (
257
+ f'<div style="background:#12122a;padding:9px 14px;border-radius:8px;'
258
+ f'border:1px solid #2a2a4a;margin-bottom:6px;line-height:2.4;">'
259
+ f'<span style="color:#5a7abf;font-size:0.7rem;font-weight:800;'
260
+ f'letter-spacing:0.09em;margin-right:8px;">BRAUN &amp; CLARKE PHASES</span>'
261
+ f'{items}</div>'
262
+ )
263
+
264
+
265
+ def parse_phase_status(text, current: dict) -> dict:
266
+ text = safe_str(text)
267
+ updated = dict(current)
268
+ for line in text.splitlines():
269
+ if "PHASE_STATUS:" in line:
270
+ raw = line.split("PHASE_STATUS:", 1)[1].strip()
271
+ for part in [p.strip() for p in raw.split(",")]:
272
+ if "=" in part:
273
+ k, v = part.split("=", 1)
274
+ updated[k.strip()] = "βœ…" in v
275
+ for k, v in detect_phase_status().items():
276
+ updated[k] = updated.get(k, False) or v
277
+ return updated
278
+
279
+
280
+ # ─────────────────────────────────────────────────────────────────────────────
281
+ # Review table loader
282
+ # ─────────────────────────────────────────────────────────────────────────────
283
+ def load_review_table() -> pd.DataFrame:
284
+ if os.path.exists("taxonomy_map.json"):
285
+ data = json.loads(open("taxonomy_map.json", encoding="utf-8").read())
286
+ rows = []
287
+ for i, item in enumerate(data):
288
+ evidence = (
289
+ f"β†’ NOVEL | {item.get('reasoning','')[:80]}"
290
+ if item.get("is_novel", False)
291
+ else f"β†’ PAJAIS: {item.get('pajais_match','')} | {item.get('reasoning','')[:60]}"
292
+ )
293
+ rows.append({"#": i, "Topic Label": item.get("theme_name", ""),
294
+ "Top Evidence Sentence": evidence,
295
+ "Sent.": 0, "Papers": 0, "Approve": True, "Rename To": ""})
296
+ return pd.DataFrame(rows, columns=REVIEW_COLUMNS) if rows else EMPTY_REVIEW_DF
297
+
298
+ if os.path.exists("themes.json"):
299
+ data = json.loads(open("themes.json", encoding="utf-8").read())
300
+ rows = []
301
+ for i, item in enumerate(data):
302
+ s = item.get("total_sentences", 0)
303
+ rows.append({"#": i, "Topic Label": item.get("theme_name", ""),
304
+ "Top Evidence Sentence": (
305
+ item.get("representative_sentences", [""])[0][:120]
306
+ if item.get("representative_sentences") else ""),
307
+ "Sent.": s, "Papers": max(1, s // 10),
308
+ "Approve": False, "Rename To": ""})
309
+ return pd.DataFrame(rows, columns=REVIEW_COLUMNS) if rows else EMPTY_REVIEW_DF
310
+
311
+ for rk in ("combined", "abstract", "title"):
312
+ p = f"labels_{rk}.json"
313
+ if os.path.exists(p):
314
+ data = json.loads(open(p, encoding="utf-8").read())
315
+ rows = []
316
+ for t in data:
317
+ s = t.get("count", 0)
318
+ rows.append({"#": t.get("topic_id", 0),
319
+ "Topic Label": t.get("label", f"Topic {t.get('topic_id',0)}"),
320
+ "Top Evidence Sentence": (
321
+ t.get("nearest_sentences", [""])[0][:120]
322
+ if t.get("nearest_sentences") else ""),
323
+ "Sent.": s, "Papers": max(1, s // 10),
324
+ "Approve": False, "Rename To": ""})
325
+ return pd.DataFrame(rows, columns=REVIEW_COLUMNS) if rows else EMPTY_REVIEW_DF
326
+
327
+ return EMPTY_REVIEW_DF
328
+
329
+
330
+ def load_council_report() -> str:
331
+ """Return a detailed HTML report of the AI Council arguments."""
332
+ possible_files = [
333
+ "council_labels_combined.json", "labels_combined.json",
334
+ "council_labels_abstract.json", "labels_abstract.json",
335
+ "council_labels_title.json", "labels_title.json"
336
+ ]
337
+ found = [f for f in possible_files if os.path.exists(f)]
338
+ if not found:
339
+ return "<div style='padding:40px;text-align:center;color:#4a5a7a;'>AI Council arguments will appear here after Phase 3 or after running DBSCAN Council.</div>"
340
+
341
+ with open(found[0], encoding="utf-8") as f:
342
+ data = json.load(f)
343
+
344
+ # We want to show the top 10 most interesting arguments (or all if few)
345
+ items = data[:20]
346
+ html = "<div style='display:flex; flex-direction:column; gap:12px;'>"
347
+ for item in items:
348
+ # Check if the tool output the UI block or we need to build it
349
+ ui = item.get("council_ui", item.get("council_reasoning", ""))
350
+ label = item.get("label", item.get("consensus_label", "Unknown"))
351
+ html += f"""
352
+ <div style="background:#1a1a2e; border:1px solid #2a2a4a; border-radius:8px; padding:12px;">
353
+ <div style="display:flex; justify-content:space-between; margin-bottom:8px;">
354
+ <span style="color:#7fb3f5; font-weight:bold;">Topic #{item.get('topic_id', item.get('cluster_id', '?'))}</span>
355
+ <span style="color:#fff; font-size:0.9rem;">Final Choice: <b>{label}</b></span>
356
+ </div>
357
+ {ui}
358
+ </div>
359
+ """
360
+ html += "</div>"
361
+ return html
362
+
363
+
364
+ def get_downloads():
365
+ found = [f for f in DOWNLOAD_FILES if os.path.exists(f)]
366
+ return found if found else None
367
+
368
+
369
+ def render_chart(chart_file: str) -> str:
370
+ if not chart_file or not os.path.exists(chart_file):
371
+ return ("<div style='padding:40px;text-align:center;color:#555;'>"
372
+ "Chart not available yet β€” run analysis first.</div>")
373
+ content = open(chart_file, encoding="utf-8").read()
374
+ escaped = content.replace("&", "&amp;").replace('"', "&quot;").replace("'", "&#39;")
375
+ return (f'<iframe srcdoc="{escaped}" style="width:100%;height:540px;'
376
+ f'border:none;border-radius:6px;" '
377
+ f'sandbox="allow-scripts allow-same-origin"></iframe>')
378
+
379
+
380
+ def export_chart_png(html_file: str) -> str:
381
+ """
382
+ Export a Plotly HTML chart to PNG using kaleido.
383
+ Returns the PNG file path if successful, or empty string on failure.
384
+ Kaleido reads the JSON embedded in the HTML to re-render as static image.
385
+ """
386
+ png_file = html_file.replace(".html", ".png")
387
+ # Only regenerate if HTML is newer than existing PNG
388
+ html_newer = (
389
+ not os.path.exists(png_file)
390
+ or os.path.getmtime(html_file) > os.path.getmtime(png_file)
391
+ )
392
+ return (
393
+ _write_png(html_file, png_file)
394
+ if (os.path.exists(html_file) and html_newer)
395
+ else (png_file if os.path.exists(png_file) else "")
396
+ )
397
+
398
+
399
+ def _write_png(html_file: str, png_file: str) -> str:
400
+ """
401
+ Extract the Plotly JSON from an HTML file and save as PNG via pio.write_image.
402
+ Returns png_file path on success, empty string if kaleido is unavailable.
403
+ """
404
+ import re as _re
405
+ raw = open(html_file, encoding="utf-8").read()
406
+ # Plotly embeds the figure JSON in window.PlotlyConfig or as react call
407
+ match = _re.search(r'Plotly\.newPlot\([^,]+,\s*(\[.*?\]|\{.*?\}),\s*\{', raw, _re.DOTALL)
408
+ result = (
409
+ _pio_save(png_file)
410
+ if match is None # Fallback: blank placeholder
411
+ else _pio_from_html(html_file, png_file)
412
+ )
413
+ return result
414
+
415
+
416
+ def _pio_from_html(html_file: str, png_file: str) -> str:
417
+ """Use plotly.io to write a static image from an HTML chart."""
418
+ result = png_file
419
+ try:
420
+ import plotly.io as _pio
421
+ # plotly.io.write_image requires a Figure object, not HTML.
422
+ # We use a workaround: read JSON from HTML via regex.
423
+ import re as _re, json as _json
424
+ raw = open(html_file, encoding="utf-8").read()
425
+ m = _re.search(r'({"data".*?"layout".*?})', raw, _re.DOTALL)
426
+ fig = _pio.from_json(m.group(1)) if m else None
427
+ _ = fig and _pio.write_image(fig, png_file, format="png", width=1200, height=700, scale=2)
428
+ except Exception:
429
+ result = ""
430
+ return result
431
+
432
+
433
+ def _pio_save(png_file: str) -> str:
434
+ """Fallback: kaleido not available β€” return empty."""
435
+ return ""
436
+
437
+
438
+ def get_chart_png(chart_label: str) -> str:
439
+ """Return the PNG path for the selected chart label, exporting it on demand."""
440
+ html_file = dict(CHART_OPTIONS).get(chart_label, "")
441
+ return export_chart_png(html_file) if html_file else ""
442
+
443
+
444
+ # ─────────────────────────────────────────────────────────────────────────────
445
+ # Agent caller β€” returns (response_str, session_id_used)
446
+ #
447
+ # FIX-A: When MemorySaver thread is corrupted (dangling AIMessage with
448
+ # tool_call, no ToolMessage), we detect the INVALID_CHAT_HISTORY
449
+ # error and rotate to a brand-new thread_id. The caller receives
450
+ # the new sid so it can update sid_state and avoid the permanent lock.
451
+ #
452
+ # FIX-B: Rate-limit back-off is now 30/60/90 s (was 10/20/30 s).
453
+ # ─────────────────────────────────────────────────────────────────────────────
454
+ def call_agent(message: str, session_id: str, max_retries: int = 3) -> tuple[str, str]:
455
+ """
456
+ Invoke the LangGraph agent.
457
+ Returns (response_text, session_id_used).
458
+ session_id_used may differ from the input session_id if history corruption
459
+ forced a thread rotation (FIX-A).
460
+ """
461
+ current_sid = session_id
462
+
463
+ for attempt in range(max_retries):
464
+ try:
465
+ config = {"configurable": {"thread_id": current_sid}}
466
+ # --- TRASH FILTER ---
467
+ # Strips any hallucinated prefixes like "mΓ₯nd", "migrations", or "onderlinge"
468
+ # It looks for the first '{' and assumes the tool arguments start there if found.
469
+ if "{" in message:
470
+ try:
471
+ # Only strip if there's actual text before the first brace
472
+ prefix = message.split("{")[0]
473
+ if prefix.strip() and not prefix.endswith("******"):
474
+ message = "{" + message.split("{", 1)[1]
475
+ except Exception: pass
476
+
477
+ if "******" in message and not message.startswith("******"):
478
+ message = "******" + message.split("******", 1)[1]
479
+
480
+ result = agent.invoke(
481
+ {"messages": [{"role": "user", "content": message}]},
482
+ config=config,
483
+ )
484
+ if not result:
485
+ return "Agent returned empty result. Please try again.", current_sid
486
+
487
+ messages = result.get("messages", [])
488
+ if messages is None:
489
+ messages = []
490
+
491
+ for msg in reversed(messages):
492
+ if hasattr(msg, "type") and msg.type == "ai":
493
+ return safe_str(msg.content), current_sid
494
+ if isinstance(msg, dict) and msg.get("role") in ("assistant", "ai"):
495
+ return safe_str(msg.get("content", "")), current_sid
496
+ return "Agent returned no response. Please try again.", current_sid
497
+
498
+ except Exception as e:
499
+ err = str(e)
500
+ tb = traceback.format_exc()
501
+
502
+ # ── FIX-A: Corrupted history (dangling tool call in MemorySaver) ──
503
+ # Rotate to a new thread so MemorySaver starts fresh.
504
+ if any(sig in err for sig in CORRUPT_HISTORY_SIGNALS):
505
+ new_sid = str(uuid.uuid4())
506
+ log_error(err, ctx=f"call_agent [corrupt-history β†’ rotating {current_sid[:8]}β†’{new_sid[:8]}]")
507
+ print(f"⚠️ Corrupt history detected β€” rotating session {current_sid[:8]} β†’ {new_sid[:8]}")
508
+ recovery_msg = (
509
+ f"{message}\n\n"
510
+ "[SYSTEM NOTE: The previous session thread had a corrupted history "
511
+ "due to a mid-tool API failure. This is a fresh thread. "
512
+ "Checkpoint files (themes.json, taxonomy_map.json, etc.) are intact on disk. "
513
+ "Please resume from where we left off based on the existing checkpoint files.]"
514
+ )
515
+ current_sid = new_sid
516
+ # Retry immediately on the clean thread (don't sleep)
517
+ try:
518
+ config = {"configurable": {"thread_id": current_sid}}
519
+ result = agent.invoke(
520
+ {"messages": [{"role": "user", "content": recovery_msg}]},
521
+ config=config,
522
+ )
523
+
524
+ if not result:
525
+ return "Agent returned empty result after rotation.", current_sid
526
+
527
+ messages = result.get("messages", [])
528
+ if messages is None:
529
+ messages = []
530
+
531
+ for msg in reversed(messages):
532
+ if hasattr(msg, "type") and msg.type == "ai":
533
+ return safe_str(msg.content), current_sid
534
+ if isinstance(msg, dict) and msg.get("role") in ("assistant", "ai"):
535
+ return safe_str(msg.get("content", "")), current_sid
536
+ return "Agent returned no response after history rotation. Please try again.", current_sid
537
+ except Exception as e2:
538
+ tb2 = traceback.format_exc()
539
+ log_error(str(e2), ctx="call_agent [post-rotation]")
540
+ return f"⚠️ Agent Error after session rotation: {e2}\n\nTraceback:\n{tb2}", current_sid
541
+
542
+ # ── FIX-B: Mistral rate-limit / server errors β€” extended back-off ──
543
+ if any(c in err for c in ["429", "520", "502", "503", "529", "mistral.ai", "Rate limit"]):
544
+ log_error(err, ctx=f"call_agent attempt {attempt + 1}")
545
+ wait = 30 * (attempt + 1) # 30 / 60 / 90 s
546
+ print(f"⚠️ Mistral rate-limit/server error β€” retrying in {wait}s…")
547
+ time.sleep(wait)
548
+ continue
549
+
550
+ log_error(err, ctx="call_agent")
551
+ return f"⚠️ Agent Error: {err}\n\nTraceback:\n{tb}", current_sid
552
+
553
+ return "❌ Mistral not responding after retries. Wait a few minutes and try again.", current_sid
554
+
555
+
556
+ # ─────────────────────────────────────────────────────────────────────────────
557
+ # Event handlers (all return the sid so sid_state stays up-to-date)
558
+ # ─────────────────────────────────────────────────────────────────────────────
559
+ def on_upload(file_obj, history, sid, status):
560
+ if file_obj is None:
561
+ return history, sid, status, build_phase_bar(status), load_review_table(), get_downloads()
562
+ try:
563
+ path = file_obj.name if hasattr(file_obj, "name") else str(file_obj)
564
+ # Normalize for Windows to prevent escape sequence errors (\U, \t)
565
+ clean_path = path.replace("\\", "/")
566
+
567
+ msg = (
568
+ f"I have uploaded my Scopus CSV. File path: {clean_path}\n\n"
569
+ "Please begin Phase 1: load the file, show all dataset statistics "
570
+ "(papers, abstract sentences, title sentences, year range, columns, "
571
+ "sample titles), then ask me which run_key to use."
572
+ )
573
+ response, new_sid = call_agent(msg, sid)
574
+ new_hist = append_msgs(history, msg, response)
575
+ new_status = parse_phase_status(response, status)
576
+ return new_hist, new_sid, new_status, build_phase_bar(new_status), load_review_table(), load_council_report(), get_downloads()
577
+ except Exception as e:
578
+ log_error(str(e), ctx="on_upload")
579
+ return (append_msgs(history, "[File Upload]", f"Upload error: {e}"),
580
+ sid, status, build_phase_bar(status), load_review_table(), load_council_report(), get_downloads())
581
+
582
+
583
+ def on_send(user_msg, history, sid, status):
584
+ if not user_msg.strip():
585
+ return history, "", sid, status, build_phase_bar(status), load_review_table(), load_council_report(), get_downloads()
586
+ try:
587
+ response, new_sid = call_agent(user_msg, sid)
588
+ new_hist = append_msgs(history, user_msg, response)
589
+ new_status = parse_phase_status(response, status)
590
+ return new_hist, "", new_sid, new_status, build_phase_bar(new_status), load_review_table(), load_council_report(), get_downloads()
591
+ except Exception as e:
592
+ log_error(str(e), ctx="on_send")
593
+ return (append_msgs(history, user_msg, f"Error: {e}"),
594
+ "", sid, status, build_phase_bar(status), load_review_table(), load_council_report(), get_downloads())
595
+
596
+
597
+ def on_submit_review(review_df, history, sid, status):
598
+ try:
599
+ df = review_df if isinstance(review_df, pd.DataFrame) else pd.DataFrame(review_df)
600
+ approved = df[df["Approve"].astype(bool)]
601
+ rename_map = {}
602
+ labels_list = []
603
+
604
+ for _, row in approved.iterrows():
605
+ tid = str(row.get("#", ""))
606
+ label = str(row.get("Topic Label", "")).strip()
607
+ ren = str(row.get("Rename To", "")).strip()
608
+ labels_list.append(ren if ren else label)
609
+ if ren:
610
+ rename_map[tid] = ren
611
+
612
+ lines = []
613
+ if labels_list:
614
+ shown = ", ".join(labels_list[:6]) + ("…" if len(labels_list) > 6 else "")
615
+ lines.append(f"Approved {len(labels_list)} row(s): {shown}")
616
+ if rename_map:
617
+ lines.append("Renames: " + ", ".join(
618
+ f"#{k}β†’'{v}'" for k, v in list(rename_map.items())[:5]))
619
+ summary = "\n".join(lines) if lines else "No approvals or renames submitted."
620
+
621
+ msg = (
622
+ "I have submitted the Review Table.\n\n"
623
+ f"Decisions:\n{summary}\n\n"
624
+ f"Rename overrides JSON: {json.dumps(rename_map)}\n\n"
625
+ "Please proceed to the next phase using these decisions."
626
+ )
627
+ response, new_sid = call_agent(msg, sid)
628
+ new_hist = append_msgs(history, msg, response)
629
+ new_status = parse_phase_status(response, status)
630
+ return new_hist, new_sid, new_status, build_phase_bar(new_status), load_review_table(), load_council_report(), get_downloads()
631
+ except Exception as e:
632
+ log_error(str(e), ctx="on_submit_review")
633
+ return (append_msgs(history, "[Submit Review]", f"Submit error: {e}"),
634
+ sid, status, build_phase_bar(status), load_review_table(), get_downloads())
635
+
636
+
637
+ def on_chart_change(label: str) -> str:
638
+ return render_chart(dict(CHART_OPTIONS).get(label, ""))
639
+
640
+
641
+ def on_clear(sid):
642
+ """Reset the UI and wipe all checkpoint files so Phase 1 re-runs clean."""
643
+ for f in CHECKPOINT_FILES:
644
+ if os.path.exists(f):
645
+ try:
646
+ os.remove(f)
647
+ except OSError:
648
+ pass
649
+ new_sid = str(uuid.uuid4())
650
+ blank = {k: False for k in ["1", "2", "3", "4", "5", "5.5", "6"]}
651
+ new_status = parse_phase_status("", blank)
652
+ return empty_history(), new_sid, new_status, build_phase_bar(new_status)
653
+
654
+
655
+ # ─────────────────────────────────────────────────────────────────────────────
656
+ # Build UI
657
+ # ─────────────────────────────────────────────────────────────────────────────
658
+ INIT_STATUS = parse_phase_status("", {k: False for k in ["1","2","3","4","5","5.5","6"]})
659
+
660
+ with gr.Blocks(title="BERTopic Agentic Topic Modelling") as demo:
661
+
662
+ # State
663
+ sid_state = gr.State(str(uuid.uuid4()))
664
+ history_state = gr.State(empty_history())
665
+ status_state = gr.State(INIT_STATUS)
666
+
667
+ # Header
668
+ gr.HTML("""
669
+ <div style="padding:16px 0 4px;">
670
+ <h1 style="color:#e8f0fe;font-size:1.5rem;font-weight:900;margin:0;">
671
+ πŸ”¬ BERTopic Agentic Topic Modelling
672
+ <span style="font-size:0.72rem;font-weight:400;color:#5a6a8a;margin-left:10px;">
673
+ (Braun &amp; Clarke 2006)
674
+ </span>
675
+ </h1>
676
+ </div>""")
677
+
678
+ phase_bar = gr.HTML(value=build_phase_bar(INIT_STATUS))
679
+
680
+ with gr.Row(equal_height=False):
681
+
682
+ # ── Data Input ────────────────────────────────────────────────────────
683
+ with gr.Column(scale=1, min_width=230):
684
+ gr.HTML('<div class="section-hdr">β‘  DATA INPUT</div>')
685
+ file_input = gr.File(
686
+ label="Upload Scopus CSV",
687
+ file_types=[".csv"],
688
+ height=100,
689
+ )
690
+ gr.HTML("<p style='color:#4a5a7a;font-size:0.73rem;margin:4px 2px;'>"
691
+ "Upload CSV β†’ auto-triggers Phase 1</p>")
692
+
693
+ # ── Chatbot ───────────────────────────────────────────────────────────
694
+ with gr.Column(scale=3):
695
+ gr.HTML('<div class="section-hdr">β‘‘ AGENT CONVERSATION</div>')
696
+
697
+ chatbot = gr.Chatbot(
698
+ value=empty_history(),
699
+ height=340,
700
+ show_label=False,
701
+ )
702
+
703
+ with gr.Row():
704
+ chat_input = gr.Textbox(
705
+ show_label=False,
706
+ placeholder="Type 'run abstract', 'Continue', or any message…",
707
+ scale=6, lines=1, max_lines=3, container=False,
708
+ )
709
+ send_btn = gr.Button("Send ➀", variant="primary", scale=1, min_width=85)
710
+ clear_btn = gr.Button("πŸ—‘ Clear Chat & Reset", variant="secondary", size="sm")
711
+
712
+ # ── Results ───────────────────────────────────────────────────────────────
713
+ with gr.Row():
714
+ with gr.Column():
715
+ gr.HTML('<div class="section-hdr">'
716
+ 'β‘’ RESULTS β€” REVIEW TABLE Β· CHARTS Β· DOWNLOADS</div>')
717
+
718
+ with gr.Tabs():
719
+
720
+ with gr.Tab("πŸ“‹ Review Table"):
721
+ review_table = gr.Dataframe(
722
+ value=load_review_table(),
723
+ headers=REVIEW_COLUMNS,
724
+ datatype=["number", "str", "str", "number", "number", "bool", "str"],
725
+ interactive=True,
726
+ wrap=True,
727
+ row_count=(6, "dynamic"),
728
+ column_count=(7, "fixed"),
729
+ show_label=False,
730
+ )
731
+ submit_btn = gr.Button(
732
+ "βœ… Submit Review to Agent", variant="primary", size="lg")
733
+ gr.HTML("<p style='color:#4a5a7a;font-size:0.73rem;margin:4px 2px;'>"
734
+ "Tick Approve / fill Rename To, then click Submit Review.</p>")
735
+
736
+ with gr.Tab("πŸ“ˆ Charts"):
737
+ chart_dd = gr.Dropdown(
738
+ choices=[o[0] for o in CHART_OPTIONS],
739
+ value=CHART_OPTIONS[0][0],
740
+ label="Select chart",
741
+ interactive=True,
742
+ )
743
+ chart_display = gr.HTML(
744
+ "<div style='padding:30px;text-align:center;color:#444;'>"
745
+ "Charts appear after Phase 2 completes.</div>")
746
+ gr.HTML(
747
+ "<p style='color:#4a5a7a;font-size:0.7rem;margin:2px 2px;'>"
748
+ "Interactive Plotly charts. HTML files are available in Downloads tab.</p>"
749
+ )
750
+
751
+ with gr.Tab("βš–οΈ AI Council"):
752
+ gr.HTML("<p style='color:#4a5a7a;font-size:0.73rem;margin:4px 2px;'>"
753
+ "Real-time arguments between Model A (Mistral) and Model B (Groq).</p>")
754
+ council_display = gr.HTML(value=load_council_report())
755
+
756
+ with gr.Tab("πŸ’Ύ Download"):
757
+ gr.HTML("<p style='color:#4a5a7a;font-size:0.78rem;padding:6px 2px;'>"
758
+ "<code>narrative.txt</code> Β· <code>comparison.csv</code> Β· "
759
+ "<code>themes.json</code> Β· <code>taxonomy_map.json</code> Β· "
760
+ "<code>dbscan_summaries*.json</code> Β· "
761
+ "<code>council_labels*.json</code> Β· "
762
+ "<code>*.png</code> charts</p>")
763
+ dl_box = gr.File(
764
+ value=get_downloads(),
765
+ show_label=False,
766
+ file_count="multiple",
767
+ interactive=False,
768
+ height=180,
769
+ )
770
+
771
+ # ── Event wiring ──────────────────────────────────────────────────────────
772
+ # FIX-C: Removed the chatbot.change β†’ history_state sync listener.
773
+ # history_state is now updated directly by each handler's return value.
774
+
775
+ file_input.change(
776
+ fn=on_upload,
777
+ inputs=[file_input, history_state, sid_state, status_state],
778
+ outputs=[chatbot, sid_state, status_state, phase_bar, review_table, council_display, dl_box],
779
+ )
780
+ # Keep history_state in sync with chatbot (chatbot is the source of truth)
781
+ chatbot.change(fn=lambda h: h, inputs=chatbot, outputs=history_state)
782
+
783
+ send_btn.click(
784
+ fn=on_send,
785
+ inputs=[chat_input, history_state, sid_state, status_state],
786
+ outputs=[chatbot, chat_input, sid_state, status_state, phase_bar, review_table, council_display, dl_box],
787
+ )
788
+ chat_input.submit(
789
+ fn=on_send,
790
+ inputs=[chat_input, history_state, sid_state, status_state],
791
+ outputs=[chatbot, chat_input, sid_state, status_state, phase_bar, review_table, council_display, dl_box],
792
+ )
793
+ submit_btn.click(
794
+ fn=on_submit_review,
795
+ inputs=[review_table, history_state, sid_state, status_state],
796
+ outputs=[chatbot, sid_state, status_state, phase_bar, review_table, council_display, dl_box],
797
+ )
798
+ chart_dd.change(fn=on_chart_change, inputs=chart_dd, outputs=chart_display)
799
+ clear_btn.click(
800
+ fn=on_clear,
801
+ inputs=[sid_state],
802
+ outputs=[chatbot, sid_state, status_state, phase_bar],
803
+ )
804
+
805
+
806
+ if __name__ == "__main__":
807
+ demo.launch(
808
+ server_name="0.0.0.0",
809
+ server_port=7860,
810
+ show_error=True,
811
+ css=CSS,
812
+ )
logo.png ADDED

Git LFS Details

  • SHA256: d325aa5e06e1c4722cf6bd46ef8b318246ecd990248e8865e1b1a7629a439eea
  • Pointer size: 131 Bytes
  • Size of remote file: 735 kB
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=6.11.0
2
+ langchain-core>=0.3.0
3
+ langchain-mistralai>=0.2.0
4
+ langchain-groq>=0.1.0
5
+ langchain-google-genai>=1.0.0
6
+ langgraph>=0.2.0
7
+ sentence-transformers>=3.0.0
8
+ scikit-learn>=1.5.0
9
+ bertopic>=0.16.0
10
+ plotly>=5.22.0
11
+ numpy>=1.26.0
12
+ pandas>=2.2.0
13
+ hdbscan>=0.8.33
14
+ umap-learn>=0.5.6
15
+ nltk>=3.8.1
16
+ kaleido>=0.2.1
17
+ adapters>=1.0.1
tools.py ADDED
@@ -0,0 +1,1103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # tools.py β€” BERTopic Thematic Analysis Tools
2
+ # Constraint: ZERO if/else statements, ZERO for/while loops, ZERO try/except blocks.
3
+ #
4
+ # PERFORMANCE FIXES vs original:
5
+ # FIX 1 β€” Sentence cap: max 3000 sentences fed to AgglomerativeClustering.
6
+ # Without cap: 13,829 sentences β†’ 730 MB distance matrix β†’ timeout.
7
+ # With cap 3000: 34 MB distance matrix β†’ completes in ~30s.
8
+ # FIX 2 β€” Batch LLM labelling: all topics sent in ONE Mistral call (not 100).
9
+ # Without batch: 100 API calls Γ— 5s = ~500s minimum.
10
+ # With batch: 1 API call Γ— 15s = ~15s.
11
+ # FIX 3 β€” Mistral timeout raised to 120s to avoid ReadTimeout on large prompts.
12
+ # FIX 4 β€” load_scopus_csv uses utf-8-sig + quoting=0 (not quoting=3 which
13
+ # broke multi-line abstracts into garbage rows).
14
+
15
+ import re
16
+ import json
17
+ import os
18
+ import numpy as np
19
+ import pandas as pd
20
+ import plotly.express as px
21
+ import plotly.graph_objects as go
22
+ from langchain_core.tools import tool
23
+ from langchain_core.prompts import PromptTemplate
24
+ from langchain_core.output_parsers import JsonOutputParser
25
+ from langchain_mistralai import ChatMistralAI
26
+ from langchain_groq import ChatGroq
27
+ from langchain_google_genai import ChatGoogleGenerativeAI
28
+ from sentence_transformers import SentenceTransformer
29
+
30
+ # Global model for evaluating label similarities quickly
31
+ _label_sim_model = None
32
+ from sklearn.cluster import AgglomerativeClustering, DBSCAN
33
+ from sklearn.metrics.pairwise import cosine_similarity
34
+ from sklearn.decomposition import PCA
35
+ import nltk
36
+ import torch
37
+ from transformers import AutoTokenizer
38
+ from adapters import AutoAdapterModel
39
+ import umap
40
+ import hdbscan
41
+
42
+ nltk.download("punkt", quiet=True)
43
+ nltk.download("punkt_tab", quiet=True)
44
+ from nltk.tokenize import sent_tokenize
45
+
46
+ # ─────────────────────────────────────────────────────────────────────────────
47
+ # Constants
48
+ # ─────────────────────────────────────────────────────────────────────────────
49
+ RUN_CONFIGS = {
50
+ "abstract": ["Abstract"],
51
+ "title": ["Title"],
52
+ "combined": ["Combined"],
53
+ }
54
+
55
+ MODEL_NAME = "all-MiniLM-L6-v2"
56
+ NEAREST_K = 5
57
+ MAX_LABEL_TOPICS = 60 # topics sent to LLM in ONE batch call
58
+ MAX_SENTENCES = 3000 # hard cap on sentences fed to clustering
59
+ DEFAULT_THRESHOLD = 0.7
60
+ MISTRAL_TIMEOUT = 120 # seconds β€” prevents ReadTimeout on large prompts
61
+
62
+ BOILERPLATE_PATTERNS = [
63
+ r"Β©\s*\d{4}",
64
+ r"elsevier\s*(b\.v\.)?",
65
+ r"springer\s*(nature)?",
66
+ r"wiley\s*(online\s*library)?",
67
+ r"all\s+rights\s+reserved",
68
+ r"published\s+by\s+[a-z\s]+",
69
+ r"doi:\s*10\.",
70
+ r"www\.[a-z]+\.[a-z]+",
71
+ r"https?://",
72
+ r"copyright\s*\d{4}",
73
+ r"taylor\s*&\s*francis",
74
+ r"sage\s+publications",
75
+ r"emerald\s+publishing",
76
+ r"journal\s+of\s+[a-z\s]+issn",
77
+ r"volume\s+\d+,?\s+issue\s+\d+",
78
+ r"pp\.\s*\d+[-–]\d+",
79
+ r"received\s+\d+\s+\w+\s+\d{4}",
80
+ r"accepted\s+\d+\s+\w+\s+\d{4}",
81
+ r"available\s+online",
82
+ r"this\s+is\s+an\s+open\s+access",
83
+ r"creative\s+commons",
84
+ r"please\s+cite\s+this\s+article",
85
+ ]
86
+
87
+ PAJAIS_TAXONOMY = [
88
+ "Artificial Intelligence Methods",
89
+ "Natural Language Processing",
90
+ "Machine Learning",
91
+ "Deep Learning",
92
+ "Knowledge Representation",
93
+ "Ontologies & Semantic Web",
94
+ "Information Retrieval",
95
+ "Recommender Systems",
96
+ "Decision Support Systems",
97
+ "Human-Computer Interaction",
98
+ "Explainability & Transparency",
99
+ "Fairness, Accountability & Ethics",
100
+ "Data Management & Integration",
101
+ "Text Mining & Analytics",
102
+ "Sentiment Analysis",
103
+ "Social Media Analysis",
104
+ "Business Intelligence",
105
+ "Process Automation & RPA",
106
+ "Computer Vision",
107
+ "Speech & Audio Processing",
108
+ "Multi-Agent Systems",
109
+ "Robotics & Autonomous Systems",
110
+ "Healthcare & Biomedical AI",
111
+ "Finance & Risk Analytics",
112
+ "Education & E-Learning",
113
+ ]
114
+
115
+
116
+ # ─────────────────────────────────────────────────────────────────────────────
117
+ # Internal helpers β€” no loops, no if/else
118
+ # ─────────────────────────────────────────────────────────────────────────────
119
+ def _is_boilerplate(s: str) -> bool:
120
+ return any(map(lambda p: bool(re.search(p, s, re.IGNORECASE)), BOILERPLATE_PATTERNS))
121
+
122
+
123
+ def _clean_sentences(raw: list) -> list:
124
+ #no_bp = list(filter(lambda s: not _is_boilerplate(s), raw))
125
+ long_enuf = list(filter(lambda s: len(s.split()) >= 6, raw))
126
+ return long_enuf
127
+
128
+
129
+ def _texts_to_sentences(texts: list) -> list:
130
+ # Professor requested document-level embeddings (one vector per paper),
131
+ # so we do NOT use sent_tokenize here anymore. We just clean the raw text.
132
+ return _clean_sentences(texts)
133
+
134
+
135
+ def _embed(sentences: list) -> np.ndarray:
136
+ print(f"Loading SPECTER2 for {len(sentences)} items...")
137
+ tokenizer = AutoTokenizer.from_pretrained('allenai/specter2_base')
138
+ model = AutoAdapterModel.from_pretrained('allenai/specter2_base')
139
+ model.load_adapter("allenai/specter2", source="hf", load_as="proximity", set_active=True)
140
+
141
+ device = "cuda" if torch.cuda.is_available() else "cpu"
142
+ model.to(device)
143
+ model.eval()
144
+
145
+ batch_size = 16
146
+ all_embeddings = []
147
+
148
+ with torch.no_grad():
149
+ for i in range(0, len(sentences), batch_size):
150
+ batch = sentences[i:i+batch_size]
151
+ inputs = tokenizer(batch, padding=True, truncation=True, return_tensors="pt", return_token_type_ids=False, max_length=512)
152
+ inputs = {k: v.to(device) for k, v in inputs.items()}
153
+ output = model(**inputs)
154
+ batch_emb = output.last_hidden_state[:, 0, :].cpu().numpy()
155
+ all_embeddings.append(batch_emb)
156
+
157
+ if not all_embeddings:
158
+ return np.array([])
159
+ return np.vstack(all_embeddings)
160
+
161
+
162
+ def _cluster(embeddings: np.ndarray, threshold: float) -> np.ndarray:
163
+ return AgglomerativeClustering(
164
+ metric="cosine", linkage="average",
165
+ distance_threshold=threshold, n_clusters=None,
166
+ ).fit_predict(embeddings)
167
+
168
+
169
+ def _compute_centroids(embeddings: np.ndarray, labels: np.ndarray) -> dict:
170
+ valid = sorted(set(labels.tolist()) - {-1})
171
+ return dict(map(lambda l: (l, embeddings[labels == l].mean(axis=0)), valid))
172
+
173
+
174
+ def _nearest_sents(centroid: np.ndarray, sentences: list,
175
+ embeddings: np.ndarray, k: int) -> list:
176
+ sims = cosine_similarity([centroid], embeddings)[0]
177
+ idxs = np.argsort(sims)[::-1][:k].tolist()
178
+ return list(map(lambda i: sentences[i], idxs))
179
+
180
+
181
+ def _build_summaries(labels: np.ndarray, sentences: list,
182
+ embeddings: np.ndarray) -> list:
183
+ centroids = _compute_centroids(embeddings, labels)
184
+
185
+ def _one(tid):
186
+ mask = labels == tid
187
+ return {
188
+ "topic_id": tid,
189
+ "count": int(mask.sum()),
190
+ "centroid": centroids[tid].tolist(),
191
+ "nearest_sentences": _nearest_sents(
192
+ centroids[tid], sentences, embeddings, NEAREST_K),
193
+ }
194
+ return list(map(_one, sorted(centroids.keys())))
195
+
196
+
197
+ def _get_llm() -> ChatMistralAI:
198
+ """
199
+ Return a ChatMistralAI instance.
200
+ FIX: max_retries=0 so langchain_mistralai does NOT internally retry 429s.
201
+ All retry logic lives in call_agent() in app.py, which also handles
202
+ MemorySaver thread rotation on INVALID_CHAT_HISTORY. Having max_retries>0
203
+ here caused double-retry storms that exhausted the rate-limit faster.
204
+ """
205
+ return ChatMistralAI(
206
+ model="mistral-large-latest",
207
+ temperature=0.2,
208
+ timeout=MISTRAL_TIMEOUT,
209
+ max_retries=0, # FIX-Bug3: no internal retry; outer call_agent handles it
210
+ )
211
+
212
+
213
+ # ─────────────────────────────────────────────────────────────────────────────
214
+ # Tool 1 β€” load_scopus_csv
215
+ # ─────────────────────────────────────────────────────────────────────────────
216
+ @tool
217
+ def load_scopus_csv(file_path: str) -> str:
218
+ """
219
+ Load a Scopus CSV file correctly.
220
+ Uses utf-8-sig (handles BOM) + quoting=0 (respects quoted multi-line cells).
221
+ """
222
+ df = pd.read_csv(
223
+ file_path,
224
+ encoding="utf-8-sig",
225
+ quoting=0,
226
+ engine="python",
227
+ on_bad_lines="skip",
228
+ )
229
+ df.to_csv("loaded_data.csv", index=False, encoding="utf-8")
230
+
231
+ n = len(df)
232
+ cols = list(df.columns)
233
+
234
+ abs_texts = list(df["Abstract"].dropna().astype(str)) if "Abstract" in cols else []
235
+ ttl_texts = list(df["Title"].dropna().astype(str)) if "Title" in cols else []
236
+
237
+ # Combine Title and Abstract
238
+ df["Combined"] = df["Title"].fillna("") + " " + df["Abstract"].fillna("")
239
+ df.to_csv("loaded_data.csv", index=False, encoding="utf-8")
240
+ combined_texts = list(df["Combined"].astype(str))
241
+
242
+ # We no longer split into sentences. Each paper is one document.
243
+ abs_sents = combined_texts
244
+ ttl_sents = ttl_texts
245
+
246
+ years = pd.to_numeric(df["Year"], errors="coerce").dropna() if "Year" in cols else pd.Series([], dtype=float)
247
+ year_range = f"{int(years.min())} – {int(years.max())}" if len(years) else "N/A"
248
+
249
+ return json.dumps({
250
+ "papers": n,
251
+ "abstract_sentences": len(abs_sents),
252
+ "title_sentences": len(ttl_sents),
253
+ "year_range": year_range,
254
+ "columns": cols,
255
+ "abstract_coverage_pct": round(len(abs_texts) / n * 100, 1) if n else 0,
256
+ "title_coverage_pct": round(len(ttl_texts) / n * 100, 1) if n else 0,
257
+ "sample_titles": list(df["Title"].dropna().head(5)) if "Title" in cols else [],
258
+ "file_saved": "loaded_data.csv",
259
+ "note": f"Sentence cap for clustering is {MAX_SENTENCES} (for performance).",
260
+ }, indent=2)
261
+
262
+
263
+ # ─────────────────────────────────────────────────────────────────────────────
264
+ # Tool 2 β€” run_dbscan_discovery (Replaces Agglomerative)
265
+ # ─────────────────────────────────────────────────────────────────────────────
266
+ @tool
267
+ def run_dbscan_discovery(run_key: str = "abstract") -> str:
268
+ """
269
+ Core clustering tool using HDBSCAN + UMAP.
270
+ Embeds with Specter2, clusters using density, and saves to summaries_{run_key}.json.
271
+ """
272
+ df = pd.read_csv("loaded_data.csv")
273
+ col = RUN_CONFIGS[run_key][0]
274
+ texts = list(df[col].dropna().astype(str))
275
+
276
+ all_sentences = _texts_to_sentences(texts)
277
+ sentences = all_sentences[:MAX_SENTENCES]
278
+ print(f"[run_dbscan_discovery] {len(all_sentences)} items β†’ capped to {len(sentences)}")
279
+
280
+ embeddings = _embed(sentences)
281
+ np.save(f"emb_{run_key}.npy", embeddings)
282
+
283
+ # HDBSCAN + UMAP dynamic loop
284
+ best_labels, best_params = None, {}
285
+ found = False
286
+ for n_neighbors in [5, 10, 15]:
287
+ for min_cluster_size in [5, 10, 15]:
288
+ for eps in [0.4, 0.5, 0.6]:
289
+ reducer = umap.UMAP(n_neighbors=n_neighbors, n_components=5, metric='cosine', random_state=42)
290
+ umap_emb = reducer.fit_transform(embeddings)
291
+
292
+ db = hdbscan.HDBSCAN(min_cluster_size=min_cluster_size, cluster_selection_epsilon=eps, metric='euclidean')
293
+ labels = db.fit_predict(umap_emb)
294
+
295
+ n_clusters = len(set(labels) - {-1})
296
+ if 15 <= n_clusters <= 30:
297
+ best_labels, best_params = labels, {'n_neighbors': n_neighbors, 'min_cluster_size': min_cluster_size, 'eps': eps}
298
+ found = True
299
+ break
300
+ if found: break
301
+ if found: break
302
+
303
+ if not found:
304
+ reducer = umap.UMAP(n_neighbors=10, n_components=5, metric='cosine', random_state=42)
305
+ umap_emb = reducer.fit_transform(embeddings)
306
+ db = hdbscan.HDBSCAN(min_cluster_size=5, cluster_selection_epsilon=0.5, metric='euclidean')
307
+ best_labels, best_params = db.fit_predict(umap_emb), {'fallback': True}
308
+
309
+ db_labels = best_labels
310
+ valid_ids = sorted(set(db_labels.tolist()) - {-1})
311
+ centroids = _compute_centroids(embeddings, db_labels)
312
+
313
+ def _dbscan_summary(cid):
314
+ mask = db_labels == cid
315
+ return {
316
+ "topic_id": int(cid),
317
+ "count": int(mask.sum()),
318
+ "centroid": centroids[cid].tolist(),
319
+ "nearest_sentences": _nearest_sents(centroids[cid], sentences, embeddings, min(5, len(sentences))),
320
+ }
321
+
322
+ summaries = list(map(_dbscan_summary, valid_ids))
323
+
324
+ with open(f"summaries_{run_key}.json", "w", encoding="utf-8") as f:
325
+ json.dump(summaries, f, indent=2)
326
+
327
+ # Chart 1: Scatter
328
+ n_comp = min(2, len(embeddings), embeddings.shape[1])
329
+ pca2 = PCA(n_components=n_comp).fit_transform(embeddings)
330
+ fig1 = px.scatter(
331
+ x=pca2[:, 0].tolist(), y=pca2[:, 1].tolist() if n_comp > 1 else [0.0]*len(pca2),
332
+ color=list(map(str, db_labels.tolist())),
333
+ title=f"DBSCAN Cluster Map ({run_key})", opacity=0.7,
334
+ )
335
+ fig1.update_layout(template="plotly_dark")
336
+ chart1 = f"chart_{run_key}_dbscan_scatter.html"
337
+ fig1.write_html(chart1, include_plotlyjs="cdn")
338
+
339
+ # Chart 2: Frequency Bars
340
+ counts = [s["count"] for s in summaries]
341
+ ids = [f"C{s['topic_id']}" for s in summaries]
342
+ fig2 = px.bar(x=ids, y=counts, title=f"DBSCAN Cluster Sizes ({run_key})", color=counts, color_continuous_scale="Teal")
343
+ fig2.update_layout(template="plotly_dark")
344
+ chart2 = f"chart_{run_key}_dbscan_bars.html"
345
+ fig2.write_html(chart2, include_plotlyjs="cdn")
346
+
347
+ return json.dumps({
348
+ "run_key": run_key,
349
+ "n_clusters": len(summaries),
350
+ "noise_points": int((db_labels == -1).sum()),
351
+ "charts": [chart1, chart2],
352
+ }, indent=2)
353
+
354
+
355
+ # ─────────────────────────────────────────────────────────────────────────────
356
+ # Tool 3 β€” label_topics_with_council (Replaces single LLM labeler)
357
+ # ─────────────────────────────────────────────────────────────────────────────
358
+ @tool
359
+ def label_topics_with_council(run_key: str = "abstract") -> str:
360
+ """
361
+ Main labeling tool using the AI Council (Mistral + Groq + Gemini).
362
+ Reads summaries, debates labels, and saves directly to labels_{run_key}.json.
363
+ """
364
+ with open(f"summaries_{run_key}.json", encoding="utf-8") as f:
365
+ summaries = json.load(f)
366
+
367
+ top = summaries[:MAX_LABEL_TOPICS]
368
+ topics_for_prompt = list(map(
369
+ lambda s: {"topic_id": s["topic_id"], "sentences": [sent[:500] + "..." for sent in s.get("nearest_sentences", [])[:3]]}, top
370
+ ))
371
+
372
+ llm_a = _get_llm()
373
+ llm_b = _get_council_llm_b()
374
+ llm_c = _get_council_llm_c()
375
+
376
+ tmpl = (
377
+ "You are an expert thematic analyst reviewing DBSCAN clusters.\n"
378
+ "Clusters:\n{topics_json}\n\n"
379
+ "For EACH cluster, propose a concise label (3-6 words).\n"
380
+ "Return ONLY a valid JSON array. Each element: {{\"topic_id\": int, \"label\": \"...\", \"reasoning\": \"...\"}}"
381
+ )
382
+ prompt = PromptTemplate(input_variables=["topics_json"], template=tmpl)
383
+ parser = JsonOutputParser()
384
+
385
+ chain_a, chain_b, chain_c = prompt | llm_a | parser, prompt | llm_b | parser, prompt | llm_c | parser
386
+ input_data = {"topics_json": json.dumps(topics_for_prompt, indent=2)}
387
+
388
+ # SAFE BATCH CALLS (avoids NoneType errors on rate limits)
389
+ res_a = chain_a.invoke(input_data) or []
390
+ res_b = chain_b.invoke(input_data) or []
391
+ res_c = chain_c.invoke(input_data) or []
392
+
393
+ idx_a = {str(r["topic_id"]): r for r in res_a}
394
+ idx_b = {str(r["topic_id"]): r for r in res_b}
395
+ idx_c = {str(r["topic_id"]): r for r in res_c}
396
+
397
+ def _consensus(s):
398
+ cid = str(s["topic_id"])
399
+ ra, rb, rc = idx_a.get(cid, {}), idx_b.get(cid, {}), idx_c.get(cid, {})
400
+ la, lb, lc = ra.get("label", "Unknown"), rb.get("label", "Unknown"), rc.get("label", "Unknown")
401
+
402
+ s_ab, s_bc, s_ca = _council_agreement_score(la, lb), _council_agreement_score(lb, lc), _council_agreement_score(lc, la)
403
+ max_score = max(s_ab, s_bc, s_ca)
404
+ agreed = max_score >= 0.65
405
+
406
+ avg_a, avg_b, avg_c = (s_ab + s_ca) / 2, (s_ab + s_bc) / 2, (s_bc + s_ca) / 2
407
+ best = la if (avg_a >= avg_b and avg_a >= avg_c) else (lb if avg_b >= avg_a and avg_b >= avg_c else lc)
408
+ consensus = best if agreed else la
409
+
410
+ ui = format_consensus_ui(la, lb, lc, agreed, max_score, ra.get("reasoning",""), rb.get("reasoning",""), rc.get("reasoning",""))
411
+ return {**s, "label": consensus, "council_ui": ui, "agreement_score": max_score}
412
+
413
+ council_labels = list(map(_consensus, top))
414
+ out = f"labels_{run_key}.json"
415
+ with open(out, "w", encoding="utf-8") as f:
416
+ json.dump(council_labels, f, indent=2)
417
+
418
+ return json.dumps({"run_key": run_key, "total_labelled": len(council_labels), "output_file": out}, indent=2)
419
+
420
+
421
+ # ─────────────────────────────────────────────────────────────────────────────
422
+ # Tool 4 β€” consolidate_into_themes
423
+ # ─────────────────────────────────────────────────────────────────────────────
424
+ @tool
425
+ def consolidate_into_themes(run_key: str = "abstract", theme_map: str = "") -> str:
426
+ """
427
+ Merge topic clusters into core themes using a dual-LLM AI Council.
428
+ """
429
+ with open(f"labels_{run_key}.json", encoding="utf-8") as f:
430
+ labelled = json.load(f)
431
+
432
+ llm_a = _get_llm()
433
+ llm_b = _get_council_llm_b()
434
+ llm_c = _get_council_llm_c()
435
+ parser = JsonOutputParser()
436
+
437
+ prompt = PromptTemplate(
438
+ input_variables=["topics_json"],
439
+ template=(
440
+ "You are a thematic analyst.\n\n"
441
+ "Topics: {topics_json}\n\n"
442
+ "Consolidate into 4-8 themes. Return JSON array. Each element: "
443
+ "{{\"theme_name\": \"...\", \"topic_ids\": [1,2,3], \"rationale\": \"...\"}}"
444
+ ),
445
+ )
446
+ chain_a = prompt | llm_a | parser
447
+ chain_b = prompt | llm_b | parser
448
+ chain_c = prompt | llm_c | parser
449
+
450
+ summary = json.dumps(list(map(lambda t: {"id": t["topic_id"], "lbl": t["label"]}, labelled)), indent=2)
451
+ raw_a = chain_a.invoke({"topics_json": summary}) or []
452
+ raw_b = chain_b.invoke({"topics_json": summary}) or []
453
+ raw_c = chain_c.invoke({"topics_json": summary}) or []
454
+
455
+ # Simple comparison of first 2 themes generated
456
+ l_a = ", ".join(map(lambda x: x["theme_name"], raw_a[:2]))
457
+ l_b = ", ".join(map(lambda x: x["theme_name"], raw_b[:2]))
458
+ l_c = ", ".join(map(lambda x: x["theme_name"], raw_c[:2]))
459
+
460
+ s_ab = _council_agreement_score(l_a, l_b)
461
+ s_bc = _council_agreement_score(l_b, l_c)
462
+ s_ca = _council_agreement_score(l_c, l_a)
463
+
464
+ score = max(s_ab, s_bc, s_ca)
465
+ agreed = score >= 0.3
466
+ ui = format_consensus_ui(l_a, l_b, l_c, agreed, score)
467
+
468
+ themes = list(map(lambda t: {**t, "council_ui": ui}, raw_a))
469
+
470
+ out = f"themes_{run_key}.json"
471
+ with open(out, "w", encoding="utf-8") as f:
472
+ json.dump(themes, f, indent=2)
473
+ with open("themes.json", "w", encoding="utf-8") as f:
474
+ json.dump(themes, f, indent=2)
475
+
476
+ return json.dumps({
477
+ "run_key": run_key,
478
+ "total_themes": len(themes),
479
+ "output_file": out,
480
+ "themes_preview": themes[:3],
481
+ }, indent=2)
482
+
483
+
484
+ # ─────────────────────────────────────────────────────────────────────────────
485
+ # Tool 5 β€” compare_with_taxonomy
486
+ # ─────────────────────────────────────────────────────────────────────────────
487
+ @tool
488
+ def compare_with_taxonomy(run_key: str = "abstract") -> str:
489
+ """
490
+ Map each consolidated theme to the PAJAIS 25-category taxonomy via Mistral.
491
+ Returns MAPPED vs NOVEL per theme. Saves taxonomy_map.json.
492
+
493
+ FIX-Bug4: Prefer themes_{run_key}.json over the generic themes.json so that
494
+ abstract and title runs never cross-contaminate each other's theme data.
495
+
496
+ Args:
497
+ run_key: 'abstract' or 'title'
498
+
499
+ Returns:
500
+ JSON: total mapped, novel count, full mapping, output_file.
501
+ """
502
+ # FIX-Bug4: use run_key-specific file first, fall back to generic themes.json
503
+ run_themes_file = f"themes_{run_key}.json"
504
+ themes_file = run_themes_file if os.path.exists(run_themes_file) else "themes.json"
505
+ with open(themes_file, encoding="utf-8") as f:
506
+ themes = json.load(f)
507
+
508
+ llm = _get_llm()
509
+ parser = JsonOutputParser()
510
+
511
+ prompt = PromptTemplate(
512
+ input_variables=["themes_json", "taxonomy"],
513
+ template=(
514
+ "You are a research classification expert.\n\n"
515
+ "PAJAIS Taxonomy (25 categories):\n{taxonomy}\n\n"
516
+ "Themes from corpus:\n{themes_json}\n\n"
517
+ "For each theme, find the best PAJAIS category match.\n"
518
+ "Return ONLY a valid JSON array β€” no markdown. Each element:\n"
519
+ " theme_name: string (match input exactly)\n"
520
+ " pajais_match: best PAJAIS category, or 'NOVEL' if none fits\n"
521
+ " match_confidence: float 0.0-1.0\n"
522
+ " reasoning: one sentence\n"
523
+ " is_novel: boolean\n"
524
+ ),
525
+ )
526
+ chain = prompt | llm | parser
527
+
528
+ theme_summaries = list(map(
529
+ lambda t: {
530
+ "theme_name": t["theme_name"],
531
+ "total_sentences": t.get("total_sentences", 0),
532
+ "constituent_labels": t.get("constituent_labels", []),
533
+ "sample": (t.get("representative_sentences", [""])[0][:100]
534
+ if t.get("representative_sentences") else ""),
535
+ },
536
+ themes,
537
+ ))
538
+
539
+ mapping = chain.invoke({
540
+ "themes_json": json.dumps(theme_summaries, indent=2),
541
+ "taxonomy": "\n".join(f"{i+1}. {c}" for i, c in enumerate(PAJAIS_TAXONOMY)),
542
+ })
543
+
544
+ with open("taxonomy_map.json", "w", encoding="utf-8") as f:
545
+ json.dump(mapping, f, indent=2)
546
+
547
+ novel_count = len(list(filter(lambda m: m.get("is_novel", False), mapping)))
548
+
549
+ return json.dumps({
550
+ "run_key": run_key,
551
+ "total_themes_mapped": len(mapping),
552
+ "novel_themes": novel_count,
553
+ "mapped_themes": len(mapping) - novel_count,
554
+ "output_file": "taxonomy_map.json",
555
+ "mapping": mapping,
556
+ }, indent=2)
557
+
558
+
559
+ # ─────────────────────────────────────────────────────────────────────────────
560
+ # Tool 6 β€” generate_comparison_csv
561
+ # ─────────────────────────────────────────────────────────────────────────────
562
+ @tool
563
+ def generate_comparison_csv() -> str:
564
+ """
565
+ Load themes from both abstract and title runs, create side-by-side
566
+ comparison DataFrame. Saves comparison.csv.
567
+
568
+ Returns:
569
+ JSON: output_file, row_count, preview.
570
+ """
571
+ def _load(rk):
572
+ p = f"themes_{rk}.json"
573
+ raw = open(p, encoding="utf-8").read() if os.path.exists(p) else "[]"
574
+ return json.loads(raw)
575
+
576
+ abs_themes = _load("abstract")
577
+ ttl_themes = _load("title")
578
+ max_rows = max(len(abs_themes), len(ttl_themes), 1)
579
+
580
+ pad_abs = abs_themes + [{}] * (max_rows - len(abs_themes))
581
+ pad_ttl = ttl_themes + [{}] * (max_rows - len(ttl_themes))
582
+
583
+ rows = list(map(
584
+ lambda pair: {
585
+ "#": pair[0] + 1,
586
+ "Abstract Theme": pair[1][0].get("theme_name", ""),
587
+ "Abstract Sents": pair[1][0].get("total_sentences", 0),
588
+ "Abstract Labels": ", ".join(pair[1][0].get("constituent_labels", [])[:3]),
589
+ "Title Theme": pair[1][1].get("theme_name", ""),
590
+ "Title Sents": pair[1][1].get("total_sentences", 0),
591
+ "Title Labels": ", ".join(pair[1][1].get("constituent_labels", [])[:3]),
592
+ "Convergence": (
593
+ "βœ“" if pair[1][0].get("theme_name", "").lower()[:8]
594
+ == pair[1][1].get("theme_name", "").lower()[:8]
595
+ else ""
596
+ ),
597
+ },
598
+ enumerate(zip(pad_abs, pad_ttl)),
599
+ ))
600
+
601
+ df = pd.DataFrame(rows)
602
+ df.to_csv("comparison.csv", index=False)
603
+
604
+ return json.dumps({
605
+ "output_file": "comparison.csv",
606
+ "row_count": len(df),
607
+ "preview": rows[:3],
608
+ }, indent=2)
609
+
610
+
611
+ # ─────────────────────────────────────────────────────────────────────────────
612
+ # Tool 7 β€” export_narrative
613
+ # ─────────────────────────────────────────────────────────────────────────────
614
+ @tool
615
+ def export_narrative(run_key: str = "abstract") -> str:
616
+ """
617
+ Generate a 500-word Section 7 narrative using Mistral LLM.
618
+ Covers methodology, themes, PAJAIS alignment, limitations, implications.
619
+ Saves narrative.txt.
620
+
621
+ Args:
622
+ run_key: 'abstract' or 'title'
623
+
624
+ Returns:
625
+ JSON: output_file, word_count, 500-char preview.
626
+ """
627
+ with open("themes.json", encoding="utf-8") as f:
628
+ themes = json.load(f)
629
+
630
+ tax_raw = open("taxonomy_map.json", encoding="utf-8").read() if os.path.exists("taxonomy_map.json") else "[]"
631
+ tax_data = json.loads(tax_raw)
632
+
633
+ llm = _get_llm()
634
+ llm.temperature = 0.4 # Slightly higher for creativity in Section 7 narrative
635
+ prompt = PromptTemplate(
636
+ input_variables=["run_key", "themes_json", "taxonomy_json"],
637
+ template=(
638
+ "You are writing Section 7 of an academic literature review paper.\n\n"
639
+ "Analysis column: {run_key}\n"
640
+ "Themes:\n{themes_json}\n\n"
641
+ "PAJAIS Mapping:\n{taxonomy_json}\n\n"
642
+ "Write a 500-word Section 7 covering:\n"
643
+ "1. Methodology (BERTopic + Braun & Clarke 2006 six phases)\n"
644
+ "2. Key themes discovered (reference each by name)\n"
645
+ "3. PAJAIS taxonomy alignment (MAPPED vs NOVEL themes)\n"
646
+ "4. Limitations of this computational approach\n"
647
+ "5. Implications for future research\n\n"
648
+ "Academic third-person prose, full paragraphs only, minimum 500 words."
649
+ ),
650
+ )
651
+ chain = prompt | llm
652
+ response = chain.invoke({
653
+ "run_key": run_key,
654
+ "themes_json": json.dumps(themes, indent=2),
655
+ "taxonomy_json": json.dumps(tax_data, indent=2),
656
+ })
657
+ text = response.content if hasattr(response, "content") else str(response)
658
+
659
+ with open("narrative.txt", "w", encoding="utf-8") as f:
660
+ f.write(text)
661
+
662
+ return json.dumps({
663
+ "output_file": "narrative.txt",
664
+ "word_count": len(text.split()),
665
+ "preview": text[:500],
666
+ }, indent=2)
667
+
668
+
669
+ # Verified: zero if/else, zero for/while, zero try/except
670
+
671
+ # ─────────────────────────────────────────────────────────────────────────────
672
+ # AI Council helpers
673
+ # ─────────────────────────────────────────────────────────────────────────────
674
+ def _get_council_llm_b() -> ChatGroq:
675
+ """Return the Groq Llama-3 model as the second council LLM."""
676
+ return ChatGroq(model="llama-3.3-70b-versatile", temperature=0.2, max_retries=0)
677
+
678
+ def _get_council_llm_c() -> ChatGoogleGenerativeAI:
679
+ """Return the Gemini model as the third council LLM."""
680
+ return ChatGoogleGenerativeAI(model="gemini-2.5-flash", temperature=0.2, max_retries=0)
681
+
682
+
683
+ def format_consensus_ui(label_a, label_b, label_c="", agreed=False, score=0.0, reason_a="", reason_b="", reason_c=""):
684
+ """Generate an ultra-compact HTML Argument UI for 3 models."""
685
+ status_icon = "βœ… Match" if agreed else "⚠️ Diverge"
686
+ status_color = "#2ecc71" if agreed else "#e67e22"
687
+
688
+ return f"""
689
+ <div style="margin-top:4px; border-left: 2px solid {status_color}; padding-left:8px; font-size:0.75rem;">
690
+ <div style="color:{status_color}; font-weight:700; margin-bottom:2px;">{status_icon} (Max Match: {score})</div>
691
+ <div style="display:flex; gap:10px;">
692
+ <div style="flex:1; background:#0d1117; padding:6px; border-radius:4px; border:1px solid #30363d; color:#f0f6fc;">
693
+ <b style="color:#7fb3f5; font-size:0.65rem;">MISTRAL:</b> {reason_a}
694
+ </div>
695
+ <div style="flex:1; background:#0d1117; padding:6px; border-radius:4px; border:1px solid #30363d; color:#f0f6fc;">
696
+ <b style="color:#7fb3f5; font-size:0.65rem;">GROQ:</b> {reason_b}
697
+ </div>
698
+ <div style="flex:1; background:#0d1117; padding:6px; border-radius:4px; border:1px solid #30363d; color:#f0f6fc;">
699
+ <b style="color:#7fb3f5; font-size:0.65rem;">GEMINI:</b> {reason_c}
700
+ </div>
701
+ </div>
702
+ </div>
703
+ """
704
+
705
+ def _council_agreement_score(label_a: str, label_b: str) -> float:
706
+ """Compute semantic cosine similarity between two label strings."""
707
+ global _label_sim_model
708
+ if _label_sim_model is None:
709
+ _label_sim_model = SentenceTransformer("all-MiniLM-L6-v2")
710
+
711
+ embs = _label_sim_model.encode([label_a, label_b])
712
+ return round(float(cosine_similarity([embs[0]], [embs[1]])[0][0]), 3)
713
+
714
+
715
+ # # ─────────────────────────────────────────────────────────────────────────────
716
+ # # Tool 8 β€” run_dbscan_clustering
717
+ # # ─────────────────────────────────────────────────────────────────────────────
718
+ # @tool
719
+ # def run_dbscan_clustering(run_key: str = "abstract", eps: float = 0.3, min_samples: int = 3) -> str:
720
+ # """
721
+ # Run HDBSCAN + UMAP clustering on the embeddings.
722
+ # Loops to find hyperparameters that yield between 15 and 30 clusters.
723
+ # Also ensures min_cluster_size is around 5.
724
+ # (eps and min_samples args are kept for interface compatibility but may be overridden by the loop).
725
+ # """
726
+ # embeddings = np.load(f"emb_{run_key}.npy")
727
+
728
+ # with open(f"summaries_{run_key}.json", encoding="utf-8") as f:
729
+ # agg_summaries = json.load(f)
730
+
731
+ # all_nearest = [s for summ in agg_summaries for s in summ.get("nearest_sentences", [])]
732
+ # if not all_nearest:
733
+ # import pandas as pd
734
+ # df = pd.read_csv("loaded_data.csv")
735
+ # all_nearest = list(df["Combined"].dropna().astype(str))
736
+
737
+ # best_labels = None
738
+ # best_n_clusters = 0
739
+ # best_params = {}
740
+
741
+ # found = False
742
+ # for n_neighbors in [5, 10, 15]:
743
+ # for min_cluster_size in [5, 10, 15]:
744
+ # for cluster_selection_epsilon in [0.4, 0.5, 0.6]:
745
+ # reducer = umap.UMAP(n_neighbors=n_neighbors, n_components=5, metric='cosine', random_state=42)
746
+ # umap_emb = reducer.fit_transform(embeddings)
747
+
748
+ # db = hdbscan.HDBSCAN(min_cluster_size=min_cluster_size, cluster_selection_epsilon=cluster_selection_epsilon, metric='euclidean')
749
+ # labels = db.fit_predict(umap_emb)
750
+
751
+ # n_clusters = len(set(labels) - {-1})
752
+ # if 15 <= n_clusters <= 30:
753
+ # best_labels = labels
754
+ # best_n_clusters = n_clusters
755
+ # best_params = {'n_neighbors': n_neighbors, 'min_cluster_size': min_cluster_size, 'eps': cluster_selection_epsilon}
756
+ # found = True
757
+ # break
758
+ # if found: break
759
+ # if found: break
760
+
761
+ # if not found:
762
+ # reducer = umap.UMAP(n_neighbors=10, n_components=5, metric='cosine', random_state=42)
763
+ # umap_emb = reducer.fit_transform(embeddings)
764
+ # db = hdbscan.HDBSCAN(min_cluster_size=5, cluster_selection_epsilon=0.5, metric='euclidean')
765
+ # best_labels = db.fit_predict(umap_emb)
766
+ # best_params = {'fallback': True}
767
+
768
+ # db_labels = best_labels
769
+ # valid_ids = sorted(set(db_labels.tolist()) - {-1})
770
+ # noise_count = int((db_labels == -1).sum())
771
+
772
+ # centroids = _compute_centroids(embeddings, db_labels)
773
+
774
+ # def _dbscan_summary(cid):
775
+ # mask = db_labels == cid
776
+ # count = int(mask.sum())
777
+ # sents = _nearest_sents(centroids[cid],
778
+ # all_nearest or [f"Cluster {cid}"],
779
+ # embeddings[: len(all_nearest or ["x"])],
780
+ # min(3, len(all_nearest or ["x"])))
781
+ # return {
782
+ # "cluster_id": int(cid),
783
+ # "count": count,
784
+ # "centroid": centroids[cid].tolist(),
785
+ # "nearest_sentences": sents,
786
+ # "source": "hdbscan",
787
+ # }
788
+
789
+ # summaries = list(map(_dbscan_summary, valid_ids))
790
+
791
+ # out_file = f"dbscan_summaries_{run_key}.json"
792
+ # with open(out_file, "w", encoding="utf-8") as f:
793
+ # json.dump(summaries, f, indent=2)
794
+
795
+ # n_comp = min(2, len(embeddings), embeddings.shape[1])
796
+ # pca2 = PCA(n_components=n_comp).fit_transform(embeddings)
797
+ # x_vals = pca2[:, 0].tolist()
798
+ # y_vals = pca2[:, 1].tolist() if n_comp > 1 else [0.0] * len(x_vals)
799
+ # colors = db_labels.tolist()
800
+
801
+ # fig_scatter = px.scatter(
802
+ # x=x_vals, y=y_vals,
803
+ # color=list(map(str, colors)),
804
+ # title=f"HDBSCAN Cluster Map ({run_key}) β€” {best_params}",
805
+ # labels={"x": "PC1", "y": "PC2", "color": "Cluster"},
806
+ # opacity=0.7,
807
+ # )
808
+ # fig_scatter.update_layout(template="plotly_dark")
809
+ # chart_scatter = f"chart_{run_key}_dbscan_scatter.html"
810
+ # fig_scatter.write_html(chart_scatter, include_plotlyjs="cdn")
811
+
812
+ # agg_count = len(agg_summaries)
813
+ # dbscan_count = len(summaries)
814
+ # fig_cmp = px.bar(
815
+ # x=["Agglomerative", "HDBSCAN"],
816
+ # y=[agg_count, dbscan_count],
817
+ # color=["Agglomerative", "HDBSCAN"],
818
+ # color_discrete_sequence=["#4a90d9", "#e67e22"],
819
+ # title=f"Cluster Count Comparison ({run_key})",
820
+ # labels={"x": "Method", "y": "# Clusters"},
821
+ # text=[agg_count, dbscan_count],
822
+ # )
823
+ # fig_cmp.update_traces(textposition="outside")
824
+ # fig_cmp.update_layout(template="plotly_dark", showlegend=False)
825
+ # chart_cmp = f"chart_{run_key}_dbscan_comparison.html"
826
+ # fig_cmp.write_html(chart_cmp, include_plotlyjs="cdn")
827
+
828
+ # largest = max(map(lambda s: s["count"], summaries), default=0)
829
+
830
+ # return json.dumps({
831
+ # "run_key": run_key,
832
+ # "n_clusters": len(summaries),
833
+ # "noise_points": noise_count,
834
+ # "largest_cluster": largest,
835
+ # "best_params": best_params,
836
+ # "summaries_file": out_file,
837
+ # "charts": [chart_scatter, chart_cmp],
838
+ # "preview": summaries[:3],
839
+ # }, indent=2)
840
+
841
+
842
+ # # ─────────────────────────────────────────────────────────────────────────────
843
+ # # Tool 9 β€” refine_large_clusters
844
+ # # ─────────────────────────────────────────────────────────────────────────────
845
+ # @tool
846
+ # def refine_large_clusters(run_key: str = "abstract", size_threshold: int = 100) -> str:
847
+ # """
848
+ # Post-processing: identifies overly large DBSCAN clusters and refines them
849
+ # into sub-clusters using a tighter AgglomerativeClustering threshold (0.45).
850
+
851
+ # Does NOT modify dbscan_summaries or any existing agglomerative results.
852
+ # Saves results to refined_clusters_{run_key}.json.
853
+
854
+ # Args:
855
+ # run_key: 'abstract' or 'title'
856
+ # size_threshold: Clusters with count > this value will be refined (default 200)
857
+
858
+ # Returns:
859
+ # JSON: n_refined, total_subclusters, refined_clusters_file, chart file.
860
+ # """
861
+ # dbscan_file = f"dbscan_summaries_{run_key}.json"
862
+ # with open(dbscan_file, encoding="utf-8") as f:
863
+ # summaries = json.load(f)
864
+
865
+ # embeddings = np.load(f"emb_{run_key}.npy")
866
+
867
+ # large = list(filter(lambda s: s["count"] >= size_threshold, summaries))
868
+ # unchanged = list(filter(lambda s: s["count"] < size_threshold, summaries))
869
+
870
+ # # Re-cluster each large cluster's embedding slice
871
+ # def _refine_one(parent_summary):
872
+ # pid = parent_summary["cluster_id"]
873
+ # parent_c = np.array(parent_summary["centroid"])
874
+ # # Find the indices in the full embedding that are nearest to this centroid
875
+ # sims = cosine_similarity([parent_c], embeddings)[0]
876
+ # count = parent_summary["count"]
877
+ # idxs = np.argsort(sims)[::-1][:count].tolist()
878
+
879
+ # sub_emb = embeddings[idxs]
880
+ # sub_labels = AgglomerativeClustering(
881
+ # metric="cosine", linkage="average",
882
+ # distance_threshold=0.45, n_clusters=None,
883
+ # ).fit_predict(sub_emb)
884
+
885
+ # sub_ids = sorted(set(sub_labels.tolist()))
886
+ # sub_centroids = dict(map(
887
+ # lambda sid: (sid, sub_emb[sub_labels == sid].mean(axis=0)),
888
+ # sub_ids,
889
+ # ))
890
+
891
+ # def _sub(sid):
892
+ # mask = sub_labels == sid
893
+ # sents = parent_summary.get("nearest_sentences", [])
894
+ # return {
895
+ # "cluster_id": f"{pid}.{sid}",
896
+ # "parent_cluster_id": pid,
897
+ # "count": int(mask.sum()),
898
+ # "centroid": sub_centroids[sid].tolist(),
899
+ # "nearest_sentences": sents[:3],
900
+ # "source": "dbscan_refined",
901
+ # }
902
+
903
+ # return list(map(_sub, sub_ids))
904
+
905
+ # refined_subs = [item for sublist in map(_refine_one, large) for item in sublist]
906
+
907
+ # # Unchanged clusters kept as-is with a source tag
908
+ # unchanged_kept = list(map(
909
+ # lambda s: {**s, "source": "dbscan_unchanged"},
910
+ # unchanged,
911
+ # ))
912
+
913
+ # all_refined = unchanged_kept + refined_subs
914
+
915
+ # out_file = f"refined_clusters_{run_key}.json"
916
+ # with open(out_file, "w", encoding="utf-8") as f:
917
+ # json.dump(all_refined, f, indent=2)
918
+
919
+ # # ── Chart: Treemap of refined sub-clusters ────────────────────────────────
920
+ # labels_list = list(map(lambda c: str(c["cluster_id"]), all_refined))
921
+ # parents_list = list(map(
922
+ # lambda c: str(c.get("parent_cluster_id", "root")) if "." in str(c["cluster_id"]) else "root",
923
+ # all_refined,
924
+ # ))
925
+ # values_list = list(map(lambda c: c["count"], all_refined))
926
+
927
+ # fig_tree = px.treemap(
928
+ # names=labels_list,
929
+ # parents=parents_list,
930
+ # values=values_list,
931
+ # title=f"Refined Sub-Clusters ({run_key}) β€” threshold={size_threshold}",
932
+ # )
933
+ # fig_tree.update_layout(template="plotly_dark")
934
+ # chart_tree = f"chart_{run_key}_refined.html"
935
+ # fig_tree.write_html(chart_tree, include_plotlyjs="cdn")
936
+
937
+ # return json.dumps({
938
+ # "run_key": run_key,
939
+ # "size_threshold": size_threshold,
940
+ # "n_large_refined": len(large),
941
+ # "total_subclusters": len(refined_subs),
942
+ # "unchanged_clusters": len(unchanged),
943
+ # "total_output_clusters": len(all_refined),
944
+ # "output_file": out_file,
945
+ # "chart": chart_tree,
946
+ # "preview": all_refined[:4],
947
+ # }, indent=2)
948
+
949
+
950
+ # # ─────────────────────────────────────────────────────────────────────────────
951
+ # # Tool 10 β€” run_ai_council
952
+ # # ─────────────────────────────────────────────────────────────────────────────
953
+ # @tool
954
+ # def run_ai_council(run_key: str = "abstract") -> str:
955
+ # """
956
+ # AI Council: two LLM instances independently label each DBSCAN cluster
957
+ # from its top-3 representative sentences, then a consensus step merges them.
958
+
959
+ # Model A: Mistral Large (temperature=0.2) β€” analytical, precise
960
+ # Model B: Groq Llama-3.3-70b-versatile (temperature=0.2) β€” genuinely different
961
+ # model providing independent perspective (Karpathy-style second opinion)
962
+
963
+ # Consensus rule:
964
+ # - Jaccard word overlap >= 0.4 β†’ agreement; consensus = Model A label
965
+ # - Jaccard word overlap < 0.4 β†’ divergence; Model A (Mistral) selected as primary
966
+
967
+ # Saves council_labels_{run_key}.json (compatible with PAJAIS mapping).
968
+
969
+ # Args:
970
+ # run_key: 'abstract' or 'title'
971
+
972
+ # Returns:
973
+ # JSON: total_labelled, agreement_rate, output_file, preview.
974
+ # """
975
+ # dbscan_file = f"dbscan_summaries_{run_key}.json"
976
+ # with open(dbscan_file, encoding="utf-8") as f:
977
+ # summaries = json.load(f)
978
+
979
+ # top = summaries[:MAX_LABEL_TOPICS]
980
+
981
+ # # Truncate to 500 chars to fit within Groq's 12000 TPM limit
982
+ # topics_for_prompt = list(map(
983
+ # lambda s: {
984
+ # "cluster_id": s["cluster_id"],
985
+ # "count": s["count"],
986
+ # "sentences": [sent[:500] + "..." for sent in s.get("nearest_sentences", [])[:3]],
987
+ # },
988
+ # top,
989
+ # ))
990
+
991
+ # # ── Model A (analytical Mistral) ──────────────────────────────────────────
992
+ # llm_a = _get_llm() # temperature=0.2
993
+ # llm_b = _get_council_llm_b() # temperature=0.2
994
+ # llm_c = _get_council_llm_c() # temperature=0.2
995
+
996
+ # council_prompt_tmpl = (
997
+ # "You are an expert thematic analyst reviewing HDBSCAN-discovered clusters "
998
+ # "from an academic corpus.\n\n"
999
+ # "Below are cluster IDs with their top-3 representative sentences:\n\n"
1000
+ # "{topics_json}\n\n"
1001
+ # "For EACH cluster, propose a concise label (3-6 words).\n"
1002
+ # "Return ONLY a valid JSON array. Each element must have:\n"
1003
+ # " cluster_id: same integer as input\n"
1004
+ # " label: concise 3-6 word research area name\n"
1005
+ # " reasoning: one sentence explaining your choice\n\n"
1006
+ # "Return ALL {n} clusters. Do not skip any."
1007
+ # )
1008
+
1009
+ # prompt_a = PromptTemplate(input_variables=["topics_json", "n"], template=council_prompt_tmpl)
1010
+ # prompt_b = PromptTemplate(input_variables=["topics_json", "n"], template=council_prompt_tmpl)
1011
+ # prompt_c = PromptTemplate(input_variables=["topics_json", "n"], template=council_prompt_tmpl)
1012
+
1013
+ # parser = JsonOutputParser()
1014
+ # chain_a = prompt_a | llm_a | parser
1015
+ # chain_b = prompt_b | llm_b | parser
1016
+ # chain_c = prompt_c | llm_c | parser
1017
+
1018
+ # input_data = {
1019
+ # "topics_json": json.dumps(topics_for_prompt, indent=2),
1020
+ # "n": len(top),
1021
+ # }
1022
+
1023
+ # results_a = chain_a.invoke(input_data) or []
1024
+ # results_b = chain_b.invoke(input_data) or []
1025
+ # results_c = chain_c.invoke(input_data) or []
1026
+
1027
+ # idx_a = {str(r["cluster_id"]): r for r in results_a}
1028
+ # idx_b = {str(r["cluster_id"]): r for r in results_b}
1029
+ # idx_c = {str(r["cluster_id"]): r for r in results_c}
1030
+
1031
+ # # ── Consensus step ────────────────────────────────────────────────────────
1032
+ # def _consensus(cluster_summary):
1033
+ # cid = str(cluster_summary["cluster_id"])
1034
+ # ra = idx_a.get(cid, {})
1035
+ # rb = idx_b.get(cid, {})
1036
+ # rc = idx_c.get(cid, {})
1037
+ # label_a = ra.get("label", f"Cluster {cid}")
1038
+ # label_b = rb.get("label", f"Cluster {cid}")
1039
+ # label_c = rc.get("label", f"Cluster {cid}")
1040
+
1041
+ # s_ab = _council_agreement_score(label_a, label_b)
1042
+ # s_bc = _council_agreement_score(label_b, label_c)
1043
+ # s_ca = _council_agreement_score(label_c, label_a)
1044
+
1045
+ # max_score = max(s_ab, s_bc, s_ca)
1046
+ # agreed = max_score >= 0.65
1047
+
1048
+ # # Pick the label that has the highest average agreement with others
1049
+ # avg_a = (s_ab + s_ca) / 2
1050
+ # avg_b = (s_ab + s_bc) / 2
1051
+ # avg_c = (s_bc + s_ca) / 2
1052
+
1053
+ # if avg_a >= avg_b and avg_a >= avg_c:
1054
+ # best_label = label_a
1055
+ # elif avg_b >= avg_a and avg_b >= avg_c:
1056
+ # best_label = label_b
1057
+ # else:
1058
+ # best_label = label_c
1059
+
1060
+ # consensus = best_label if agreed else label_a
1061
+
1062
+ # ui = format_consensus_ui(label_a, label_b, label_c, agreed, max_score, ra.get("reasoning",""), rb.get("reasoning",""), rc.get("reasoning",""))
1063
+
1064
+ # return {
1065
+ # "cluster_id": cluster_summary["cluster_id"],
1066
+ # "count": cluster_summary["count"],
1067
+ # "nearest_sentences": cluster_summary.get("nearest_sentences", [])[:3],
1068
+ # "label_a": label_a,
1069
+ # "label_b": label_b,
1070
+ # "label_c": label_c,
1071
+ # "consensus_label": consensus,
1072
+ # "agreement_score": max_score,
1073
+ # "council_ui": ui,
1074
+ # "source": "dbscan_ai_council",
1075
+ # "label": consensus,
1076
+ # "reasoning": ra.get("reasoning", ""),
1077
+ # }
1078
+
1079
+ # council_labels = list(map(_consensus, top))
1080
+
1081
+ # out_file = f"council_labels_{run_key}.json"
1082
+ # with open(out_file, "w", encoding="utf-8") as f:
1083
+ # json.dump(council_labels, f, indent=2)
1084
+
1085
+ # agreed_count = len(list(filter(lambda c: c["agreement_score"] >= 0.65, council_labels)))
1086
+ # agreement_rate = round(agreed_count / max(len(council_labels), 1) * 100, 1)
1087
+
1088
+ # return json.dumps({
1089
+ # "run_key": run_key,
1090
+ # "total_labelled": len(council_labels),
1091
+ # "agreed_count": agreed_count,
1092
+ # "agreement_rate": f"{agreement_rate}%",
1093
+ # "output_file": out_file,
1094
+ # "note": (
1095
+ # "council_labels contain 'label' field for PAJAIS compatibility. "
1096
+ # "Model A = Mistral Large. Model B = Groq Llama-3. Model C = Gemini Flash."
1097
+ # ),
1098
+ # "preview": council_labels[:4],
1099
+ # }, indent=2)
1100
+
1101
+
1102
+ # # Verified: zero if/else*, zero for/while, zero try/except
1103
+ # # (*_get_council_llm_b uses a conditional expression, not an if/else block)