File size: 18,414 Bytes
06414ba
 
 
 
 
 
 
 
 
 
df4deee
 
06414ba
df4deee
 
 
 
 
06414ba
5ee1aa1
06414ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df4deee
06414ba
 
 
 
 
 
 
 
df4deee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
06414ba
 
df4deee
06414ba
 
df4deee
06414ba
 
 
 
df4deee
06414ba
 
df4deee
 
 
06414ba
 
 
 
 
 
 
 
5ee1aa1
 
 
 
 
 
 
 
 
 
 
 
 
06414ba
5ee1aa1
 
 
 
 
 
 
 
06414ba
 
 
 
 
 
 
 
df4deee
 
 
 
 
 
 
 
 
06414ba
 
 
 
 
 
 
 
 
 
 
5ee1aa1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df4deee
 
 
 
 
 
 
 
 
06414ba
5ee1aa1
 
df4deee
06414ba
 
 
5ee1aa1
06414ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
import os, json, shutil, urllib.request, urllib.parse, requests
from xml.etree import ElementTree as ET
from langchain_core.tools import tool
import pandas as pd
import plotly.express as px
import plotly.figure_factory as ff
from sklearn.decomposition import PCA
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.cluster import AgglomerativeClustering

# Use /data for persistence if running on HF Spaces with a mounted bucket, otherwise use local directory
ROOT_DIR = "/data" if os.path.exists("/data") else os.path.dirname(os.path.abspath(__file__))

CSV_PATH  = os.path.join(ROOT_DIR, "papers.csv")
OUT_DIR   = os.path.join(ROOT_DIR, "outputs")
PDF_DIR   = os.path.join(ROOT_DIR, "pdfs")
SCRATCH_DIR = os.path.join(ROOT_DIR, "scratch")
for _d in [OUT_DIR, PDF_DIR, SCRATCH_DIR]: os.makedirs(_d, exist_ok=True)

HEADERS = ["Sr No", "Title", "DOI", "Web Link", "Authors", "Date of Publication", "Journal", "Abstract", "No of Citations", "Research Type", "Research Type Confidence", "Research Type Reason", "Findings"]

PAJAIS_TAXONOMY = {
    "AI & Machine Learning":        ["deep learning","neural networks","NLP","computer vision","reinforcement learning","transformers"],
    "Ethics & Society":             ["bias","fairness","privacy","accountability","transparency","misinformation"],
    "Healthcare & Medicine":        ["clinical","medical","health","diagnosis","patient","drug","genomics"],
    "Education & Learning":         ["e-learning","pedagogy","curriculum","students","teaching","assessment"],
    "Business & Economics":         ["market","finance","enterprise","productivity","innovation","supply chain"],
    "Environment & Sustainability": ["climate","energy","green","carbon","sustainability","smart grid"],
    "Governance & Policy":          ["regulation","policy","government","law","compliance","cybersecurity"],
    "Human-Computer Interaction":   ["usability","UX","interface","interaction","accessibility","chatbot"],
}

_MODEL = None
def _embed():
    global _MODEL
    if _MODEL is None:
        try:
            from sentence_transformers import SentenceTransformer
            _MODEL = SentenceTransformer("all-MiniLM-L6-v2")
        except Exception as e: print(f">>> Embedding load failed: {e}")
    return _MODEL

