CoolFace
Apppublic

aarush2109/bertopic-modelling

sourceHugging Faceupdated 4mo agoView on Hugging Face
0likes
agent.py317 linesDownload Raw Back to root
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    MAX_RETRIES = 369    waits = [4, 5, 5]70    for attempt in range(MAX_RETRIES):71        try:72            r = requests.post(73                f"https://generativelanguage.googleapis.com/v1beta/models/"74                f"{model}:generateContent?key={key}",75                headers={"Content-Type":"application/json"},76                json={"contents":[{"parts":[{"text":prompt}]}],77                      "generationConfig":{"temperature":0.2}}, timeout=60)78            d = r.json()79            if "candidates" not in d:80                err = d.get("error",{})81                msg = err.get("message","") if isinstance(err,dict) else str(err)82                if "quota" in msg.lower() or "rate" in msg.lower():83                    w = waits[attempt]84                    logger.warning("Gemini rate-limited. Retry %d/%d after %ds", attempt+1, MAX_RETRIES, w)85                    time.sleep(w); continue86                logger.warning("Gemini attempt %d: %s", attempt+1, msg)87                return {"label": "Unavailable"}88            return _parse(d["candidates"][0]["content"]["parts"][0]["text"])89        except Exception as e:90            w = waits[attempt]91            logger.warning("Gemini error. Retry %d/%d after %ds", attempt+1, MAX_RETRIES, w)92            time.sleep(w)93    logger.warning("Gemini unavailable for topic. Continuing with remaining models.")94    return {"label": "Unavailable"}95 96# ---------------------------------------------------------------------------97# Topic labelling prompt98# ---------------------------------------------------------------------------99def _label_prompt(keyphrases, rep_docs):100    kp = ", ".join(k[0] if isinstance(k,tuple) else k for k in keyphrases[:5])101    ab = " | ".join(a[:250] for a in rep_docs[:3])102    return f"""You are a research topic classifier.103A SPECTER-2 + HDBSCAN pipeline produced a topic cluster.104KEYPHRASES: {kp}105REPRESENTATIVE ABSTRACTS: {ab}106Return ONLY valid JSON:107{{108  "label": "<5-8 word topic label>",109  "description": "<one sentence description>",110  "pacis_match": "<closest PAJAIS 2019 category, or NOVEL if none>",111  "confidence": <0.0-1.0>112}}"""113 114# ---------------------------------------------------------------------------115# Defence prompt for disagreements116# ---------------------------------------------------------------------------117def _defence_prompt(keyphrases, rep_docs, votes):118    kp = ", ".join(k[0] if isinstance(k,tuple) else k for k in keyphrases[:5])119    v_str = "\n".join(f"  LLM{i+1}: {v.get('label','?')}" for i,v in enumerate(votes))120    return f"""Resolve this labelling disagreement.121KEYPHRASES: {kp}122Votes:\n{v_str}123Pick the best label or synthesise a better one.124Return ONLY JSON: {{"label":"...","description":"...","pacis_match":"...","confidence":0.0}}"""125 126# ---------------------------------------------------------------------------127# Grounding check128# ---------------------------------------------------------------------------129def _grounding(label, keyphrases):130    if not label or not keyphrases: return {"verdict":"FAIL", "score":0, "precision":0.0, "recall":0.0, "matched":[]}131    lt = set(re.findall(r"\b[a-z]{3,}\b", label.lower()))132    kt = set()133    for k in keyphrases:134        kt.update(re.findall(r"\b[a-z]{3,}\b", (k[0] if isinstance(k,tuple) else k).lower()))135    noise = {"the","and","for","with","using","based","from","that","are","this"}136    lt -= noise; kt -= noise137    m = list(lt & kt)138    precision = len(m)/max(len(lt),1)139    recall = len(m)/max(len(kt),1)140    return {"verdict":"PASS" if m else "FAIL", "score":precision, "precision":precision, "recall":recall, "matched":m}141 142def _clean(s):143    s = str(s or "").replace("\n"," ").strip()144    return s[:60].rsplit(" ",1)[0] if len(s)>60 else s145 146# ---------------------------------------------------------------------------147# LangGraph node: run topic modelling148# ---------------------------------------------------------------------------149def embed_and_cluster(state: PipelineState) -> dict:150    from tools import run_topic_modeling151    try:152        td = run_topic_modeling(state["filepath"], state.get("n_trials", 50))153        return {"topic_data": td}154    except Exception as e:155        return {"error": str(e)}156 157# ---------------------------------------------------------------------------158# LangGraph node: LLM Council — 4 sheets for topic modelling159# ---------------------------------------------------------------------------160def llm_council(state: PipelineState) -> dict:161    td = state["topic_data"]162    if not td: return {"error": "No topic data"}163    client = Groq(api_key=state["groq_key"], max_retries=0)164    mk, gk = state["mistral_key"], state["gemini_key"]165 166    sheets = {1:[], 2:[], 3:[], 4:[]}  # 1=Groq, 2=Mistral, 3=Gemini, 4=Consolidated167    interps = {}168 169    for cid in sorted(td["keyphrases"].keys()):170        kps = td["keyphrases"][cid]171        rds = td["representative_docs"].get(cid, [])172        sw = td["membership"].get(cid, {"strong":0,"weak":0})173        prompt = _label_prompt(kps, rds)174 175        s1 = _groq(client, prompt); time.sleep(1)176        s2 = _mistral(prompt, mk); time.sleep(1)177        s3 = _gemini(prompt, gk); time.sleep(4)  # respect Gemini free-tier rate limit178        votes = [s1, s2, s3]179 180        # Sheets 1-3181        for si, (sheet_n, resp) in enumerate([(1,s1),(2,s2),(3,s3)]):182            sheets[sheet_n].append({"cluster":cid, **{k:resp.get(k,"—")183                for k in ["label","description","pacis_match","confidence"]}})184 185        # Sheet 4: consolidate186        valid = [v for v in votes if v and "label" in v]187        labels_l = [_clean(v.get("label","")).lower() for v in valid]188        counts = Counter(labels_l)189 190        if any(c>=3 for c in counts.values()):191            agreement = "Triple"192            winner = max(counts, key=counts.get)193            best = next(v for v in valid if _clean(v["label"]).lower()==winner)194        elif any(c>=2 for c in counts.values()):195            agreement = "Two"196            winner = max(counts, key=counts.get)197            best = next(v for v in valid if _clean(v["label"]).lower()==winner)198        else:199            agreement = "Single"200            d = _groq(client, _defence_prompt(kps, rds, votes))201            best = d if d and "label" in d else (valid[0] if valid else {})202 203        label = _clean(best.get("label",""))204        gc = _grounding(label, kps)205        if gc["verdict"]=="FAIL" and valid:206            label = _clean(valid[0].get("label",""))207            gc = _grounding(label, kps)208 209        consistency_score = 1.0 if agreement == "Triple" else (0.67 if agreement == "Two" else 0.33)210 211        cp = td.get("cluster_persistence",{}).get(cid, 0.0)212        sheets[4].append({"cluster":cid, "label":label, "agreement":agreement,213            "description":best.get("description",""),214            "pacis_match":best.get("pacis_match",""),215            "strong":sw["strong"], "weak":sw["weak"],216            "persistence":round(cp,4), "grounding":gc["verdict"],217            "consistency": round(consistency_score, 4),218            "precision": round(gc.get("precision",0), 4),219            "recall": round(gc.get("recall",0), 4)})220 221        interps[cid] = {"label":label, "agreement":agreement,222            "strong":sw["strong"], "weak":sw["weak"],223            "persistence":cp, "description":best.get("description",""),224            "pacis_match":best.get("pacis_match",""),225            "keyphrases":[k[0] if isinstance(k,tuple) else k for k in kps[:5]],226            "consistency": round(consistency_score, 4),227            "precision": round(gc.get("precision",0), 4),228            "recall": round(gc.get("recall",0), 4)}229 230        logger.info("Cluster %d → %s [%s]", cid, label, agreement)231 232    # Agreement rate on labels233    total = len(sheets[4]) or 1234    n_triple = sum(1 for r in sheets[4] if r.get("agreement")=="Triple")235    n_two = sum(1 for r in sheets[4] if r.get("agreement")=="Two")236    237    from sklearn.metrics import cohen_kappa_score238    l1 = [_clean(r.get("label","")).lower() for r in sheets[1]]239    l2 = [_clean(r.get("label","")).lower() for r in sheets[2]]240    l3 = [_clean(r.get("label","")).lower() for r in sheets[3]]241    k12 = cohen_kappa_score(l1, l2) if len(l1) == len(l2) and l1 else 0242    k23 = cohen_kappa_score(l2, l3) if len(l2) == len(l3) and l2 else 0243    k13 = cohen_kappa_score(l1, l3) if len(l1) == len(l3) and l1 else 0244    avg_kappa = (k12 + k23 + k13) / 3 if l1 else 0245    246    avg_precision = sum(r.get("precision",0) for r in sheets[4]) / total247    avg_recall = sum(r.get("recall",0) for r in sheets[4]) / total248    avg_consistency = sum(r.get("consistency",0) for r in sheets[4]) / total249 250    rates = {251        "triple": round(n_triple / total * 100),252        "two_or_more": round((n_triple + n_two) / total * 100),253        "single": round((total - n_triple - n_two) / total * 100),254        "cohen_kappa": round(avg_kappa, 4),255        "global_precision": round(avg_precision, 4),256        "global_recall": round(avg_recall, 4),257        "global_consistency": round(avg_consistency, 4)258    }259 260    # Append the global cohen_kappa to each topic so it appears in the JSON output261    for r in sheets[4]:262        r["cohen_kappa"] = rates["cohen_kappa"]263        if r["cluster"] in interps:264            interps[r["cluster"]]["cohen_kappa"] = rates["cohen_kappa"]265 266    # Save outputs — 4 separate sheet files267    sheet_paths = {}268    names = {1:"sheet1_groq",2:"sheet2_mistral",3:"sheet3_gemini",4:"sheet4_consolidated"}269    for sn, name in names.items():270        path = f"{name}.csv"271        pd.DataFrame(sheets[sn]).to_csv(path, index=False)272        sheet_paths[sn] = path273    with open("topics.json","w") as f: json.dump(sheets[4], f, indent=2)274 275    return {"interpretations":interps, "sheets":sheets,276            "agreement_rates":rates, "sheet_paths":sheet_paths,277            "json_path":"topics.json"}278 279# ---------------------------------------------------------------------------280# LangGraph node: build mismatch table281# ---------------------------------------------------------------------------282def build_mismatch(state: PipelineState) -> dict:283    from tools import build_mismatch_table284    td = state["topic_data"]285    interps = state.get("interpretations", {})286    labels_map = {cid: v["label"] for cid, v in interps.items()}287    mt = build_mismatch_table(td["keyphrases"], labels_map)288    return {"mismatch_table": mt}289 290# ---------------------------------------------------------------------------291# Build the LangGraph292# ---------------------------------------------------------------------------293def build_graph() -> StateGraph:294    g = StateGraph(PipelineState)295    g.add_node("embed_and_cluster", embed_and_cluster)296    g.add_node("llm_council", llm_council)297    g.add_node("build_mismatch", build_mismatch)298    g.set_entry_point("embed_and_cluster")299    g.add_edge("embed_and_cluster", "llm_council")300    g.add_edge("llm_council", "build_mismatch")301    g.add_edge("build_mismatch", END)302    return g.compile()303 304# Compiled graph — importable305pipeline_graph = build_graph()306 307def run_pipeline(filepath, groq_key, mistral_key, gemini_key, n_trials=50):308    """Convenience wrapper."""309    result = pipeline_graph.invoke({310        "filepath": filepath,311        "groq_key": groq_key,312        "mistral_key": mistral_key,313        "gemini_key": gemini_key,314        "n_trials": n_trials,315    })316    return result317