testing / tools.py
advaitidalvi23's picture
Upload 4 files
2a14306 verified
Raw
History Blame Contribute Delete
14.3 kB
"""
tools.py
--------
Tool definitions for the Journal Topic Modelling Agent.
Each tool is a plain Python function; the agent (agent.py) calls these directly.
All functions return a dict with a "status" key ("ok" or "error").
"""
import json
import re
import csv
import io
from typing import Any, List, Dict
# ── Phase 1: Ingestion ────────────────────────────────────────────────────────
def parse_csv(csv_text: str) -> dict:
"""
Parse raw CSV text and return structured records.
Returns: {"status", "records", "columns", "count"}
"""
try:
reader = csv.DictReader(io.StringIO(csv_text.strip()))
records = [dict(row) for row in reader]
columns = list(reader.fieldnames or [])
return {
"status": "ok",
"records": records,
"columns": columns,
"count": len(records),
}
except Exception as e:
return {"status": "error", "message": str(e), "records": [], "columns": [], "count": 0}
def extract_text_corpus(records: list, text_fields: list = None) -> dict:
"""
Extract and combine text from specified fields across all records.
Defaults to ['abstract', 'title'].
Returns: {"status", "corpus", "total_words", "doc_count"}
"""
if text_fields is None:
text_fields = ["abstract", "title"]
corpus = []
total_words = 0
for i, rec in enumerate(records):
parts = []
for field in text_fields:
for k, v in rec.items():
if k.strip().lower() == field.lower() and v and str(v).strip():
parts.append(str(v).strip())
text = " ".join(parts)
if text:
corpus.append({"id": i, "text": text, "record": rec})
total_words += len(text.split())
return {
"status": "ok",
"corpus": corpus,
"total_words": total_words,
"doc_count": len(corpus),
}
# ── Phase 2: Topic Extraction & Labelling ─────────────────────────────────────
def chunk_corpus_for_topic_extraction(corpus: list, chunk_size: int = 20) -> dict:
"""
Split corpus into chunks for batched LLM topic extraction.
Returns: {"status", "chunks", "chunk_count"}
"""
chunks = []
for i in range(0, len(corpus), chunk_size):
chunks.append(corpus[i : i + chunk_size])
return {"status": "ok", "chunks": chunks, "chunk_count": len(chunks)}
def merge_and_deduplicate_topics(topic_lists: list) -> dict:
"""
Merge multiple raw topic lists, normalise, and deduplicate.
Returns: {"status", "unique_topics", "raw_count", "unique_count"}
"""
all_topics = []
for lst in topic_lists:
if isinstance(lst, list):
all_topics.extend(lst)
elif isinstance(lst, str) and lst.strip():
all_topics.append(lst)
seen = set()
unique = []
for t in all_topics:
if not isinstance(t, str):
continue
norm = re.sub(r"\s+", " ", t.strip().lower())
if norm and norm not in seen:
seen.add(norm)
unique.append(t.strip())
return {
"status": "ok",
"unique_topics": unique,
"raw_count": len(all_topics),
"unique_count": len(unique),
}
def assign_topic_labels(topics: list, labels_map: dict) -> dict:
"""
Assign human-readable labels to a list of topic strings.
labels_map: {topic_string: label_string}
Returns: {"status", "labelled_topics", "count"}
"""
labelled = []
for t in topics:
label = labels_map.get(t) or labels_map.get(t.lower()) or t
labelled.append({"topic": t, "label": label})
return {"status": "ok", "labelled_topics": labelled, "count": len(labelled)}
def build_topic_frequency_table(corpus: list, labelled_topics: list) -> dict:
"""
Count how many documents mention each topic (simple keyword match).
Returns: {"status", "frequency_table", "doc_count"}
"""
doc_count = len(corpus)
freq_table = []
for item in labelled_topics:
topic_words = item["topic"].lower().split()
count = sum(
1 for doc in corpus if all(w in doc["text"].lower() for w in topic_words)
)
pct = round(100.0 * count / doc_count, 2) if doc_count else 0.0
freq_table.append(
{
"topic": item["topic"],
"label": item["label"],
"frequency": count,
"percentage": pct,
}
)
freq_table.sort(key=lambda x: x["frequency"], reverse=True)
return {"status": "ok", "frequency_table": freq_table, "doc_count": doc_count}
# ── Phase 3: Title vs Abstract Comparison ────────────────────────────────────
def split_corpus_by_field(records: list) -> dict:
"""
Separate title and abstract corpora from the records list.
Returns: {"status", "title_corpus", "abstract_corpus", "title_count", "abstract_count"}
"""
title_corpus = []
abstract_corpus = []
for i, rec in enumerate(records):
title_text = ""
abstract_text = ""
for k, v in rec.items():
kl = k.strip().lower()
if kl == "title" and v:
title_text = str(v).strip()
elif kl == "abstract" and v:
abstract_text = str(v).strip()
if title_text:
title_corpus.append({"id": i, "text": title_text})
if abstract_text:
abstract_corpus.append({"id": i, "text": abstract_text})
return {
"status": "ok",
"title_corpus": title_corpus,
"abstract_corpus": abstract_corpus,
"title_count": len(title_corpus),
"abstract_count": len(abstract_corpus),
}
def compare_topic_distributions(title_topics: list, abstract_topics: list) -> dict:
"""
Compare topic frequency distributions between title and abstract corpora.
Each input list: [{"topic", "frequency", "percentage"}, ...]
Returns: {"status", "comparison", "title_only", "abstract_only", "shared"}
"""
title_map = {t["topic"].lower(): t for t in title_topics}
abstract_map = {t["topic"].lower(): t for t in abstract_topics}
all_keys = set(title_map.keys()) | set(abstract_map.keys())
comparison = []
for key in all_keys:
t_item = title_map.get(key, {})
a_item = abstract_map.get(key, {})
topic_name = (t_item.get("topic") or a_item.get("topic") or key)
comparison.append(
{
"topic": topic_name,
"title_freq": t_item.get("frequency", 0),
"abstract_freq": a_item.get("frequency", 0),
"title_pct": t_item.get("percentage", 0.0),
"abstract_pct": a_item.get("percentage", 0.0),
"delta_pct": round(
a_item.get("percentage", 0.0) - t_item.get("percentage", 0.0), 2
),
}
)
comparison.sort(key=lambda x: abs(x["delta_pct"]), reverse=True)
title_only = [c["topic"] for c in comparison if c["title_freq"] > 0 and c["abstract_freq"] == 0]
abstract_only = [c["topic"] for c in comparison if c["abstract_freq"] > 0 and c["title_freq"] == 0]
shared = [c["topic"] for c in comparison if c["title_freq"] > 0 and c["abstract_freq"] > 0]
return {
"status": "ok",
"comparison": comparison,
"title_only": title_only,
"abstract_only": abstract_only,
"shared": shared,
}
def save_comparison_csv(comparison: list) -> dict:
"""
Serialise the comparison list to CSV format string.
Returns: {"status", "csv_text", "row_count"}
"""
if not comparison:
return {"status": "error", "message": "No comparison data provided", "csv_text": "", "row_count": 0}
output = io.StringIO()
fieldnames = ["topic", "title_freq", "abstract_freq", "title_pct", "abstract_pct", "delta_pct"]
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(comparison)
return {"status": "ok", "csv_text": output.getvalue(), "row_count": len(comparison)}
# ── Phase 4: PAJAIS Taxonomy Mapping ─────────────────────────────────────────
PAJAIS_THEMES = [
"Human-Computer Interaction",
"Decision Support Systems",
"Knowledge Management",
"Information Retrieval",
"Machine Learning & AI",
"Natural Language Processing",
"Big Data & Analytics",
"Privacy & Security",
"Social Media & Web 2.0",
"Healthcare Informatics",
"Education & e-Learning",
"Business Intelligence",
"Recommender Systems",
"Cloud Computing",
"Internet of Things",
"Ethical AI & Fairness",
"Digital Transformation",
"Ontologies & Semantic Web",
"Supply Chain & Operations",
"Sentiment Analysis",
]
def map_topics_to_pajais(labelled_topics: list, mapping: dict) -> dict:
"""
Map discovered topics to PAJAIS taxonomy themes.
mapping: {topic_string: pajais_theme_or_"NOVEL"}
Returns: {"status", "taxonomy_map", "mapped", "novel",
"coverage_pct", "pajais_themes_used", "novel_count", "mapped_count"}
"""
taxonomy_map = {theme: [] for theme in PAJAIS_THEMES}
taxonomy_map["NOVEL"] = []
mapped = []
novel = []
for item in labelled_topics:
topic = item["topic"]
theme = mapping.get(topic) or mapping.get(topic.lower()) or "NOVEL"
entry = {"topic": topic, "label": item.get("label", topic), "pajais_theme": theme}
if theme == "NOVEL":
novel.append(entry)
taxonomy_map["NOVEL"].append(topic)
else:
mapped.append(entry)
if theme in taxonomy_map:
taxonomy_map[theme].append(topic)
else:
taxonomy_map[theme] = [topic]
total = len(labelled_topics)
coverage = round(100.0 * len(mapped) / total, 2) if total else 0.0
return {
"status": "ok",
"taxonomy_map": taxonomy_map,
"mapped": mapped,
"novel": novel,
"coverage_pct": coverage,
"pajais_themes_used": list({m["pajais_theme"] for m in mapped}),
"novel_count": len(novel),
"mapped_count": len(mapped),
}
def save_taxonomy_json(taxonomy_map: dict) -> dict:
"""
Serialise taxonomy map to a JSON string.
Returns: {"status", "json_text"}
"""
try:
return {"status": "ok", "json_text": json.dumps(taxonomy_map, indent=2)}
except Exception as e:
return {"status": "error", "message": str(e), "json_text": "{}"}
# ── Phase 5: Narrative Support ────────────────────────────────────────────────
def build_narrative_context(
frequency_table: list,
comparison: list,
taxonomy_result: dict,
record_count: int,
) -> dict:
"""
Assemble a structured context dict to feed into the narrative generation prompt.
Returns: {"status", "context"}
"""
top_topics = [
{"topic": t["topic"], "label": t["label"], "freq": t["frequency"], "pct": t["percentage"]}
for t in frequency_table[:10]
]
biggest_delta = sorted(comparison, key=lambda x: abs(x.get("delta_pct", 0)), reverse=True)[:5]
novel_sample = [n["topic"] for n in taxonomy_result.get("novel", [])[:10]]
context = {
"record_count": record_count,
"top_10_topics": top_topics,
"biggest_title_abstract_deltas": biggest_delta,
"novel_themes_count": len(taxonomy_result.get("novel", [])),
"novel_sample": novel_sample,
"pajais_themes_covered": taxonomy_result.get("pajais_themes_used", []),
"pajais_coverage_pct": taxonomy_result.get("coverage_pct", 0),
}
return {"status": "ok", "context": context}
def validate_narrative(text: str, min_words: int = 450) -> dict:
"""
Check that the narrative meets the minimum word count.
Returns: {"status", "valid", "word_count", "message"}
"""
words = len(text.split())
valid = words >= min_words
return {
"status": "ok",
"valid": valid,
"word_count": words,
"message": "OK" if valid else f"Too short: {words} words (need {min_words}+)",
}
# ── Utility ───────────────────────────────────────────────────────────────────
def summarise_run(
record_count: int,
topic_count: int,
mapped_count: int,
novel_count: int,
coverage_pct: float,
) -> dict:
"""Return a summary dict for display."""
return {
"status": "ok",
"summary": {
"papers_analysed": record_count,
"topics_discovered": topic_count,
"pajais_mapped": mapped_count,
"novel_themes": novel_count,
"pajais_coverage_pct": coverage_pct,
},
}
# ── Tool registry ─────────────────────────────────────────────────────────────
TOOL_REGISTRY = {
"parse_csv": parse_csv,
"extract_text_corpus": extract_text_corpus,
"chunk_corpus_for_topic_extraction": chunk_corpus_for_topic_extraction,
"merge_and_deduplicate_topics": merge_and_deduplicate_topics,
"assign_topic_labels": assign_topic_labels,
"build_topic_frequency_table": build_topic_frequency_table,
"split_corpus_by_field": split_corpus_by_field,
"compare_topic_distributions": compare_topic_distributions,
"save_comparison_csv": save_comparison_csv,
"map_topics_to_pajais": map_topics_to_pajais,
"save_taxonomy_json": save_taxonomy_json,
"build_narrative_context": build_narrative_context,
"validate_narrative": validate_narrative,
"summarise_run": summarise_run,
}
PAJAIS_THEMES_LIST = PAJAIS_THEMES