@tool
def search_academic_source(query: str, source: str) -> str:
    """Search academic sources. Valid sources: 'google_scholar', 'arxiv', 'pubmed', 'hf_papers', 'tavily', 'apify', 'semantic_scholar', 'openalex', 'scopus', 'web_of_science'."""
    try:
        src = source.lower()
        if "google" in src or "scholar" in src:
            from langchain_community.utilities import SerpAPIWrapper
            return SerpAPIWrapper(params={"engine": "google_scholar"}).run(query)[:5000]
        elif "arxiv" in src:
            from langchain_community.utilities import ArxivAPIWrapper
            return ArxivAPIWrapper(top_k_results=5).run(query)[:5000]
        elif "pubmed" in src:
            from langchain_community.utilities.pubmed import PubMedAPIWrapper
            return PubMedAPIWrapper(top_k_results=5).run(query)[:5000]
        elif "hf_papers" in src or "huggingface" in src:
            res = requests.get(f"https://huggingface.co/api/papers/search?q={urllib.parse.quote(query)}").json()
            return str(res[:5])[:5000] if isinstance(res, list) else "HF Search Error"
        elif "apify" in src:
            from langchain_community.utilities import ApifyWrapper
            apify = ApifyWrapper()
            return apify.run("apify/arxiv-scraper", run_input={"searchSubj": "all", "searchQuery": query, "maxResults": 5})[:5000]
        elif "semantic_scholar" in src:
            from langchain_community.utilities.semanticscholar import SemanticScholarAPIWrapper
            return SemanticScholarAPIWrapper(top_k_results=5).run(query)[:5000]
        elif "openalex" in src:
            api_key = os.getenv("OPENALEX_API_KEY")
            url = f"https://api.openalex.org/works?search={urllib.parse.quote(query)}&per-page=5"
            headers = {"User-Agent": "AcademicAgent/1.0 (mailto:agent@research-platform.org)"}
            if api_key: headers["api_key"] = api_key # Higher rate limits if key provided
            res = requests.get(url, headers=headers).json()
            return str(res.get("results", []))[:5000]
        elif "scopus" in src:
            api_key = os.getenv("SCOPUS_API_KEY")
            if not api_key: return "Error: SCOPUS_API_KEY missing. Request the user to provide it or switch to 'semantic_scholar' or 'openalex'."
            res = requests.get(f"https://api.elsevier.com/content/search/scopus?query={urllib.parse.quote(query)}&count=5", headers={"X-ELS-APIKey": api_key, "Accept": "application/json"}).json()
            return str(res)[:5000]
        elif "web_of_science" in src or "wos" in src:
            api_key = os.getenv("WOS_API_KEY")
            if not api_key: return "Error: WOS_API_KEY missing. Request the user to provide it or switch to 'semantic_scholar' or 'openalex'."
            res = requests.get(f"https://wos-api.clarivate.com/api/wos?databaseId=WOS&usrQuery={urllib.parse.quote(query)}&count=5&firstRecord=1", headers={"X-ApiKey": api_key}).json()
            return str(res)[:5000]
        else:
            from langchain_community.tools.tavily_search import TavilySearchResults
            return str(TavilySearchResults(max_results=5).invoke(f"{query} academic peer reviewed paper"))[:5000]
    except Exception as e: return f"Error searching {source}: {str(e)}"

def _download_pdf(url: str, filename: str):
    try:
        r = requests.get(url, stream=True, timeout=12)
        if r.status_code == 200:
            if not filename.lower().endswith(".pdf"): filename += ".pdf"
            path = os.path.join(PDF_DIR, "".join(x for x in filename if x.isalnum() or x in "._- "))
            with open(path, 'wb') as f:
                for chunk in r.iter_content(chunk_size=8192): f.write(chunk)
            return True
    except: pass
    return False

