research-agent / src /agent.py
abhid1234's picture
fix concise truncation: enforce brevity via prompt
dead2bc verified
Raw
History Blame Contribute Delete
12 kB
"""LangGraph orchestration: search -> filter -> read -> score -> synthesize (-> deepen)."""
from __future__ import annotations
import json
import re
from concurrent.futures import ThreadPoolExecutor
from typing import Callable, TypedDict
from . import utils
from .db import PaperVectorDB
from .tools import (
DownloadTool,
ExtractionTool,
Paper,
SearchTool,
SemanticScholarTool,
)
class AgentState(TypedDict):
topic: str
papers: list[Paper]
review: str
gaps: list[str]
depth: int
iteration: int
NUM_QUERIES = 3
RESULTS_PER_QUERY = 8
DEFAULT_MAX_PAPERS = 15
POOL_BUFFER = 3
READ_WORKERS = 4 # parallel PDF download + extraction
def _generate_queries(topic: str, meter: dict | None = None, model: str | None = None) -> list[str]:
prompt = (
f"Generate {NUM_QUERIES} diverse, specific search queries that together "
f'cover the research topic: "{topic}".\n'
"Vary the angle (methods, applications, theory, recent advances). "
"Return ONLY a JSON array of short query strings."
)
try:
raw = utils.complete(prompt, meter=meter, model=model)
match = re.search(r"\[.*\]", raw, flags=re.DOTALL)
queries = [str(q).strip() for q in (json.loads(match.group(0)) if match else []) if str(q).strip()]
except Exception:
queries = []
if topic not in queries:
queries.insert(0, topic)
return queries[:NUM_QUERIES] or [topic]
def _queries_from_gaps(topic, gaps, meter=None, model=None) -> list[str]:
gap_text = "\n".join(f"- {g}" for g in gaps[:5])
prompt = (
f'For the topic "{topic}", here are open problems/gaps identified so far:\n'
f"{gap_text}\n\nGenerate {NUM_QUERIES} new, specific search queries to find "
"papers addressing these gaps. Return ONLY a JSON array of short query strings."
)
try:
raw = utils.complete(prompt, meter=meter, model=model)
match = re.search(r"\[.*\]", raw, flags=re.DOTALL)
return [str(q).strip() for q in (json.loads(match.group(0)) if match else []) if str(q).strip()][:NUM_QUERIES]
except Exception:
return []
def _filter_relevant(topic, papers, keep, meter=None, model=None) -> list[Paper]:
"""Drop clearly off-topic candidates with one cheap LLM relevance pass."""
if len(papers) <= keep:
return papers
listing = "\n".join(
f"[{i}] {p.title}{p.abstract[:160]}" for i, p in enumerate(papers)
)
prompt = (
f'Topic: "{topic}"\n\nCandidate papers:\n{listing}\n\n'
f"Return ONLY a JSON array of the indices (numbers) of the {keep} papers most "
"relevant to the topic, best first. Exclude clearly off-topic papers."
)
try:
raw = utils.complete(prompt, meter=meter, model=model)
match = re.search(r"\[.*\]", raw, flags=re.DOTALL)
idxs = json.loads(match.group(0)) if match else []
chosen = [papers[i] for i in idxs if isinstance(i, int) and 0 <= i < len(papers)]
return chosen or papers[:keep]
except Exception:
return papers[:keep]
def build_graph(
max_papers: int = DEFAULT_MAX_PAPERS,
progress: Callable[[str], None] | None = None,
meter: dict | None = None,
model: str | None = None,
year_min: int = 0,
style: str = "concise",
extract_model: str | None = None,
):
"""Build and compile the research agent graph.
``style`` is "concise" (default — shorter review, far cheaper since output
tokens dominate cost) or "comprehensive" (longer, more detailed).
``extract_model`` overrides the model for the ~20 mechanical extraction
calls (e.g. a cheaper flash-lite), keeping the run model for synthesis.
"""
from langgraph.graph import END, StateGraph
say = progress or (lambda _msg: None)
searcher = SearchTool(max_results=RESULTS_PER_QUERY)
scholar = SemanticScholarTool(max_results=RESULTS_PER_QUERY, year_min=year_min)
downloader = DownloadTool()
extractor = ExtractionTool(model=extract_model or model, meter=meter)
pool_cap = max_papers + POOL_BUFFER
def _run_searches(queries: list[str], existing: list[Paper]) -> list[Paper]:
merged: dict[str, Paper] = {p.id: p for p in existing}
seen_titles = {p.title.lower() for p in existing}
def add(papers: list[Paper]) -> None:
for p in papers:
if p.id in merged or p.title.lower() in seen_titles:
continue
merged[p.id] = p
seen_titles.add(p.title.lower())
for q in queries:
say(f" query: {q}")
try:
add(searcher.search(q))
except Exception as err:
say(f" (arXiv query failed: {err})")
try:
add(scholar.search(q))
except Exception as err:
say(f" (Semantic Scholar query failed: {err})")
return list(merged.values())
def search_node(state: AgentState) -> dict:
topic = state["topic"]
queries = _generate_queries(topic, meter=meter, model=model)
say(f"Searching arXiv + Semantic Scholar with {len(queries)} queries...")
candidates = _run_searches(queries, state.get("papers", []))
n_s2 = sum(1 for p in candidates if p.source == "semantic_scholar")
say(f"Found {len(candidates)} candidates ({len(candidates) - n_s2} arXiv, {n_s2} S2).")
# Relevance filter: drop off-topic papers before the expensive read step.
already_read = [p for p in candidates if p.full_text]
fresh = [p for p in candidates if not p.full_text]
if fresh:
say("Filtering candidates by relevance...")
fresh = _filter_relevant(topic, fresh, pool_cap, meter=meter, model=model)
papers = (already_read + fresh)[: pool_cap + state.get("iteration", 0) * POOL_BUFFER]
say(f"Keeping {len(papers)} relevant papers.")
return {"papers": papers}
def read_node(state: AgentState) -> dict:
papers = state["papers"]
if not papers:
raise RuntimeError(
"No papers found from arXiv or Semantic Scholar — both sources may "
"be rate-limiting right now. Please try again in a minute."
)
# Only read the top max_papers (already ranked by score_node); the rest
# were filtered/ranked on abstracts alone, so we never pay to read them.
todo = [p for p in papers[:max_papers] if not p.full_text]
say(f"Reading top {len(todo)} papers (parallel)...")
def _read_one(paper: Paper) -> None:
paper.full_text = downloader.get_text(paper)
try:
extractor.extract(paper)
except Exception as err:
say(f" (extraction failed: {paper.title[:40]}: {err})")
tag = "abstract-only" if paper.read_from == "abstract" else "pdf"
say(f" read: {paper.title[:55]} [{tag}]")
with ThreadPoolExecutor(max_workers=READ_WORKERS) as pool:
list(pool.map(_read_one, todo))
return {"papers": papers}
def score_node(state: AgentState) -> dict:
papers = state["papers"]
say("Scoring papers by relevance...")
db = None
try:
db = PaperVectorDB()
db.index(papers)
ranked_ids = db.search(state["topic"], n=len(papers))
order = {pid: rank for rank, pid in enumerate(ranked_ids)}
for p in papers:
p.score = 1.0 / (1 + order.get(p.id, len(papers)))
papers.sort(key=lambda p: order.get(p.id, len(papers)))
except Exception as err:
say(f" (scoring skipped: {err})")
finally:
if db is not None:
db.close()
return {"papers": papers}
def synthesize_node(state: AgentState) -> dict:
top = state["papers"][:max_papers]
say(f"Synthesizing {style} review from top {len(top)} papers...")
papers_block = utils.format_papers_for_synthesis(top)
if style == "comprehensive":
length_note = "Write a thorough, detailed review."
section_note = (
"2. ## Key Themes (group by methodology)\n"
"3. ## Key Findings\n"
"4. ## Open Problems & Gaps\n"
)
else: # concise (default): brevity comes from the prompt, not a token cap,
# so the review never truncates — it just stays short (and cheap).
length_note = (
"Write a CONCISE review — 600 words MAXIMUM for the whole thing. "
"Keep EVERY section to 2-4 tight sentences. Do NOT write a paragraph "
"or bullet per paper or per theme; summarize across them. Prefer "
"brevity over completeness. Still include all five sections below."
)
section_note = (
"2. ## Key Themes (2-4 sentences across methodologies)\n"
"3. ## Key Findings (2-4 sentences)\n"
"4. ## Open Problems & Gaps (2-4 sentences)\n"
)
prompt = (
f"{length_note}\n\nLiterature review on: {state['topic']}\n\n"
f"Papers:\n{papers_block}\n\n"
"Use these Markdown sections:\n"
"1. ## Introduction\n"
f"{section_note}"
"5. ## Summary Table (Markdown table: Paper | Year | Method | Key result)\n\n"
"Be specific and grounded ONLY in the papers above. Every claim must cite "
"its source as [Author, Year] using the first author's surname. Do not "
"invent papers, authors, or findings not present above."
)
# High ceiling for BOTH styles so nothing ever truncates; concise stays
# cheap because the prompt keeps the actual output short.
review = utils.complete(prompt, max_tokens=16384, meter=meter, model=model)
review = review.rstrip() + "\n" + utils.references_markdown(top)
gaps: list[str] = []
section = re.search(
r"##\s*Open Problems.*?\n(.*?)(?:\n##\s|\Z)", review, flags=re.DOTALL
)
if section:
gaps = [
line.strip("-* ").strip()
for line in section.group(1).splitlines()
if line.strip().startswith(("-", "*"))
]
return {"review": review, "gaps": gaps}
def deepen_node(state: AgentState) -> dict:
it = state.get("iteration", 0) + 1
say(f"Deepening (pass {it + 1}/{state['depth']}): searching gaps...")
queries = _queries_from_gaps(state["topic"], state.get("gaps", []), meter=meter, model=model)
if not queries:
return {"iteration": it}
candidates = _run_searches(queries, state["papers"])
fresh = [p for p in candidates if not p.full_text]
kept_fresh = _filter_relevant(state["topic"], fresh, pool_cap, meter=meter, model=model) if fresh else []
papers = [p for p in candidates if p.full_text] + kept_fresh
say(f"Now {len(papers)} candidate papers after deepening.")
return {"papers": papers, "iteration": it}
def should_deepen(state: AgentState) -> str:
if state.get("iteration", 0) < state.get("depth", 1) - 1 and state.get("gaps"):
return "deepen"
return END
graph = StateGraph(AgentState)
graph.add_node("search", search_node)
graph.add_node("read", read_node)
graph.add_node("score", score_node)
graph.add_node("synthesize", synthesize_node)
graph.add_node("deepen", deepen_node)
graph.set_entry_point("search")
graph.add_edge("search", "score") # rank on abstracts first...
graph.add_edge("score", "read") # ...then read only the top max_papers
graph.add_edge("read", "synthesize")
graph.add_conditional_edges("synthesize", should_deepen, {"deepen": "deepen", END: END})
graph.add_edge("deepen", "score")
return graph.compile()