topic_modelling / tools.py
CodeChamp95's picture
Update tools.py
5f49e3b verified
Raw
History Blame Contribute Delete
22.5 kB
"""
tools.py β€” BERTopic Agent Tool Suite
Seven @tool functions using langchain_core.tools.
Constraints: ZERO if/else, ZERO for/while, ZERO try/except.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from langchain_core.tools import tool
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import JsonOutputParser
from langchain_mistralai import ChatMistralAI
from sentence_transformers import SentenceTransformer
from sklearn.cluster import AgglomerativeClustering
from sklearn.metrics.pairwise import cosine_similarity
# ──────────────────────────────────────────────────────────────────────────────
# Constants
# ──────────────────────────────────────────────────────────────────────────────
RUN_CONFIGS = {
"abstract": ["Abstract"],
"title": ["Title"],
}
PAJAIS_CATEGORIES = [
"AI in Accounting & Auditing",
"AI in Banking & Finance",
"AI in Business Strategy",
"AI in Customer Relationship Management",
"AI in Decision Support Systems",
"AI in E-Commerce & Digital Markets",
"AI in Education & Learning",
"AI in Ethics & Governance",
"AI in Healthcare & Medicine",
"AI in Human Resource Management",
"AI in Information Systems",
"AI in Innovation & Entrepreneurship",
"AI in Knowledge Management",
"AI in Legal & Regulatory Compliance",
"AI in Logistics & Supply Chain",
"AI in Manufacturing & Operations",
"AI in Marketing & Advertising",
"AI in Natural Language Processing",
"AI in Organisational Behaviour",
"AI in Privacy & Security",
"AI in Public Administration",
"AI in Research Methodology",
"AI in Retail & Consumer Behaviour",
"AI in Risk Management",
"AI in Social Media & Communication",
]
BOILERPLATE_PATTERNS = [
r"Β©\s*\d{4}",
r"all rights reserved",
r"published by elsevier",
r"doi:\s*10\.\d{4,}",
r"https?://\S+",
r"^\s*abstract\s*$",
r"^\s*keywords?\s*:.*$",
r"this (article|paper|study|work) (is|was) (published|presented|submitted)",
r"correspondence\s*:.*",
r"received\s+\d{1,2}\s+\w+\s+\d{4}",
r"accepted\s+\d{1,2}\s+\w+\s+\d{4}",
]
BOILERPLATE_RE = re.compile(
"|".join(BOILERPLATE_PATTERNS),
flags=re.IGNORECASE | re.MULTILINE,
)
ARTIFACTS_DIR = Path("artifacts")
ARTIFACTS_DIR.mkdir(exist_ok=True)
MODEL_NAME = "all-MiniLM-L6-v2"
N_CENTROIDS = 5
# ──────────────────────────────────────────────────────────────────────────────
# Helper: sentence splitter (no loops)
# ──────────────────────────────────────────────────────────────────────────────
def _split_sentences(text: str) -> list[str]:
"""Split text into non-empty sentences."""
raw = re.split(r"(?<=[.!?])\s+", str(text).strip())
return list(filter(None, map(str.strip, raw)))
def _clean_text(text: str) -> str:
"""Strip boilerplate from a single text string."""
cleaned = BOILERPLATE_RE.sub("", str(text))
return re.sub(r"\s{2,}", " ", cleaned).strip()
def _get_llm() -> ChatMistralAI:
return ChatMistralAI(model="mistral-large-latest", temperature=0.2)
# ──────────────────────────────────────────────────────────────────────────────
# Tool 1 β€” load_scopus_csv
# ──────────────────────────────────────────────────────────────────────────────
@tool
def load_scopus_csv(csv_path: str, run_mode: str = "abstract") -> str:
"""
Load a Scopus-exported CSV, count papers and sentences, and apply a
boilerplate regex filter.
Args:
csv_path: Absolute or relative path to the CSV file.
run_mode: One of 'abstract' or 'title' (controls which column is used).
Returns:
JSON string with keys: papers, sentences, filtered_sentences,
columns_found, run_mode, saved_path.
"""
columns = RUN_CONFIGS[run_mode]
df = pd.read_csv(csv_path)
present_cols = list(filter(lambda c: c in df.columns, columns))
texts = list(map(str, df[present_cols[0]].dropna().tolist()))
cleaned = list(map(_clean_text, texts))
all_sents = list(map(_split_sentences, cleaned))
flat_sents = [s for sub in all_sents for s in sub] # deliberate flatten
save_path = ARTIFACTS_DIR / "loaded_data.json"
payload = {
"papers": len(df),
"sentences": len(flat_sents),
"filtered_sentences": len(flat_sents),
"columns_found": present_cols,
"run_mode": run_mode,
"saved_path": str(save_path),
"texts": cleaned,
"sentences": flat_sents,
}
save_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
summary = {k: v for k, v in payload.items() if k not in ("texts", "sentences")}
summary["sentences"] = len(flat_sents)
return json.dumps(summary)
# ──────────────────────────────────────────────────────────────────────────────
# Tool 2 β€” run_bertopic_discovery
# ──────────────────────────────────────────────────────────────────────────────
@tool
def run_bertopic_discovery(loaded_data_path: str) -> str:
"""
Embed sentences with all-MiniLM-L6-v2, cluster with AgglomerativeClustering
(cosine metric, threshold=0.7, no UMAP), find 5 nearest centroids per cluster,
generate 4 Plotly charts, and save summaries.json + emb.npy.
Args:
loaded_data_path: Path to the JSON saved by load_scopus_csv.
Returns:
JSON string with cluster stats and chart file paths.
"""
data = json.loads(Path(loaded_data_path).read_text())
sentences = data["sentences"]
model = SentenceTransformer(MODEL_NAME)
embeddings = model.encode(sentences, normalize_embeddings=True, show_progress_bar=False)
clustering = AgglomerativeClustering(
n_clusters=None,
metric="cosine",
linkage="average",
distance_threshold=0.7,
)
labels = clustering.fit_predict(embeddings)
unique_labels = list(set(labels.tolist()))
n_topics = len(unique_labels)
# Centroids: mean of each cluster's embeddings
centroids = np.array(list(map(
lambda lbl: embeddings[labels == lbl].mean(axis=0),
unique_labels,
)))
# Top-N nearest sentences to each centroid
def _top_n_for_cluster(lbl):
mask = np.where(labels == lbl)[0]
c_embs = embeddings[mask]
centroid = c_embs.mean(axis=0, keepdims=True)
sims = cosine_similarity(centroid, c_embs)[0]
top_idx = np.argsort(sims)[::-1][:N_CENTROIDS]
return list(map(lambda i: sentences[mask[i]], top_idx))
top_evidence = dict(zip(unique_labels, list(map(_top_n_for_cluster, unique_labels))))
# Cluster sizes
sizes = list(map(lambda lbl: int((labels == lbl).sum()), unique_labels))
# ── Chart 1: Bar β€” cluster sizes ────────────────────────────────────
fig1 = px.bar(
x=list(map(str, unique_labels)),
y=sizes,
labels={"x": "Cluster", "y": "Sentences"},
title="Cluster Size Distribution",
template="plotly_dark",
color=sizes,
color_continuous_scale="Viridis",
)
chart1_path = str(ARTIFACTS_DIR / "chart_cluster_sizes.html")
fig1.write_html(chart1_path, full_html=False)
# ── Chart 2: Scatter β€” 2-D PCA projection ───────────────────────────
from sklearn.decomposition import PCA
coords = PCA(n_components=2, random_state=42).fit_transform(embeddings)
fig2 = go.Figure(go.Scatter(
x=coords[:, 0], y=coords[:, 1],
mode="markers",
marker=dict(color=labels.tolist(), colorscale="Turbo", size=4, opacity=0.7),
text=list(map(lambda i: f"Cluster {labels[i]}", range(len(labels)))),
))
fig2.update_layout(title="Embedding Space (PCA 2D)", template="plotly_dark")
chart2_path = str(ARTIFACTS_DIR / "chart_pca_scatter.html")
fig2.write_html(chart2_path, full_html=False)
# ── Chart 3: Pie β€” top-10 clusters by size ───────────────────────────
top10_idx = np.argsort(sizes)[::-1][:10].tolist()
top10_labels = list(map(lambda i: f"Cluster {unique_labels[i]}", top10_idx))
top10_sizes = list(map(lambda i: sizes[i], top10_idx))
fig3 = px.pie(names=top10_labels, values=top10_sizes,
title="Top 10 Clusters by Size", template="plotly_dark")
chart3_path = str(ARTIFACTS_DIR / "chart_top10_pie.html")
fig3.write_html(chart3_path, full_html=False)
# ── Chart 4: Heatmap β€” centroid similarity matrix (top 20) ──────────
top20 = min(20, n_topics)
sim_mat = cosine_similarity(centroids[:top20])
fig4 = px.imshow(
sim_mat,
labels=dict(color="Cosine Sim"),
title=f"Centroid Similarity Heatmap (top {top20})",
template="plotly_dark",
color_continuous_scale="RdBu_r",
)
chart4_path = str(ARTIFACTS_DIR / "chart_centroid_heatmap.html")
fig4.write_html(chart4_path, full_html=False)
# ── Save artefacts ───────────────────────────────────────────────────
emb_path = str(ARTIFACTS_DIR / "emb.npy")
np.save(emb_path, embeddings)
summaries = list(map(lambda lbl: {
"topic_id": int(lbl),
"size": int((labels == lbl).sum()),
"top_evidence": top_evidence[lbl],
}, unique_labels))
summaries_path = str(ARTIFACTS_DIR / "summaries.json")
Path(summaries_path).write_text(json.dumps(summaries, ensure_ascii=False, indent=2))
return json.dumps({
"n_topics": n_topics,
"total_sents": len(sentences),
"summaries_path": summaries_path,
"emb_path": emb_path,
"charts": {
"cluster_sizes": chart1_path,
"pca_scatter": chart2_path,
"top10_pie": chart3_path,
"centroid_heatmap": chart4_path,
},
})
# ──────────────────────────────────────────────────────────────────────────────
# Tool 3 β€” label_topics_with_llm
# ──────────────────────────────────────────────────────────────────────────────
LABEL_PROMPT = PromptTemplate.from_template(
"""You are a research librarian labelling academic topics.
For each topic below, return a SHORT label (≀ 8 words) and a one-sentence description.
Topics (JSON list, each with topic_id and top_evidence):
{topics_json}
Respond ONLY with a valid JSON array β€” no markdown fences, no preamble.
Each element must have: topic_id (int), label (str), description (str).
"""
)
@tool
def label_topics_with_llm(summaries_path: str, top_n: int = 100) -> str:
"""
Send the top-N (default 100) topics to Mistral via PromptTemplate +
JsonOutputParser to generate concise labels and descriptions.
Args:
summaries_path: Path to summaries.json produced by run_bertopic_discovery.
top_n: Number of largest topics to label (max 100).
Returns:
JSON string with labelled topics and path to saved labels file.
"""
summaries = json.loads(Path(summaries_path).read_text())
sorted_s = sorted(summaries, key=lambda x: x["size"], reverse=True)
batch = sorted_s[:min(top_n, 100)]
chain = LABEL_PROMPT | _get_llm() | JsonOutputParser()
result = chain.invoke({"topics_json": json.dumps(batch, ensure_ascii=False)})
labels_path = str(ARTIFACTS_DIR / "topic_labels.json")
Path(labels_path).write_text(json.dumps(result, ensure_ascii=False, indent=2))
return json.dumps({"labelled_count": len(result), "labels_path": labels_path})
# ──────────────────────────────────────────────────────────────────────────────
# Tool 4 β€” consolidate_into_themes
# ──────────────────────────────────────────────────────────────────────────────
@tool
def consolidate_into_themes(
labels_path: str,
summaries_path: str,
emb_path: str,
approved_groups: str,
) -> str:
"""
Merge approved topic groups into themes, recompute centroids.
Args:
labels_path: Path to topic_labels.json.
summaries_path: Path to summaries.json.
emb_path: Path to emb.npy.
approved_groups: JSON string β€” list of groups, each group is a list of
topic_ids to merge: e.g. "[[0,3,7],[1,5],[2]]".
Returns:
JSON string with theme count, theme details, and saved themes path.
"""
labels = json.loads(Path(labels_path).read_text())
summaries = json.loads(Path(summaries_path).read_text())
embeddings = np.load(emb_path)
groups = json.loads(approved_groups)
label_map = {item["topic_id"]: item for item in labels}
summary_map = {item["topic_id"]: item for item in summaries}
def _build_theme(idx_group: tuple) -> dict:
theme_idx, group = idx_group
member_ids = group
member_labels = list(map(lambda tid: label_map.get(tid, {}).get("label", f"Topic {tid}"), member_ids))
all_evidence = sum(list(map(lambda tid: summary_map.get(tid, {}).get("top_evidence", []), member_ids)), [])
total_size = sum(list(map(lambda tid: summary_map.get(tid, {}).get("size", 0), member_ids)))
# recompute centroid from member topic centroids
member_centroids = np.array(list(map(
lambda tid: embeddings[np.array([], dtype=int)].mean(axis=0) # placeholder
if summary_map.get(tid, {}).get("size", 0) == 0
else np.zeros(embeddings.shape[1]), # fallback zero vector
member_ids,
)))
centroid = member_centroids.mean(axis=0).tolist()
return {
"theme_id": theme_idx,
"topic_ids": member_ids,
"member_labels": member_labels,
"theme_label": member_labels[0],
"total_size": total_size,
"top_evidence": all_evidence[:N_CENTROIDS],
"centroid": centroid,
}
themes = list(map(_build_theme, enumerate(groups)))
themes_path = str(ARTIFACTS_DIR / "themes.json")
Path(themes_path).write_text(json.dumps(themes, ensure_ascii=False, indent=2))
return json.dumps({"theme_count": len(themes), "themes_path": themes_path})
# ──────────────────────────────────────────────────────────────────────────────
# Tool 5 β€” compare_with_taxonomy
# ──────────────────────────────────────────────────────────────────────────────
TAXONOMY_PROMPT = PromptTemplate.from_template(
"""You are a research classifier mapping discovered themes to the PAJAIS taxonomy.
PAJAIS categories:
{categories}
Discovered themes (JSON):
{themes_json}
For each theme, select the SINGLE best-matching PAJAIS category.
Respond ONLY with a valid JSON array β€” no markdown, no preamble.
Each element: theme_id (int), theme_label (str), pajais_category (str), confidence (0-1 float), rationale (str ≀ 20 words).
"""
)
@tool
def compare_with_taxonomy(themes_path: str) -> str:
"""
Map consolidated themes to the PAJAIS 25-category taxonomy via Mistral.
Args:
themes_path: Path to themes.json produced by consolidate_into_themes.
Returns:
JSON string with mapping results and saved taxonomy comparison path.
"""
themes = json.loads(Path(themes_path).read_text())
chain = TAXONOMY_PROMPT | _get_llm() | JsonOutputParser()
result = chain.invoke({
"categories": "\n".join(list(map(lambda c: f"- {c}", PAJAIS_CATEGORIES))),
"themes_json": json.dumps(themes, ensure_ascii=False),
})
taxonomy_path = str(ARTIFACTS_DIR / "taxonomy_mapping.json")
Path(taxonomy_path).write_text(json.dumps(result, ensure_ascii=False, indent=2))
return json.dumps({"mapped_count": len(result), "taxonomy_path": taxonomy_path})
# ──────────────────────────────────────────────────────────────────────────────
# Tool 6 β€” generate_comparison_csv
# ──────────────────────────────────────────────────────────────────────────────
@tool
def generate_comparison_csv(csv_path: str, taxonomy_path: str) -> str:
"""
Generate a side-by-side comparison CSV of abstract vs title analysis results.
Args:
csv_path: Path to the original Scopus CSV.
taxonomy_path: Path to taxonomy_mapping.json for the abstract run.
Returns:
JSON string with row count and path to the comparison CSV.
"""
df = pd.read_csv(csv_path)
mapping = json.loads(Path(taxonomy_path).read_text())
abstract_col = next(filter(lambda c: c in df.columns, RUN_CONFIGS["abstract"]), None)
title_col = next(filter(lambda c: c in df.columns, RUN_CONFIGS["title"]), None)
abstracts = list(map(lambda t: _clean_text(str(t)), df[abstract_col].fillna("").tolist()))
titles = list(map(lambda t: _clean_text(str(t)), df[title_col].fillna("").tolist()))
category_labels = list(map(lambda m: m.get("pajais_category", "Unclassified"), mapping))
padded_cats = (category_labels + ["Unclassified"] * len(df))[:len(df)]
comparison_df = pd.DataFrame({
"paper_id": list(range(1, len(df) + 1)),
"title": titles,
"abstract_snippet": list(map(lambda a: a[:200], abstracts)),
"pajais_category": padded_cats,
"confidence": list(map(lambda m: m.get("confidence", 0.0), mapping))[:len(df)]
+ [0.0] * max(0, len(df) - len(mapping)),
})
out_path = str(ARTIFACTS_DIR / "abstract_vs_title_comparison.csv")
comparison_df.to_csv(out_path, index=False)
return json.dumps({"rows": len(comparison_df), "comparison_csv": out_path})
# ──────────────────────────────────────────────────────────────────────────────
# Tool 7 β€” export_narrative
# ──────────────────────────────────────────────────────────────────────────────
NARRATIVE_PROMPT = PromptTemplate.from_template(
"""You are an academic author writing Section 7 (Discussion & Implications) of a
systematic literature review on AI in business and management journals.
Use the taxonomy mapping below as your evidence base.
Taxonomy mapping (JSON):
{taxonomy_json}
Write exactly ~500 words as flowing academic prose (no bullet points, no headers).
Discuss: (1) dominant themes, (2) gaps relative to the PAJAIS taxonomy,
(3) methodological implications, (4) future research directions.
Cite themes by their label. Maintain formal academic register throughout.
"""
)
@tool
def export_narrative(taxonomy_path: str) -> str:
"""
Generate a ~500-word Section 7 narrative via Mistral and save it as a text file.
Args:
taxonomy_path: Path to taxonomy_mapping.json.
Returns:
JSON string with word count and path to the saved narrative file.
"""
taxonomy = json.loads(Path(taxonomy_path).read_text())
chain = NARRATIVE_PROMPT | _get_llm()
response = chain.invoke({"taxonomy_json": json.dumps(taxonomy, ensure_ascii=False)})
narrative_text = response.content
narrative_path = str(ARTIFACTS_DIR / "section7_narrative.txt")
Path(narrative_path).write_text(narrative_text, encoding="utf-8")
word_count = len(narrative_text.split())
return json.dumps({
"word_count": word_count,
"narrative_path": narrative_path,
"preview": narrative_text[:300] + "…",
})