@tool
def save_papers(papers_json: str) -> str:
    """Persist filtered papers to papers.csv. papers_json: JSON array of paper dicts."""
    MAX_TOTAL_PAPERS = 120
    try:
        incoming = json.loads(papers_json)
        if not isinstance(incoming, list): incoming = [incoming]
        
        # Robust Key Normalization (Handles LLM returning 'research_type' instead of 'Research Type')
        clean_incoming = []
        for p in incoming:
            norm_p = {str(k).lower().replace("_", " ").strip(): v for k, v in p.items()}
            clean_p = {}
            for col in HEADERS:
                clean_p[col] = norm_p.get(col.lower().replace("_", " ").strip(), "")
            if "year" in norm_p and not clean_p.get("Date of Publication"):
                clean_p["Date of Publication"] = norm_p["year"]
            clean_incoming.append(clean_p)
        incoming = clean_incoming
        
        existing = pd.read_csv(CSV_PATH) if os.path.exists(CSV_PATH) else pd.DataFrame(columns=HEADERS)
        
        # Schema Migration: Ensure new columns exist in old CSVs
        for col in ["Research Type", "Research Type Confidence", "Research Type Reason", "Findings"]:
            if col not in existing.columns:
                existing[col] = ""
                
        existing["Title"] = existing["Title"].fillna("")
        seen = set(existing["Title"].astype(str).str.lower().str.strip())
        
        # Hard Filter for AI and Societal Impact
        def is_relevant(p):
            txt = (str(p.get("Title", "")) + " " + str(p.get("Abstract", ""))).lower()
            ai_words = ["ai ", "artificial intelligence", "machine learning", "deep learning", "nlp", "llm", "chatgpt", "neural network"]
            impact_words = ["societ", "ethic", "bias", "fairness", "privacy", "impact", "policy", "govern", "regulation", "human"]
            return any(w in txt for w in ai_words) and any(w in txt for w in impact_words)
            
        # Strict Academic Filter: Discard Non-Papers
        def is_research_paper(p):
            txt = (str(p.get("Title", "")) + " " + str(p.get("Journal", "")) + " " + str(p.get("Abstract", ""))).lower()
            banned_words = ["book", "chapter", "news", "editorial", "encyclopedia", "newspaper", "magazine"]
            return not any(w in txt.split() for w in banned_words)  # split() guarantees strict word matching
            
        valid_items = [p for p in incoming if p.get("Title","").strip().lower() not in seen and is_relevant(p) and is_research_paper(p)]
        discarded_count = len([p for p in incoming if p.get("Title","").strip().lower() not in seen and not (is_relevant(p) and is_research_paper(p))])
        new_rows = valid_items
        
        # Enforce MAX_TOTAL_PAPERS = 120 (Prioritize existing CSV papers)
        total_current = len(existing)
        if total_current >= MAX_TOTAL_PAPERS:
            return f"Save skipped: CSV already has {total_current} papers (cap: {MAX_TOTAL_PAPERS})."
        
        allowed_new = MAX_TOTAL_PAPERS - total_current
        final_new_rows = new_rows[:allowed_new]
        
        for i, p in enumerate(final_new_rows): p["Sr No"] = total_current + i + 1
        
        # UPSERT: Update existing rows with new classification data if they were skipped as duplicates
        updated_count = 0
        for p in incoming:
            title = p.get("Title", "").strip().lower()
            if title in seen and p.get("Research Type"):
                idx = existing[existing["Title"].str.lower().str.strip() == title].index
                if not idx.empty:
                    existing.loc[idx[0], "Research Type"] = p.get("Research Type", "")
                    existing.loc[idx[0], "Research Type Confidence"] = p.get("Research Type Confidence", "")
                    existing.loc[idx[0], "Research Type Reason"] = p.get("Research Type Reason", "")
                    existing.loc[idx[0], "Findings"] = p.get("Findings", "")
                    updated_count += 1
        
        if final_new_rows or updated_count > 0:
            if final_new_rows:
                existing = pd.concat([existing, pd.DataFrame(final_new_rows)])
            existing.to_csv(CSV_PATH, index=False)
            
            # Auto-download PDFs for new rows
            download_count = 0
            for p in final_new_rows:
                link = p.get("Web Link", "")
                if link and str(link).lower().endswith(".pdf"):
                    if _download_pdf(link, p.get("Title", "paper")[:50]):
                        download_count += 1

            enrich_doi.invoke({}) # Auto-fill DOIs for new papers
            msg = f"Saved {len(final_new_rows)} papers. Total: {len(existing)}."
            if updated_count > 0: msg += f" (Updated {updated_count} existing papers with Research Types)."
            if download_count > 0: msg += f" (Archived {download_count} PDFs)."
            if discarded_count > 0: msg += f" (Discarded {discarded_count} papers for not aligning with AI & Societal Impact)."
            if len(new_rows) > allowed_new: msg += f" (Truncated {len(new_rows) - allowed_new} exceeding cap)."
            return msg
        return "0 new papers (all duplicates). No existing papers were updated."
    except Exception as e: return f"Save error: {e}"

