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