BHAVIKBANKER/BERTopic_AGENTIC_AI__GROUP_1
0
1"""2agent.py — LangGraph-based topic analysis agent (§11).33-LLM Council for topic modelling, 4 sheets, triple-agreement tracking.4"""5from __future__ import annotations6import json, logging, os, re, time7from dataclasses import dataclass, field, asdict8from typing import TypedDict, Optional9from collections import Counter10import pandas as pd, numpy as np, requests11from groq import Groq12from langgraph.graph import StateGraph, END13 14logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(message)s")15logger = logging.getLogger(__name__)16 17GROQ_MODEL = "llama-3.1-8b-instant"18MISTRAL_MODEL = "mistral-small-latest"19 20# ---------------------------------------------------------------------------21# LangGraph state22# ---------------------------------------------------------------------------23class PipelineState(TypedDict, total=False):24 filepath: str25 groq_key: str26 mistral_key: str27 gemini_key: str28 n_trials: int29 topic_data: dict30 interpretations: dict31 sheets: dict # {1: [...], 2: [...], 3: [...], 4: [...]}32 agreement_rates: dict33 mismatch_table: list34 json_path: str35 csv_path: str36 error: str37 38# ---------------------------------------------------------------------------39# API helpers40# ---------------------------------------------------------------------------41def _parse(raw: str) -> dict:42 raw = raw.strip().replace("```json","").replace("```","").strip()43 s, e = raw.find("{"), raw.rfind("}")+144 if s != -1 and e > 0: raw = raw[s:e]45 try: return json.loads(raw)46 except: return {}47 48def _groq(client, prompt):49 try:50 r = client.chat.completions.create(model=GROQ_MODEL,51 messages=[{"role":"user","content":prompt}], temperature=0.2, timeout=15)52 return _parse(r.choices[0].message.content)53 except Exception as e: logger.warning("Groq: %s",e); return {}54 55def _mistral(prompt, key):56 if not key: return {}57 try:58 r = requests.post("https://api.mistral.ai/v1/chat/completions",59 headers={"Authorization":f"Bearer {key}","Content-Type":"application/json"},60 json={"model":MISTRAL_MODEL,"messages":[{"role":"user","content":prompt}],61 "temperature":0.2}, timeout=15)62 return _parse(r.json()["choices"][0]["message"]["content"])63 except Exception as e: logger.warning("Mistral: %s",e); return {}64 65def _gemini(prompt, key):66 if not key: return {}67 model = "gemini-2.5-flash"68 for attempt in range(3):69 try:70 r = requests.post(71 f"https://generativelanguage.googleapis.com/v1beta/models/"72 f"{model}:generateContent?key={key}",73 headers={"Content-Type":"application/json"},74 json={"contents":[{"parts":[{"text":prompt}]}],75 "generationConfig":{"temperature":0.2}}, timeout=60)76 d = r.json()77 if "candidates" not in d:78 err = d.get("error",{})79 msg = err.get("message","") if isinstance(err,dict) else str(err)80 if "quota" in msg.lower() or "rate" in msg.lower():81 wait = min(40, 10 * (attempt + 1))82 logger.warning("Gemini rate-limited, waiting %ds…", wait)83 time.sleep(wait); continue84 logger.warning("Gemini attempt %d: %s", attempt+1, msg)85 return {}86 return _parse(d["candidates"][0]["content"]["parts"][0]["text"])87 except Exception as e:88 logger.warning("Gemini attempt %d: %s", attempt+1, e)89 time.sleep(5)90 return {}91 92# ---------------------------------------------------------------------------93# Topic labelling prompt94# ---------------------------------------------------------------------------95def _label_prompt(keyphrases, rep_docs):96 kp = ", ".join(k[0] if isinstance(k,tuple) else k for k in keyphrases[:5])97 ab = " | ".join(a[:250] for a in rep_docs[:3])98 return f"""You are a research topic classifier.99A SPECTER-2 + HDBSCAN pipeline produced a topic cluster.100 101KEYPHRASES: {kp}102REPRESENTATIVE ABSTRACTS: {ab}103 104Return ONLY valid JSON:105{{106 "label": "<5-8 word topic label>",107 "description": "<one sentence description>",108 "pacis_match": "<closest PAJAIS 2019 category, or NOVEL if none>",109 "confidence": <0.0-1.0>110}}"""111 112# ---------------------------------------------------------------------------113# Defence prompt for disagreements114# ---------------------------------------------------------------------------115def _defence_prompt(keyphrases, rep_docs, votes):116 kp = ", ".join(k[0] if isinstance(k,tuple) else k for k in keyphrases[:5])117 v_str = "\n".join(f" LLM{i+1}: {v.get('label','?')}" for i,v in enumerate(votes))118 return f"""Resolve this labelling disagreement.119KEYPHRASES: {kp}120Votes:\n{v_str}121Pick the best label or synthesise a better one.122Return ONLY JSON: {{"label":"...","description":"...","pacis_match":"...","confidence":0.0}}"""123 124# ---------------------------------------------------------------------------125# Grounding check126# ---------------------------------------------------------------------------127def _grounding(label, keyphrases):128 if not label or not keyphrases: return {"verdict":"FAIL","score":0}129 lt = set(re.findall(r"\b[a-z]{3,}\b", label.lower()))130 kt = set()131 for k in keyphrases:132 kt.update(re.findall(r"\b[a-z]{3,}\b", (k[0] if isinstance(k,tuple) else k).lower()))133 noise = {"the","and","for","with","using","based","from","that","are","this"}134 lt -= noise; kt -= noise135 m = list(lt & kt)136 return {"verdict":"PASS" if m else "FAIL", "score":len(m)/max(len(lt),1), "matched":m}137 138def _clean(s):139 s = str(s or "").replace("\n"," ").strip()140 return s[:60].rsplit(" ",1)[0] if len(s)>60 else s141 142# ---------------------------------------------------------------------------143# LangGraph node: run topic modelling144# ---------------------------------------------------------------------------145def embed_and_cluster(state: PipelineState) -> dict:146 from tools import run_topic_modeling147 try:148 td = run_topic_modeling(state["filepath"], state.get("n_trials", 50))149 return {"topic_data": td}150 except Exception as e:151 return {"error": str(e)}152 153# ---------------------------------------------------------------------------154# LangGraph node: LLM Council — 4 sheets for topic modelling155# ---------------------------------------------------------------------------156def llm_council(state: PipelineState) -> dict:157 td = state["topic_data"]158 if not td: return {"error": "No topic data"}159 client = Groq(api_key=state["groq_key"], max_retries=0)160 mk, gk = state["mistral_key"], state["gemini_key"]161 162 sheets = {1:[], 2:[], 3:[], 4:[]} # 1=Groq, 2=Mistral, 3=Gemini, 4=Consolidated163 interps = {}164 165 for cid in sorted(td["keyphrases"].keys()):166 kps = td["keyphrases"][cid]167 rds = td["representative_docs"].get(cid, [])168 sw = td["membership"].get(cid, {"strong":0,"weak":0})169 prompt = _label_prompt(kps, rds)170 171 s1 = _groq(client, prompt); time.sleep(1)172 s2 = _mistral(prompt, mk); time.sleep(1)173 s3 = _gemini(prompt, gk); time.sleep(4) # respect Gemini free-tier rate limit174 votes = [s1, s2, s3]175 176 # Sheets 1-3177 for si, (sheet_n, resp) in enumerate([(1,s1),(2,s2),(3,s3)]):178 sheets[sheet_n].append({"cluster":cid, **{k:resp.get(k,"—")179 for k in ["label","description","pacis_match","confidence"]}})180 181 # Sheet 4: consolidate182 valid = [v for v in votes if v and "label" in v]183 labels_l = [_clean(v.get("label","")).lower() for v in valid]184 counts = Counter(labels_l)185 186 if any(c>=3 for c in counts.values()):187 agreement = "Triple"188 winner = max(counts, key=counts.get)189 best = next(v for v in valid if _clean(v["label"]).lower()==winner)190 elif any(c>=2 for c in counts.values()):191 agreement = "Two"192 winner = max(counts, key=counts.get)193 best = next(v for v in valid if _clean(v["label"]).lower()==winner)194 else:195 agreement = "Single"196 d = _groq(client, _defence_prompt(kps, rds, votes))197 best = d if d and "label" in d else (valid[0] if valid else {})198 199 label = _clean(best.get("label",""))200 gc = _grounding(label, kps)201 if gc["verdict"]=="FAIL" and valid:202 label = _clean(valid[0].get("label",""))203 204 cp = td.get("cluster_persistence",{}).get(cid, 0.0)205 sheets[4].append({"cluster":cid, "label":label, "agreement":agreement,206 "description":best.get("description",""),207 "pacis_match":best.get("pacis_match",""),208 "strong":sw["strong"], "weak":sw["weak"],209 "persistence":round(cp,4), "grounding":gc["verdict"]})210 211 interps[cid] = {"label":label, "agreement":agreement,212 "strong":sw["strong"], "weak":sw["weak"],213 "persistence":cp, "description":best.get("description",""),214 "pacis_match":best.get("pacis_match",""),215 "keyphrases":[k[0] if isinstance(k,tuple) else k for k in kps[:5]]}216 217 logger.info("Cluster %d → %s [%s]", cid, label, agreement)218 219 # Agreement rate on labels220 total = len(sheets[4]) or 1221 n_triple = sum(1 for r in sheets[4] if r.get("agreement")=="Triple")222 n_two = sum(1 for r in sheets[4] if r.get("agreement")=="Two")223 rates = {224 "triple": round(n_triple / total * 100),225 "two_or_more": round((n_triple + n_two) / total * 100),226 "single": round((total - n_triple - n_two) / total * 100),227 }228 229 # Save outputs — 4 separate sheet files230 sheet_paths = {}231 names = {1:"sheet1_groq",2:"sheet2_mistral",3:"sheet3_gemini",4:"sheet4_consolidated"}232 for sn, name in names.items():233 path = f"{name}.csv"234 pd.DataFrame(sheets[sn]).to_csv(path, index=False)235 sheet_paths[sn] = path236 with open("topics.json","w") as f: json.dump(sheets[4], f, indent=2)237 238 return {"interpretations":interps, "sheets":sheets,239 "agreement_rates":rates, "sheet_paths":sheet_paths,240 "json_path":"topics.json"}241 242# ---------------------------------------------------------------------------243# LangGraph node: build mismatch table244# ---------------------------------------------------------------------------245def build_mismatch(state: PipelineState) -> dict:246 from tools import build_mismatch_table247 td = state["topic_data"]248 interps = state.get("interpretations", {})249 labels_map = {cid: v["label"] for cid, v in interps.items()}250 mt = build_mismatch_table(td["keyphrases"], labels_map)251 return {"mismatch_table": mt}252 253# ---------------------------------------------------------------------------254# Build the LangGraph255# ---------------------------------------------------------------------------256def build_graph() -> StateGraph:257 g = StateGraph(PipelineState)258 g.add_node("embed_and_cluster", embed_and_cluster)259 g.add_node("llm_council", llm_council)260 g.add_node("build_mismatch", build_mismatch)261 g.set_entry_point("embed_and_cluster")262 g.add_edge("embed_and_cluster", "llm_council")263 g.add_edge("llm_council", "build_mismatch")264 g.add_edge("build_mismatch", END)265 return g.compile()266 267# Compiled graph — importable268pipeline_graph = build_graph()269 270def run_pipeline(filepath, groq_key, mistral_key, gemini_key, n_trials=50):271 """Convenience wrapper."""272 result = pipeline_graph.invoke({273 "filepath": filepath,274 "groq_key": groq_key,275 "mistral_key": mistral_key,276 "gemini_key": gemini_key,277 "n_trials": n_trials,278 })279 return result280 