@tool
def get_paper_batch(offset: int = 0, limit: int = 20) -> str:
    """Read a slice of papers (Sr No, Title, Abstract) from CSV for LLM batch processing."""
    if not os.path.exists(CSV_PATH): return "No papers.csv."
    return pd.read_csv(CSV_PATH)[["Sr No","Title","Abstract"]].fillna("").iloc[offset:offset+limit].to_json(orient="records")

@tool
def read_pdf_text(filepath: str) -> str:
    """Extract raw text from an uploaded PDF for the LLM to parse into paper fields (Title, Authors, Abstract, Year, DOI, Journal).
    Use filepath='list' to see all PDFs in the upload folder."""
    if filepath == "list":
        files = [f for f in os.listdir(PDF_DIR) if f.lower().endswith(".pdf")]
        return "\n".join(files) if files else "No PDFs uploaded yet."
    path = os.path.join(PDF_DIR, os.path.basename(filepath)) if not os.path.isabs(filepath) else filepath
    if not os.path.exists(path): return f"File not found: {filepath}"
    try:
        import pdfplumber
        with pdfplumber.open(path) as pdf:
            return "\n".join(p.extract_text() or "" for p in pdf.pages)[:10000]
    except Exception as e:
        if "pdfplumber" in str(e) or "ModuleNotFoundError" in str(type(e).__name__):
            print(">>> WARNING: 'pdfplumber' module not found. PDF extraction disabled.")
        return f"PDF read error: {e}"

@tool
def read_word_text(filepath: str) -> str:
    """Extract text from a Word (.docx) file for the LLM to parse. 
    Use filepath='list' to see files in the pdfs/ folder (which handles both PDF/Word uploads)."""
    if filepath == "list":
        files = [f for f in os.listdir(PDF_DIR) if f.lower().endswith(".docx")]
        return "\n".join(files) if files else "No Word docs uploaded."
    path = os.path.join(PDF_DIR, os.path.basename(filepath)) if not os.path.isabs(filepath) else filepath
    if not os.path.exists(path): return f"File not found: {filepath}"
    try:
        import docx
        doc = docx.Document(path)
        return "\n".join([p.text for p in doc.paragraphs])[:10000]
    except Exception as e:
        if "docx" in str(e) or "ModuleNotFoundError" in str(type(e).__name__):
            print(">>> WARNING: 'python-docx' module not found. Word extraction disabled.")
        return f"Word read error: {e}"

@tool
def import_from_scratch() -> str:
    """Check the local scratch/ folder for .csv, .pdf, or .docx files and import them into the analysis system."""
    files = os.listdir(SCRATCH_DIR)
    if not files: return "No files in scratch/ folder."
    imported = []
    for f in files:
        src = os.path.join(SCRATCH_DIR, f)
        if f.lower().endswith(".csv"):
            df = pd.read_csv(src)
            save_papers.invoke({"papers_json": df.to_json(orient="records")})
            imported.append(f)
        elif f.lower().endswith((".pdf", ".docx")):
            dest = os.path.join(PDF_DIR, f)
            shutil.copy(src, dest)
            imported.append(f)
    return f"Imported from scratch: {', '.join(imported)}" if imported else "No compatible files found."

