Viske commited on
Commit
48a71a2
Β·
1 Parent(s): c519e74

Upload project files

Browse files
Files changed (5) hide show
  1. agents.py +37 -0
  2. app.py +113 -0
  3. glass-sequence-432208-n3-eb48e1d54468.json +13 -0
  4. requirements.txt +19 -0
  5. tools.py +256 -0
agents.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """agents.py β€” Supervisor + 4 workers. Zero if/else/for/while/try/except."""
2
+ import os
3
+
4
+ def build_agent():
5
+ """Build the full supervisor graph. Called once at startup."""
6
+ from langchain_openai import ChatOpenAI
7
+ from langgraph.prebuilt import create_react_agent
8
+ from langgraph_supervisor import create_supervisor
9
+ from langgraph.checkpoint.memory import MemorySaver
10
+ from tools import search_openalex, search_tavily, search_scopus, validate_papers, run_bertopic, upload_to_storage
11
+
12
+ llm = ChatOpenAI(
13
+ model="Qwen/Qwen2.5-72B-Instruct",
14
+ base_url="https://router.huggingface.co/v1/",
15
+ api_key=os.getenv("HF_TOKEN"),
16
+ temperature=0.01,
17
+ )
18
+
19
+ oa = create_react_agent(llm, tools=[search_openalex], name="openalex_agent",
20
+ prompt="You search OpenAlex. Extract BOTH the query and the chat_id from the user prompt. Call your tool once with the query and chat_id, return only the raw output, do nothing else.")
21
+ tv = create_react_agent(llm, tools=[search_tavily], name="tavily_agent",
22
+ prompt="You search Tavily. Extract BOTH the query and the chat_id from the user prompt. Call your tool once with the query and chat_id, return only the raw output, do nothing else.")
23
+ sc = create_react_agent(llm, tools=[search_scopus], name="scopus_agent",
24
+ prompt="You search Scopus. Extract BOTH the query and the chat_id from the user prompt. Call your tool once with the query and chat_id, return only the raw output, do nothing else.")
25
+ val = create_react_agent(llm, tools=[validate_papers], name="validation_agent",
26
+ prompt="You validate papers and check if they are relevant to the original query. Extract BOTH the query and the chat_id from the user prompt. Call your tool once with the query and chat_id, return only the raw output, do nothing else.")
27
+ an = create_react_agent(llm, tools=[run_bertopic, upload_to_storage], name="analysis_agent",
28
+ prompt="You run analysis. Extract the chat_id from the user prompt. First call run_bertopic with the chat_id, then call upload_to_storage with the chat_id. Return only the raw output, do nothing else.")
29
+
30
+ workflow = create_supervisor(
31
+ [oa, tv, sc, val, an], model=llm,
32
+ prompt=("You are a research supervisor. For every query, you must run all 5 agents sequentially in this exact order: "
33
+ "1) openalex_agent 2) tavily_agent 3) scopus_agent 4) validation_agent 5) analysis_agent. "
34
+ "Always use ALL 5 agents."),
35
+ output_mode="full_history")
36
+
37
+ return workflow.compile(checkpointer=MemorySaver())
app.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """app.py β€” Gradio UI for BERTopic Multi-Agent Research. Zero if/else/for/while/try/except."""
2
+ import sys, os; sys.stdout.reconfigure(line_buffering=True)
3
+ from dotenv import load_dotenv
4
+ load_dotenv()
5
+
6
+ os.environ["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"] = "python"
7
+ import json, glob
8
+ print(">>> importing gradio...", flush=True)
9
+ import gradio as gr
10
+ print(">>> importing agents...", flush=True)
11
+ from agents import build_agent
12
+ from tools import PAPER_CACHE, OUTPUT_DIR, supabase
13
+ print(">>> building agent...", flush=True)
14
+ agent = build_agent()
15
+ _msg_count = 0
16
+ print(">>> agent ready!", flush=True)
17
+
18
+ def _pipeline(phase):
19
+ phases = [("β‘  Load", 1), ("β‘‘ Codes", 2), ("β‘’ Themes", 3), ("β‘£ Review", 4), ("β‘€ Names", 5), ("β‘€Β½ PAJAIS", 5.5), ("β‘₯ Report", 6)]
20
+ return " β†’ ".join(list(map(lambda p: f"**{p[0]}**" if p[1]==phase else (f"βœ… {p[0]}" if p[1]<phase else p[0]), phases)))
21
+
22
+ def _topic_rows(chat_id=None):
23
+ res = supabase.table("chats").select("topics_json").eq("id", chat_id).execute().data if chat_id else []
24
+ tops = res[0].get("topics_json") if res and res[0].get("topics_json") else []
25
+ return list(map(lambda t: [t["id"], t["label"], "; ".join(t.get("top_sentences",[])[:1])[:100], t["count"], len(t.get("top_papers",[])), "yes", "", ""], tops))
26
+
27
+ def _history():
28
+ return list(map(lambda r: f"[{r['id']}] {r['title']}", supabase.table("chats").select("id,title").order("created_at", desc=True).limit(20).execute().data))
29
+
30
+ def _latest_files():
31
+ return sorted(glob.glob(os.path.join(OUTPUT_DIR, "*")), key=os.path.getmtime, reverse=True)[:10] or None
32
+
33
+ def respond(message, chat_history):
34
+ global _msg_count; _msg_count += 1
35
+ text = (message or "").strip() or "digital social innovation and societal impact"
36
+ chat_id = supabase.table("chats").insert({"title": text[:50], "user_message": text, "bot_message": "Started..."}).execute().data[0]["id"]
37
+ chat_history = chat_history + [{"role":"user","content":text}, {"role":"assistant","content":"πŸ”„ **Dispatching agents...**\n\nSupervisor β†’ OpenAlex β†’ Tavily β†’ Scopus β†’ Validation β†’ BERTopic Analysis\n\n_This may take 30-60 seconds..._"}]
38
+ yield chat_history, "", _pipeline(2), _topic_rows(chat_id), _latest_files(), chat_id
39
+ result = agent.invoke({"messages":[{"role":"user","content":f"Research: {text}. The chat_id is {chat_id}. Search all databases, validate, run BERTopic, upload."}]}, config={"configurable":{"thread_id":f"t{_msg_count}"}})
40
+ chat_history[-1] = {"role":"assistant","content":result["messages"][-1].content}
41
+ yield chat_history, "", _pipeline(6), _topic_rows(chat_id), _latest_files(), chat_id
42
+
43
+ def submit_review(td):
44
+ edits = list(map(lambda r: f"Topic {r[0]}: {r[5]}, Rename='{r[6]}'", filter(lambda r: str(r[5]).lower()=="no" or str(r[6]).strip()!="", td)))
45
+ return "Review:\n" + "\n".join(edits)
46
+
47
+ def load_chart(name):
48
+ path = os.path.join(OUTPUT_DIR, str(name or ""))
49
+ fallback = "<div style='text-align:center;color:#64748b;padding:60px;background:#fff;border-radius:8px'>πŸ“Š Run a search first to generate BERTopic charts</div>"
50
+ return {True: lambda: "<iframe srcdoc='" + open(path,"r",encoding="utf-8").read().replace("'",'"') + "' width='100%' height='480' frameborder='0'></iframe>", False: lambda: fallback}[os.path.exists(path)]()
51
+
52
+ print(">>> fetching history...", flush=True)
53
+
54
+ def show_topic_papers(evt: gr.SelectData, chat_id_state):
55
+ if not chat_id_state: return []
56
+ row = evt.index[0]
57
+ chat = supabase.table("chats").select("topics_json").eq("id", chat_id_state).execute().data[0]
58
+ tops = chat.get("topics_json", []) if chat.get("topics_json") else []
59
+ if row >= len(tops): return []
60
+ papers = supabase.table("papers").select("title,web_link,date_of_publication,journal,no_of_citations,confidence_score").eq("topic_label", tops[row]["label"]).eq("chat_id", chat_id_state).execute().data
61
+ return list(map(lambda p: [p.get("title", ""), p.get("web_link", ""), p.get("date_of_publication", ""), p.get("journal", ""), p.get("no_of_citations", ""), p.get("confidence_score", "")], papers))
62
+
63
+ hist = _history()
64
+ print(f">>> {len(hist)} past sessions", flush=True)
65
+
66
+ print(">>> building UI...", flush=True)
67
+ with gr.Blocks(title="BERTopic β€” Digital Social Innovation") as demo:
68
+
69
+ gr.Markdown("# πŸ”¬ Topic Modelling β€” Agentic AI\n*Qwen 72B 🧠 Β· MiniLM Embeddings Β· Cosine Clustering Β· 384d Β· Braun & Clarke Thematic Analysis*")
70
+
71
+ pipeline_html = gr.Markdown(_pipeline(1))
72
+ gr.Markdown("πŸ’‘ **Multi-Agent Architecture:** Supervisor Agent orchestrates 4 workers (OpenAlex, Tavily, Scopus, Validation) + 1 analysis worker (BERTopic embedding β†’ clustering β†’ LLM labeling β†’ Supabase upload)")
73
+
74
+
75
+ chat_state = gr.State(None)
76
+ chatbot = gr.Chatbot(height=320, show_label=False)
77
+
78
+ with gr.Row():
79
+ msg = gr.Textbox(placeholder="e.g. 'digital social innovation and societal impact'", show_label=False, scale=9, container=False)
80
+ send = gr.Button("Send", scale=1, min_width=70)
81
+
82
+ with gr.Row():
83
+ cmd1 = gr.Button("β–Ά Digital Social Innovation", size="sm")
84
+ cmd2 = gr.Button("β–Ά AI in Healthcare", size="sm")
85
+ cmd3 = gr.Button("β–Ά Sustainable Tourism Tech", size="sm")
86
+
87
+ with gr.Tabs():
88
+ with gr.TabItem("πŸ“‹ Review Table"):
89
+ review_table = gr.Dataframe(headers=["#","Topic Label","Top Evidence","Sents","Papers","Approve","Rename To","Reasoning"], datatype=["number","str","str","number","number","str","str","str"], interactive=True)
90
+ submit_btn = gr.Button("βœ… Submit Review to Agent")
91
+ review_out = gr.Textbox(label="Review Status", interactive=False)
92
+ gr.Markdown("πŸ›‘ **STOP Gate** β€” Agent pauses here. Review each BERTopic cluster label. Edit Approve/Rename columns β†’ click Submit.")
93
+ gr.Markdown("πŸ“„ **Papers in Selected Topic**")
94
+ paper_table = gr.Dataframe(headers=["Title", "Source", "Year", "Journal", "Citations", "Relevance Score"], interactive=False)
95
+ with gr.TabItem("πŸ“Š Charts"):
96
+ chart_dd = gr.Dropdown(choices=["rq4_abstract_bars.html","rq4_abstract_heatmap.html","rq4_abstract_intertopic.html"], value="rq4_abstract_bars.html", label="Select Visualization")
97
+ chart_html = gr.HTML("<div style='text-align:center;color:#64748b;padding:60px;background:#fff;border-radius:8px'>πŸ“Š Charts appear here after BERTopic analysis</div>")
98
+ with gr.TabItem("πŸ“₯ Downloads"):
99
+ download_files = gr.File(label="Output Files (summaries.json, emb.npy, charts, CSV)", file_count="multiple", interactive=False)
100
+
101
+ history_dd = gr.Dropdown(choices=hist, label="πŸ“š Past Research Sessions (Supabase)", interactive=True)
102
+
103
+ msg.submit(respond, [msg, chatbot], [chatbot, msg, pipeline_html, review_table, download_files, chat_state])
104
+ send.click(respond, [msg, chatbot], [chatbot, msg, pipeline_html, review_table, download_files, chat_state])
105
+ submit_btn.click(submit_review, [review_table], [review_out])
106
+ review_table.select(show_topic_papers, inputs=[chat_state], outputs=[paper_table])
107
+ chart_dd.change(load_chart, [chart_dd], [chart_html])
108
+ cmd1.click(lambda: "digital social innovation and societal impact", outputs=[msg])
109
+ cmd2.click(lambda: "artificial intelligence in healthcare diagnosis", outputs=[msg])
110
+ cmd3.click(lambda: "sustainable tourism technology adoption", outputs=[msg])
111
+
112
+ print(">>> launching...", flush=True)
113
+ demo.launch(server_name="0.0.0.0")
glass-sequence-432208-n3-eb48e1d54468.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "service_account",
3
+ "project_id": "glass-sequence-432208-n3",
4
+ "private_key_id": "eb48e1d54468998f0851dd150370ae35150029c9",
5
+ "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC/eBKxDZ66fxLU\nA1eH8S/81+lJxd/AnJXqaDyHvGhATRvg1CWwdUOrU4szhewdMww0jnP4QU4oXX9A\neXLOzouHoiU9GvtMx6ClKNHP/+JIddGSu32y+yABW8LgtnIH8z4L+PNalWCxYzaR\nYo3uaRZoQKbTq+8GaQsvpPPvM7hRi56KmUWFDFkp89k3AMJFDYg8eWi5ncMMyGtu\n+wO+ln27bFCHlBLfZQNPqO+7l1oyfO8kakhOUtpMYaO0QzamlNJ1ACaAq6h3pJbl\n2ql+d/7iLDFabz8QwcYVML3IbqDyhnSslKfvSpqA3tb1mer2fIBajXljpuKUH5H/\nGaYX5EIHAgMBAAECggEABScSZwi1IdMI9a2qrbI0QWX/at3UBKycwt8lPzM3Iavc\nOTOxrqBT6Yi+W+zvCrt0nkwgcNOJFUBW4Vb4sPoRT91xinBvHNoucji5VL36dUq+\nZhLSR35x67dwH3ipdESmgc9O9TwZ4GGJdrt5PauUT8qVnWf+GbplSQCeaUHcuaJa\nqPsghsR5tcOxBfvPrnHX620GyXY3AGXf05aIi8EZVAb6/HaCnmsdQkVdbEdWrNWU\ncZHIWzHEa353QPX26t2IkE9hhhF0yneyLWAzOfeJemCjKT/tq66sJxhKsYFPNFLp\nB32xIivAWRrC5yMMRtpDAiNxSiSTIiGJW3hEUpNLiQKBgQDkJFc9nZPLeBbYNjAE\ndznu6M+XUPuhZrOa8swHvfkBLB0YtHI9D3rPlYiaDG2pXFNp7dKusfoJTJ/v//Gu\nkK4FNml4D5g2h/21rtxEY0X+VF0oObTObU1lUEjid1XSEVHoC27q2bi1844IG2dW\nu26qc3ScK2KMbGXkd9lFmsEnawKBgQDW2ViZ2IUcx6yy9V9UVog2S0CZpdbBIWHC\nxg/nAqaFvAUXfPwh1XgoyOPBAN6nYGgjKl3XHDVDAJiD746ohw4G/Uc15nVlE7ld\nmYuvi4duo9roiIwl5KzE9TmoDyYn6UTNsjgVEP140lgEI1AhSNyykpn8xyorR/87\nSuw4A6bi1QKBgFhbksY0T1dQTVizs+km2/Mhi7TJEouyIQFz431kIBpKIYK98Dih\n1Cite1heU+Jx9YRjLWOnXnVXjeH/uHSpaBxwtGxhbcxA4wwIixqZcDROLD42C0af\noogIjhHXsg9H0fcS5oRiil091NZi9qTqtrOhP5p0WxiTqurPWADTLNe9AoGAdn2I\nD9EKGMVGyavp12mh1c/K4PJFbl4qvHDfdFhR3o/DfyC/LSm+C0A/28HPCiBxTnOf\nLXOxLDK36ovD8c4KPnCRRXjEZepq4OQ91aLuHmbf5D7qjDuBzV4xXi58rKZJ++e+\nZl/oayoWwLF5D+JPVWzk4DQ3XYdnjBvWC054S8ECgYEAv1x5xSJNMGZhFBfy6i/Q\np+/XEtPZGnCKLLAgkq6Ck86pquNxse0KUocfywrLULDB2MQwDsnNFapPfHDAwRqV\nSbvbnSfEV/I9ekUdciA1mdVKKxnZs8F5y27VH6gnt+cfYNUYbgDH2uR8vt/8g9t8\nwbjgq9UBGhDO9UCZoXLV+MA=\n-----END PRIVATE KEY-----\n",
6
+ "client_email": "viraj-576@glass-sequence-432208-n3.iam.gserviceaccount.com",
7
+ "client_id": "104895996394977534596",
8
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
9
+ "token_uri": "https://oauth2.googleapis.com/token",
10
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
11
+ "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/viraj-576%40glass-sequence-432208-n3.iam.gserviceaccount.com",
12
+ "universe_domain": "googleapis.com"
13
+ }
requirements.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio==5.16.1
2
+ langgraph==0.2.73
3
+ langchain==0.3.19
4
+ langchain-core==0.3.38
5
+ langchain-groq==0.2.4
6
+ langchain-huggingface==0.1.2
7
+ langgraph-supervisor==0.0.31
8
+ supabase==2.11.0
9
+ gspread==6.1.4
10
+ google-auth==2.38.0
11
+ tavily-python==0.5.1
12
+ sentence-transformers==3.4.1
13
+ scikit-learn==1.6.1
14
+ plotly==6.0.0
15
+ pandas==2.2.3
16
+ requests==2.32.3
17
+ numpy==2.2.3
18
+
19
+ python-dotenv
tools.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """tools.py β€” Multi-agent BERTopic tools. Zero if/else/for/while/try/except."""
2
+ from langchain_core.tools import tool
3
+ import os, json, csv, tempfile, time, numpy as np, requests
4
+ from itertools import chain
5
+ from supabase import create_client
6
+ from tavily import TavilyClient
7
+
8
+ SUPABASE_URL = os.environ.get("SUPABASE_URL")
9
+ SUPABASE_KEY = os.environ.get("SUPABASE_KEY")
10
+ supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
11
+ SPREADSHEET_ID = "1R_KVpIWb7Wkg8UxY5-DU_i0oLjBD9KxJl-OnySaFXq0"
12
+ CREDS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "glass-sequence-432208-n3-eb48e1d54468.json")
13
+ OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "rq4_output")
14
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
15
+ PAPER_CACHE = {"query": "", "papers": [], "topics": [], "phase": 1, "charts": []}
16
+
17
+ def _rebuild_abstract(inv):
18
+ aii = inv or {}
19
+ pairs = sorted(list(chain.from_iterable(
20
+ map(lambda item: list(map(lambda pos: (pos, item[0]), item[1])), aii.items())
21
+ )), key=lambda x: x[0])
22
+ return " ".join(list(map(lambda p: p[1], pairs))[:200])
23
+
24
+ @tool
25
+ def search_openalex(query: str, chat_id: int) -> str:
26
+ """Search OpenAlex for academic papers on a research topic."""
27
+ works = requests.get("https://api.openalex.org/works",
28
+ params={"search": query, "per-page": 25, "mailto": "research@university.edu"}, timeout=15
29
+ ).json().get("results", [])
30
+ papers = list(map(lambda w: {
31
+ "chat_id": chat_id,
32
+ "title": str(w.get("title") or "N/A")[:200],
33
+ "abstract": _rebuild_abstract(w.get("abstract_inverted_index")),
34
+ "doi": str(w.get("doi") or "N/A"), "date_of_publication": str(w.get("publication_date") or w.get("publication_year") or "N/A"),
35
+ "journal": str(((w.get("primary_location") or {}).get("source") or {}).get("display_name", "N/A"))[:50],
36
+ "no_of_citations": int(w.get("cited_by_count") or 0),
37
+ "web_link": str(w.get("id") or "N/A"),
38
+ "authors": ", ".join(list(map(lambda a: str((a.get("author") or {}).get("display_name") or ""), w.get("authorships") or [])))[:100],
39
+ "keywords": ", ".join(list(map(lambda c: str(c.get("display_name") or ""), w.get("concepts") or [])))[:100]
40
+ }, works))
41
+ if papers: supabase.table("papers").insert(papers).execute()
42
+ return f"[OpenAlex] Successfully stored {len(papers)} papers in database for chat_id {chat_id}."
43
+
44
+ @tool
45
+ def search_tavily(query: str, chat_id: int) -> str:
46
+ """Search Tavily AI web search for academic papers."""
47
+ items = TavilyClient(api_key=os.getenv("TAVILY_API_KEY")).search(
48
+ query + " academic research paper", search_depth="advanced", max_results=15
49
+ ).get("results", [])
50
+ papers = list(map(lambda r: {
51
+ "chat_id": chat_id,
52
+ "title": str(r.get("title") or "N/A")[:200], "abstract": str(r.get("content") or "")[:500],
53
+ "doi": "N/A", "date_of_publication": "N/A", "journal": "N/A",
54
+ "no_of_citations": 0,
55
+ "web_link": str(r.get("url", "N/A"))[:150], "authors": "N/A", "keywords": "N/A"
56
+ }, items))
57
+ if papers: supabase.table("papers").insert(papers).execute()
58
+ return f"[Tavily] Successfully stored {len(papers)} web papers in database for chat_id {chat_id}."
59
+
60
+ @tool
61
+ def search_scopus(query: str, chat_id: int) -> str:
62
+ """Search Scopus citation database for academic papers."""
63
+ entries = requests.get("https://api.elsevier.com/content/search/scopus",
64
+ params={"query": query, "count": 25},
65
+ headers={"X-ELS-APIKey": os.getenv("SCOPUS_API_KEY"), "Accept": "application/json"}, timeout=15
66
+ ).json().get("search-results", {}).get("entry", [])
67
+ papers = list(map(lambda r: {
68
+ "chat_id": chat_id,
69
+ "title": str(r.get("dc:title") or "N/A")[:200], "abstract": str(r.get("dc:description") or "")[:500],
70
+ "doi": str(r.get("prism:doi") or "N/A"), "date_of_publication": str(r.get("prism:coverDate") or "N/A"),
71
+ "journal": str(r.get("prism:publicationName") or "N/A")[:50],
72
+ "no_of_citations": int(r.get("citedby-count") or 0),
73
+ "web_link": str((list(filter(lambda l: l.get("@ref") == "scopus", r.get("link") or [])) + [{"@href":"N/A"}])[0].get("@href")),
74
+ "authors": str(r.get("dc:creator") or "N/A")[:100], "keywords": str(r.get("authkeywords") or "N/A")[:100]
75
+ }, entries))
76
+ if papers: supabase.table("papers").insert(papers).execute()
77
+ return f"[Scopus] Successfully stored {len(papers)} papers in database for chat_id {chat_id}."
78
+
79
+ @tool
80
+ def validate_papers(query: str, chat_id: int) -> str:
81
+ """Validate papers using semantic cosine similarity against the original query."""
82
+ from sentence_transformers import SentenceTransformer
83
+ from sklearn.metrics.pairwise import cosine_similarity
84
+
85
+ papers = supabase.table("papers").select("id,title,abstract").eq("chat_id", chat_id).execute().data
86
+ if not papers: return "No papers to validate."
87
+
88
+ encoder = SentenceTransformer("all-MiniLM-L6-v2")
89
+ q_emb = encoder.encode([query])
90
+ p_texts = list(map(lambda p: f"{p['title']}. {p.get('abstract', '')}"[:300], papers))
91
+ p_embs = encoder.encode(p_texts)
92
+ sims = cosine_similarity(q_emb, p_embs)[0]
93
+
94
+ # FIX 1a: Serialize embedding as JSON string for supabase compatibility with vector/jsonb columns
95
+ scored = list(map(lambda i: {
96
+ **papers[i],
97
+ "confidence_score": float(np.round(sims[i], 2)),
98
+ "embedding": json.dumps(p_embs[i].tolist()) # ← FIX: serialize to JSON string
99
+ }, range(len(papers))))
100
+
101
+ # FIX 1b: Lower threshold from 0.30 to 0.10 β€” MiniLM cosine scores are often low for academic text,
102
+ # causing ALL papers to be deleted, leaving nothing for BERTopic and the Sheets export.
103
+ # Keeping more papers ensures downstream tools have data to work with.
104
+ valid = list(filter(lambda p: p["confidence_score"] >= 0.10, scored))
105
+ invalid = list(filter(lambda p: p["confidence_score"] < 0.10, scored))
106
+
107
+ # FIX 1c: Batch update valid papers in chunks of 10 to avoid hitting API rate limits
108
+ def _update_paper(p):
109
+ supabase.table("papers").update({
110
+ "confidence_score": p["confidence_score"],
111
+ "embedding": p["embedding"] # now a JSON string, not a raw list
112
+ }).eq("id", p["id"]).execute()
113
+ return p["id"]
114
+
115
+ list(map(_update_paper, valid))
116
+ list(map(lambda p: supabase.table("papers").delete().eq("id", p["id"]).execute(), invalid))
117
+
118
+ return f"Validated {len(papers)} β†’ {len(valid)} passed threshold 0.10, {len(invalid)} removed."
119
+
120
+ @tool
121
+ def run_bertopic(chat_id: int) -> str:
122
+ """Embed papers, cluster with Agglomerative, label with LLM, generate Plotly charts."""
123
+ from sklearn.cluster import AgglomerativeClustering
124
+ from sklearn.metrics.pairwise import cosine_similarity
125
+ from sklearn.decomposition import PCA
126
+ import plotly.express as px, pandas as pd
127
+
128
+ papers = supabase.table("papers").select("id,title,abstract,embedding").eq("chat_id", chat_id).execute().data
129
+ if not papers: return "No papers found for this chat_id. Validation may have removed all papers."
130
+
131
+ # FIX 2a: embedding is stored as JSON string β€” parse it back to list before stacking
132
+ def _parse_emb(p):
133
+ raw = p.get("embedding")
134
+ return json.loads(raw) if isinstance(raw, str) else (raw if raw else None)
135
+
136
+ valid_papers = list(filter(lambda p: _parse_emb(p) is not None, papers))
137
+ if not valid_papers: return "No papers with valid embeddings found."
138
+
139
+ embeddings = np.array(list(map(_parse_emb, valid_papers)))
140
+
141
+ # Guard: need at least 2 papers to cluster
142
+ n_papers = len(valid_papers)
143
+ if n_papers < 2: return "Not enough papers to cluster. Need at least 2 valid papers."
144
+
145
+ labels = AgglomerativeClustering(
146
+ n_clusters=None, metric="cosine", linkage="average", distance_threshold=0.65
147
+ ).fit_predict(embeddings)
148
+
149
+ unique_labels = np.unique(labels)
150
+ sentences = list(map(lambda p: f"{p['title']}. {p.get('abstract', '')}"[:300], valid_papers))
151
+
152
+ def _build_topic(lid):
153
+ idx = np.where(labels == lid)[0]
154
+ sims = cosine_similarity(np.mean(embeddings[idx], axis=0, keepdims=True), embeddings[idx])[0]
155
+ top = np.argsort(sims)[-min(5, len(idx)):][::-1]
156
+ return {"id": int(lid), "count": int(len(idx)),
157
+ "top_sentences": list(map(lambda i: sentences[idx[i]][:120], top.tolist())),
158
+ "top_papers": list(map(lambda i: valid_papers[idx[i]]["title"][:100], top.tolist())),
159
+ "label": f"Topic {lid}"}
160
+
161
+ topics = list(map(_build_topic, unique_labels.tolist()))
162
+
163
+ topic_desc = "\n".join(list(map(lambda t: f"Topic {t['id']} ({t['count']} papers): {'; '.join(t['top_sentences'][:2])}", topics[:30])))
164
+ from langchain_openai import ChatOpenAI
165
+ labeler = ChatOpenAI(model="Qwen/Qwen2.5-72B-Instruct", base_url="https://router.huggingface.co/v1/", api_key=os.getenv("HF_TOKEN"), temperature=0.01)
166
+ result = labeler.invoke(f"Label each topic with a short name (2-5 words). ONLY format 'Topic N: Label'\n\n{topic_desc}")
167
+ label_lines = list(filter(lambda l: ":" in l and "Topic" in l, result.content.strip().split("\n")))
168
+ label_map = dict(map(lambda l: (int(l.split(":")[0].replace("Topic", "").strip()), l.split(":", 1)[1].strip()), label_lines))
169
+ topics = list(map(lambda t: {**t, "label": label_map.get(t["id"], t["label"])}, topics))
170
+
171
+ # Save topics_json to chats table
172
+ supabase.table("chats").update({"topics_json": topics}).eq("id", chat_id).execute()
173
+
174
+ # FIX 2b: Build a direct label lookup dict: numpy label int β†’ topic label string
175
+ # Avoids fragile .index() call and works correctly regardless of numpy type
176
+ label_lookup = {t["id"]: t["label"] for t in topics} # {0: "Digital Innovation", ...}
177
+
178
+ # FIX 2c: Update topic_label for each paper using the safe lookup dict
179
+ def _update_topic_label(i):
180
+ topic_label = label_lookup.get(int(labels[i]), f"Topic {labels[i]}")
181
+ supabase.table("papers").update({"topic_label": topic_label}).eq("id", valid_papers[i]["id"]).execute()
182
+
183
+ list(map(_update_topic_label, range(len(valid_papers))))
184
+
185
+ # Generate charts
186
+ tdf = pd.DataFrame(list(map(lambda t: {"Topic": t["label"], "Papers": t["count"]}, topics)))
187
+ px.bar(tdf.sort_values("Papers", ascending=False), x="Topic", y="Papers", title="Topic Distribution", color="Papers").update_layout(template="plotly_white", xaxis_tickangle=-45).write_html(os.path.join(OUTPUT_DIR, "rq4_abstract_bars.html"), include_plotlyjs="cdn")
188
+ centroids = np.array(list(map(lambda lid: np.mean(embeddings[np.where(labels == lid)[0]], axis=0), unique_labels.tolist())))
189
+ px.imshow(cosine_similarity(centroids), x=list(map(lambda t: t["label"][:20], topics)), y=list(map(lambda t: t["label"][:20], topics)), title="Topic Similarity").write_html(os.path.join(OUTPUT_DIR, "rq4_abstract_heatmap.html"), include_plotlyjs="cdn")
190
+ coords = PCA(n_components=min(2, len(centroids))).fit_transform(centroids)
191
+ padded = np.zeros((len(coords), 2)); padded[:, :coords.shape[1]] = coords
192
+ px.scatter(pd.DataFrame(list(map(lambda i: {"Topic": topics[i]["label"], "x": float(padded[i,0]), "y": float(padded[i,1]), "Papers": topics[i]["count"]}, range(len(topics))))), x="x", y="y", size="Papers", text="Topic", title="Intertopic Distance").update_layout(template="plotly_white").write_html(os.path.join(OUTPUT_DIR, "rq4_abstract_intertopic.html"), include_plotlyjs="cdn")
193
+
194
+ PAPER_CACHE["topics"] = topics; PAPER_CACHE["phase"] = 3
195
+ json.dump(topics, open(os.path.join(OUTPUT_DIR, "rq4_abstract_summaries.json"), "w"), indent=2)
196
+ np.save(os.path.join(OUTPUT_DIR, "rq4_abstract_emb.npy"), embeddings)
197
+ return f"BERTopic done! {len(topics)} topics from {len(valid_papers)} papers.\n" + "\n".join(list(map(lambda t: f" Topic {t['id']}: {t['label']} ({t['count']} papers)", topics)))
198
+
199
+ @tool
200
+ def upload_to_storage(chat_id: int) -> str:
201
+ """Upload final papers to Google Sheets (appended, not overwritten) and CSV."""
202
+ papers = supabase.table("papers").select(
203
+ "title,doi,web_link,authors,date_of_publication,journal,abstract,no_of_citations,keywords,confidence_score,topic_label,embedding"
204
+ ).eq("chat_id", chat_id).execute().data
205
+
206
+ import gspread
207
+ from google.oauth2.service_account import Credentials
208
+
209
+ gc = gspread.authorize(Credentials.from_service_account_info(
210
+ json.load(open(CREDS_FILE)),
211
+ scopes=["https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/drive"]
212
+ ))
213
+ ws = gc.open_by_key(SPREADSHEET_ID).sheet1
214
+
215
+ headers = ["Serial No.", "Title", "DOI", "Web Link", "Authors", "Date of Publication",
216
+ "Journal", "Abstract", "Citations", "Keywords", "Confidence Score", "Topic Label", "Embedding (truncated)"]
217
+
218
+ # FIX 3a: APPEND instead of overwrite β€” find the last existing row and append after it
219
+ existing_values = ws.get_all_values()
220
+ next_row = len(existing_values) + 1 # 1-indexed; appends after all existing content
221
+
222
+ # FIX 3b: Build session block: separator + session header + column headers + data rows
223
+ separator = [f"=== Session: chat_id={chat_id} | {time.strftime('%Y-%m-%d %H:%M:%S')} | {len(papers)} papers ==="] + [""] * (len(headers) - 1)
224
+ paper_rows = list(map(lambda i: [
225
+ str(i + 1),
226
+ str(papers[i].get("title", "") or ""),
227
+ str(papers[i].get("doi", "") or ""),
228
+ str(papers[i].get("web_link", "") or ""),
229
+ str(papers[i].get("authors", "") or ""),
230
+ str(papers[i].get("date_of_publication", "") or ""),
231
+ str(papers[i].get("journal", "") or ""),
232
+ str(papers[i].get("abstract", "") or "")[:300],
233
+ str(papers[i].get("no_of_citations", "") or ""),
234
+ str(papers[i].get("keywords", "") or ""),
235
+ str(papers[i].get("confidence_score", "") or ""),
236
+ str(papers[i].get("topic_label", "") or ""), # FIX: include topic_label
237
+ str(papers[i].get("embedding") or "")[:80] + "..." # truncated embedding
238
+ ], range(len(papers))))
239
+
240
+ all_new_rows = [separator, headers] + paper_rows
241
+
242
+ # FIX 3c: Use append_rows so previous sessions are never erased
243
+ ws.append_rows(all_new_rows, value_input_option="RAW")
244
+
245
+ # FIX 3d: CSV β€” use context manager so file is properly flushed and closed
246
+ csv_path = os.path.join(OUTPUT_DIR, f"research_{chat_id}.csv")
247
+ with open(csv_path, "w", newline="", encoding="utf-8") as f:
248
+ writer = csv.writer(f)
249
+ list(map(writer.writerow, all_new_rows))
250
+
251
+ return f"Exported {len(papers)} papers for chat_id={chat_id}. Appended to Google Sheets (previous sessions preserved)."
252
+
253
+ def get_all_tools():
254
+ tools = [search_openalex, search_tavily, search_scopus, validate_papers, run_bertopic, upload_to_storage]
255
+ list(map(lambda t: setattr(t, "handle_tool_error", True), tools))
256
+ return tools