Spaces:
Sleeping
Sleeping
Upload project files
Browse files- agents.py +37 -0
- app.py +113 -0
- glass-sequence-432208-n3-eb48e1d54468.json +13 -0
- requirements.txt +19 -0
- 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
|