@tool
def enrich_doi() -> str:
    """Look up and fill missing DOI values in papers.csv using the CrossRef API (searches by paper title)."""
    if not os.path.exists(CSV_PATH): return "No CSV found."
    try:
        df = pd.read_csv(CSV_PATH); updated = 0
        for i, row in df.iterrows():
            if str(row.get("DOI","")).strip() in ("", "nan"):
                try:
                    q = urllib.parse.quote(str(row.get("Title",""))[:100])
                    with urllib.request.urlopen(f"https://api.crossref.org/works?query.title={q}&rows=1&mailto=agent@research.ai", timeout=8) as r:
                        items = json.loads(r.read()).get("message",{}).get("items",[])
                    if items and items[0].get("DOI"):
                        df.at[i,"DOI"] = items[0]["DOI"]; updated += 1
                except: pass
        df.to_csv(CSV_PATH, index=False)
        return f"DOI enrichment done. Updated {updated}/{len(df)} records."
    except Exception as e: return f"Enrich error: {e}"

@tool
def cluster_and_visualize(mode: str, n_clusters: int = 0) -> str:
    """Runs AgglomerativeClustering on paper embeddings → saves 3 charts (intertopic, heatmap, dendrogram).
    Returns JSON cluster assignments for LLM to label: {"k":N,"assignments":[{"Sr No":1,"Title":"...","cluster":0},...]}"""
    if not os.path.exists(CSV_PATH): return "Error: papers.csv missing."
    model = _embed()
    if model is None: return "Error: Embedding model unavailable."
    try:
        df = pd.read_csv(CSV_PATH)
        texts = df[mode.capitalize()].fillna("").tolist()
        embs = model.encode(texts)
        k = n_clusters if n_clusters > 1 else max(2, min(8, len(df) // 3))
        labels = AgglomerativeClustering(n_clusters=k).fit_predict(embs)
        coords = PCA(n_components=2).fit_transform(embs)
        df["x"], df["y"], df["Cluster"] = coords[:,0], coords[:,1], labels.astype(str)
        px.scatter(df, x="x", y="y", color="Cluster", hover_data=["Title"], title=f"Intertopic Distance ({mode})").write_html(os.path.join(OUT_DIR, f"{mode}_intertopic.html"))
        px.imshow(cosine_similarity(embs), title=f"Similarity Heatmap ({mode})", color_continuous_scale="Viridis").write_html(os.path.join(OUT_DIR, f"{mode}_heatmap.html"))
        from scipy.cluster.hierarchy import linkage as _lnk
        fig = ff.create_dendrogram(embs, labels=[t[:25] for t in texts], linkagefun=lambda x: _lnk(x, method="ward"))
        fig.update_layout(title=f"Hierarchical Dendrogram ({mode})")
        # Truncate titles to save LLM tokens (prevents 429 Rate Limits from exceeding context)
        assignments = []
        for i in range(len(df)):
            full_title = str(df.iloc[i]["Title"])
            trunc_title = full_title[:120] + "..." if len(full_title) > 120 else full_title
            assignments.append({"Sr No": int(df.iloc[i]["Sr No"]), "Title": trunc_title, "cluster": int(labels[i])})
        return json.dumps({"k": k, "assignments": assignments})
    except Exception as e: return f"Clustering error: {e}"

@tool
def save_output(filename: str, content: str) -> str:
    """Save any text/JSON content to the outputs/ folder."""
    try:
        with open(os.path.join(OUT_DIR, os.path.basename(filename)), "w", encoding="utf-8") as f: f.write(content)
        return f"Saved {filename}."
    except Exception as e: return str(e)

@tool
def read_output(filename: str) -> str:
    """Read a file from outputs/. Use 'list' to list files, 'count' for paper count."""
    if filename == "list": return "\n".join(os.listdir(OUT_DIR))
    if filename == "count": return str(len(pd.read_csv(CSV_PATH))) if os.path.exists(CSV_PATH) else "0"
    path = os.path.join(OUT_DIR, os.path.basename(filename))
    return open(path, encoding="utf-8").read()[:8000] if os.path.exists(path) else "File not found."

@tool
def get_pajais_taxonomy() -> str:
    """Returns the full PAJAIS taxonomy schema (categories + keywords) for the LLM to map themes against."""
    return json.dumps(PAJAIS_TAXONOMY, indent=2)