Spaces:
Sleeping
Sleeping
File size: 20,191 Bytes
56fa10c 6e92226 56fa10c 42fb3af 3bfe897 42fb3af 56fa10c 6e92226 56fa10c 3bfe897 6e92226 56fa10c 42fb3af 56fa10c 42fb3af 6e92226 56fa10c 6e92226 56fa10c 6e92226 56fa10c 3bfe897 56fa10c 6e92226 56fa10c 42fb3af 56fa10c 6e92226 56fa10c 42fb3af 56fa10c 42fb3af 56fa10c 42fb3af 3bfe897 56fa10c 3bfe897 56fa10c 6e92226 3bfe897 6e92226 3bfe897 56fa10c 3bfe897 56fa10c 6e92226 56fa10c 3bfe897 56fa10c 42fb3af 56fa10c 6e92226 3bfe897 6e92226 42fb3af 56fa10c 6e92226 3bfe897 6e92226 3bfe897 6e92226 56fa10c 6e92226 56fa10c 6e92226 56fa10c 6e92226 56fa10c 42fb3af 6e92226 56fa10c 6e92226 42fb3af 6e92226 56fa10c | 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 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 | """Multi-step ALS research synthesis agent (streaming). Mirrors beacon/agents/research.py pattern."""
from __future__ import annotations
import json
import re
from collections.abc import Generator
import anthropic
import chromadb
import networkx as nx
from sentence_transformers import CrossEncoder
from config import (
CROSS_ENCODER_MODEL,
CROSS_ENCODER_TOP_N,
RETRIEVAL_ENTITY_N,
RETRIEVAL_SEMANTIC_N,
RRF_K,
RRF_TOP_N,
SYNTHESIS_MODEL,
)
from graph import query as kg_query
from llm import cached_system, cached_tools
from logging_config import get_logger
from normalization.drug_vocab import build_drug_vocab, suggest_drug_term
from prompts import SYNTHESIS_SYSTEM
from rag import retriever as rag_retriever
from tools import RESEARCH_TOOLS
_logger = get_logger("agents.research_agent")
# Loaded once at startup β ~80MB model, ~80ms/pair on CPU
_cross_encoder = CrossEncoder(CROSS_ENCODER_MODEL)
# Drug vocabulary is derived from the (startup-loaded) trials + graph; cache by identity
# so it is built once per session rather than on every query.
_DRUG_VOCAB_CACHE: dict[int, dict] = {}
def _get_drug_vocab(trials: list[dict], graph: nx.DiGraph | None) -> dict:
key = id(trials)
vocab = _DRUG_VOCAB_CACHE.get(key)
if vocab is None:
vocab = build_drug_vocab(trials, graph)
_DRUG_VOCAB_CACHE.clear() # session has one trials object; avoid unbounded growth
_DRUG_VOCAB_CACHE[key] = vocab
return vocab
def stream_research_agent(
client: anthropic.Anthropic,
query: str,
collection: chromadb.Collection,
trials: list[dict],
graph: nx.DiGraph | None = None,
) -> Generator[tuple[str, str], None, None]:
"""
Stream the ALS research synthesis agent.
Yields:
("token", str) β partial text chunk for streaming display
("status", str) β status message during tool execution
("done", str) β final complete response text
"""
messages: list[anthropic.types.MessageParam] = [
{"role": "user", "content": query}
]
# Attach graph reference so _handle_search can use KG expansion
_graph = graph
first_turn = True
while True:
stream_text = ""
# Force tool use on the first turn so Claude always searches before synthesizing.
# Unknown proper nouns (drug codes, gene IDs) would otherwise trigger a
# "I don't recognize X" response straight from training knowledge.
tool_choice: dict = {"type": "any"} if first_turn else {"type": "auto"}
with client.messages.stream(
model=SYNTHESIS_MODEL,
max_tokens=4096,
system=cached_system(SYNTHESIS_SYSTEM),
tools=cached_tools(RESEARCH_TOOLS),
tool_choice=tool_choice,
messages=messages,
) as stream:
# Accumulate tool-use input JSON alongside streaming text
tool_calls: list[dict] = []
current_tool: dict | None = None
current_input_json = ""
for event in stream:
if event.type == "content_block_start":
if event.content_block.type == "tool_use":
current_tool = {
"id": event.content_block.id,
"name": event.content_block.name,
}
current_input_json = ""
yield ("status", "Searching knowledge base and re-ranking results for precision...")
elif event.type == "content_block_delta":
if event.delta.type == "text_delta":
chunk = event.delta.text
stream_text += chunk
yield ("token", chunk)
elif event.delta.type == "input_json_delta" and current_tool:
current_input_json += event.delta.partial_json
elif event.type == "content_block_stop":
if current_tool is not None:
try:
current_tool["input"] = json.loads(current_input_json)
except json.JSONDecodeError:
current_tool["input"] = {}
tool_calls.append(current_tool)
current_tool = None
current_input_json = ""
final_msg = stream.get_final_message()
messages.append({"role": "assistant", "content": final_msg.content})
first_turn = False
if final_msg.stop_reason == "end_turn":
yield ("done", stream_text)
return
if final_msg.stop_reason == "tool_use" and tool_calls:
tool_results: list[anthropic.types.ToolResultBlockParam] = []
for tool_call in tool_calls:
if tool_call["name"] == "search_research_landscape":
result = _handle_search(tool_call["input"], collection, trials, _graph)
is_error = False
else:
result = {"error": f"Unknown tool: {tool_call['name']}"}
is_error = True
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_call["id"],
"content": json.dumps(result),
"is_error": is_error,
})
messages.append({"role": "user", "content": tool_results})
else:
yield ("done", stream_text)
return
# Ubiquitous ALS disease descriptors β grounded across the whole corpus, so they
# must never count as a query "focus" entity for the grounding gate.
_GENERIC_EXACT = {"als", "mnd", "mnds", "ftd", "als/ftd", "disease", "neurodegeneration", "therapy", "treatment"}
_GENERIC_SUBSTRINGS = ("amyotrophic", "lateral sclerosis", "motor neuron")
def _is_generic_term(term: str) -> bool:
"""True for disease-generic terms that are grounded everywhere (ALS, MND, etc.)."""
t = term.lower().strip()
if t in _GENERIC_EXACT:
return True
return any(sub in t for sub in _GENERIC_SUBSTRINGS)
def _norm_alnum(s: str) -> str:
"""Lowercase, alphanumeric-only form so 'CNM-Au8', 'CNMAu8', 'cnm_au8' all unify."""
return "".join(c for c in s.lower() if c.isalnum())
# Trial status display order β available/recruiting first, closed/unavailable last.
# Expanded Access uses AVAILABLE / TEMPORARILY_NOT_AVAILABLE / NO_LONGER_AVAILABLE.
_TRIAL_STATUS_RANK = {
"AVAILABLE": 0,
"RECRUITING": 1,
"NOT_YET_RECRUITING": 2,
"ENROLLING_BY_INVITATION": 3,
"ACTIVE_NOT_RECRUITING": 4,
"TEMPORARILY_NOT_AVAILABLE": 5,
"COMPLETED": 6,
"SUSPENDED": 7,
"TERMINATED": 8,
"WITHDRAWN": 9,
"NO_LONGER_AVAILABLE": 10,
}
def _term_excerpt(document: str, terms: list[str], window: int = 600) -> str:
"""Return a 600-char excerpt centred on the first keyword match, or the document start."""
doc_lower = document.lower()
for term in terms:
idx = doc_lower.find(term.lower())
if idx != -1:
start = max(0, idx - 200)
return document[start : start + window]
return document[:window]
def _handle_search(
tool_input: dict,
collection: chromadb.Collection,
trials: list[dict],
graph: nx.DiGraph | None = None,
) -> dict:
"""Execute KG expansion β RAG search β trial lookup and return structured context."""
query_text = tool_input.get("query_text", "")
query_entities = tool_input.get("query_entities", [])
# Step 1: KG expansion β surface related entities Claude didn't name explicitly
# e.g. "tofersen" β expands to ["SOD1", "antisense oligonucleotide", "RNA splicing"]
if graph and query_entities:
expanded_entities = kg_query.expand_query_entities(graph, query_entities)
else:
expanded_entities = query_entities
# Step 2: Semantic search β up to 30 papers (pure similarity, no citation weight yet)
semantic_results = rag_retriever.search(collection, query_text, n_results=RETRIEVAL_SEMANTIC_N)
# Step 3: Entity-targeted search β up to 30 papers (one query per expanded entity)
entity_results = rag_retriever.search_by_entities(
collection, expanded_entities, n_results=RETRIEVAL_ENTITY_N
)
# Step 3b: Keyword search β exact $contains match for specific named terms.
# Also extracts alphanumeric tokens from query_text (e.g. "SPG302", "C9orf72",
# "AMX0035") that Claude may not include in query_entities because it doesn't
# recognize them as known biological entities.
_entity_tokens = list({
tok for tok in re.findall(r'\b[A-Za-z]+\d+\w*|\b[A-Z]{2,}\d*\w*', query_text)
if len(tok) >= 3
})
keyword_terms = list(dict.fromkeys(query_entities + _entity_tokens)) # dedup, preserve order
keyword_results = rag_retriever.search_by_keyword(collection, keyword_terms)
# Grounding gate β determine whether the query's *specific* focus entities are
# genuinely present in the PAPER corpus. Semantic search always returns nearest
# neighbors regardless of relevance, so we check exact literal presence
# ($contains); otherwise Claude grafts real PMIDs onto topically-adjacent-but-
# unrelated papers. Two exclusions from the focus set:
# - Ubiquitous disease descriptors (ALS / motor neuron disease) β grounded
# everywhere, never the subject of the query.
# - KG-node existence is deliberately NOT used as grounding: a compound can
# have a graph node purely from trial data while having zero paper evidence
# (e.g. SPG302). "In the corpus" means "written in a paper".
focus_terms = [t for t in keyword_terms if t.strip() and not _is_generic_term(t)]
# "Did you mean?" β for unrecognized focus terms, suggest the nearest known drug name
# (typo tolerance). SUGGESTION ONLY β the original term is still what gets searched, so a
# wrong suggestion can never silently redirect the query onto a different drug.
drug_vocab = _get_drug_vocab(trials, graph)
did_you_mean = {
t: s for t in focus_terms if (s := suggest_drug_term(t, drug_vocab))
}
grounded_terms: list[str] = []
ungrounded_terms: list[str] = []
for term in focus_terms:
if rag_retriever.is_grounded_in_corpus(collection, term):
grounded_terms.append(term)
else:
ungrounded_terms.append(term)
# If the query names specific entities and NONE are grounded in papers, the
# corpus holds no genuine evidence β Claude gets zero papers so it cannot graft.
evidence_ungrounded = bool(focus_terms) and not grounded_terms
# Step 4: RRF merge β top 20 papers
merged = rag_retriever.rrf_merge(
[semantic_results, entity_results, keyword_results], k=RRF_K, top_n=RRF_TOP_N
)
# Step 5: Cross-encoder rerank β top 15 papers
reranked = rag_retriever.cross_encoder_rerank(
_cross_encoder, query_text, merged, top_n=CROSS_ENCODER_TOP_N
)
# Step 6: Citation boost β final score = ce_score Γ log(citation_count + 2)
top_papers = rag_retriever.apply_citation_boost(reranked)
# Apply the grounding gate: suppress spurious semantic matches when the query's
# focus entity is absent from the corpus. Trials are still returned below.
paper_pool = [] if evidence_ungrounded else top_papers
# Guarantee the papers that literally name a *landscape-only* focus compound are
# citable. When a compound appears only in full-text pipeline tables (no abstract
# anywhere β e.g. SPG302), the cross-encoder ranks those table chunks below generic
# semantic neighbors and they never reach the model, so it can neither cite nor
# label them. Inject their keyword-hits (capped). Well-grounded entities (C9orf72,
# tofersen) already surface primary papers via semantic/CE β skip injection for them.
landscape_only_terms = [
t for t in grounded_terms
if not rag_retriever.is_grounded_in_abstract(collection, t)
]
if not evidence_ungrounded and landscape_only_terms:
present = {r["pmid"] for r in paper_pool}
focus_hits = rag_retriever.search_by_keyword(collection, landscape_only_terms)
for r in focus_hits[:5]:
if r["pmid"] not in present:
r.setdefault("score", r.get("similarity", 0.0))
paper_pool.append(r)
present.add(r["pmid"])
_logger.info(
"KG+RAG+CE search",
extra={"data": {
"query_entities": query_entities,
"expanded_entities": len(expanded_entities),
"semantic_hits": len(semantic_results),
"entity_hits": len(entity_results),
"keyword_hits": len(keyword_results),
"rrf_merged": len(merged),
"after_cross_encoder": len(top_papers),
"grounded_terms": grounded_terms,
"ungrounded_terms": ungrounded_terms,
"evidence_ungrounded": evidence_ungrounded,
"papers_returned": len(paper_pool),
"kg_active": graph is not None,
}},
)
# Step 7: Trial matching β return ONLY trials genuinely about the queried compound
# or target, ranked available/recruiting first (EAPs, completed, and terminated all
# included). Match on the SPECIFIC query terms β never expanded_entities, whose KG
# expansion balloons to thousands of terms and floods results with unrelated ALS
# trials. Normalized (alphanumeric-only) matching unifies "CNM-Au8" / "CNMAu8" /
# "cnm_au8" across the query, trial interventions, and enriched target_entities.
specific_terms = [
t for t in dict.fromkeys(query_entities + focus_terms)
if t.strip() and not _is_generic_term(t)
]
norm_terms = [n for n in (_norm_alnum(t) for t in specific_terms) if len(n) >= 3]
# Drop fragments that are substrings of a longer matched term β the query regex
# splits "CNM-Au8" into "CNM"/"Au8", whose short normalized forms ("cnm"/"au8")
# over-match unrelated trials. Keep only maximal terms (e.g. "cnmau8").
norm_terms = [n for n in norm_terms if not any(n != m and n in m for m in norm_terms)]
nct_ids_in_query = {w.upper() for w in query_text.split() if w.upper().startswith("NCT")}
matched: dict[str, dict] = {}
for trial in trials:
nct = trial.get("nct_id", "")
if not nct:
continue
iv_names = " ".join(iv.get("name", "") for iv in trial.get("interventions", []))
targets = " ".join(trial.get("target_entities", []))
hay = _norm_alnum(f"{trial.get('title', '')} {iv_names} {targets} {trial.get('summary', '')}")
if nct.upper() in nct_ids_in_query or any(nt in hay for nt in norm_terms):
matched[nct] = trial
ranked = sorted(matched.values(), key=lambda t: _TRIAL_STATUS_RANK.get(t.get("status", ""), 99))
related_trials = [
{
"nct_id": t.get("nct_id", ""),
"title": t.get("title", ""),
"phase": t.get("phase", ""),
"status": t.get("status", ""),
"study_type": t.get("study_type", ""),
"url": t.get("url", ""),
}
for t in ranked[:10]
]
# Context-aware grounding note steers the synthesis model away from hallucination.
if evidence_ungrounded:
_terms = ", ".join(ungrounded_terms) or "the queried entity"
grounding_note = (
f"NO paper evidence exists in this database for: {_terms}. "
"Do NOT synthesize a mechanism or any factual claim from training knowledge, and do "
"NOT cite any PMID. State explicitly that the paper database contains no evidence for "
f"{_terms}. Report ONLY the clinical trials listed below (if any) as the sole grounded "
"information."
)
elif not paper_pool:
grounding_note = (
"NO papers were retrieved. Do not synthesize from training knowledge β state that the "
"database does not contain evidence for this topic. Report only trials below (if any)."
)
else:
note = f"{len(paper_pool)} papers retrieved. Cite a PMID only for claims stated in that paper's excerpt below."
if ungrounded_terms:
note += (
f" IMPORTANT: the database has NO evidence for: {', '.join(ungrounded_terms)}. "
"Say so explicitly and never attach a PMID to any claim about those terms."
)
note += (
" Papers marked evidence_tier='landscape_mention' name a compound only in their full "
"text (e.g. a drug-pipeline table), not their abstract β when citing such a paper for "
"that compound, label the citation as a full-text/pipeline-table mention, not a primary study."
)
grounding_note = note
# Typo suggestions (never substituted into the search). Surface as "did you mean?".
if did_you_mean:
hints = "; ".join(f"'{k}' β '{v}'" for k, v in did_you_mean.items())
grounding_note += (
f" POSSIBLE TYPOS (unrecognized query terms with a near match in the database): {hints}. "
"If a suggestion looks right, tell the physician there was no exact match and ask whether "
"they meant the suggested name, inviting them to re-query with it. Do NOT assume the "
"suggestion is correct and do NOT search it yourself."
)
# Evidence tier β for each paper, flag focus terms (drug codes) that appear only in its
# full text, not its abstract. A compound named only in a full-text pipeline/landscape
# table means the paper is not a primary source for it; the synthesis model labels such
# citations accordingly. Checks the paper's full concatenated text (all chunks), because
# the retrieved representative chunk often is not the one holding the compound name.
texts_by_pmid = rag_retriever.paper_texts_for_pmids(
collection, [r["pmid"] for r in paper_pool]
)
# Only compounds that are landscape-only across the WHOLE corpus (absent from every
# abstract, e.g. SPG302) can be reliably flagged from an abstract-vs-fulltext check.
# A common gene like C9orf72 is discussed in many paper bodies without appearing in
# their abstract β flagging those would wrongly demote primary studies, so restrict
# the check to landscape_only_terms.
fulltext_only_by_pmid: dict[str, list[str]] = {}
for r in paper_pool:
pmid = r["pmid"]
texts = texts_by_pmid.get(pmid, {"abstract": "", "full": ""})
fulltext_only_by_pmid[pmid] = [
t for t in landscape_only_terms
if rag_retriever.term_matches_text(texts["full"], t)
and not rag_retriever.term_matches_text(texts["abstract"], t)
]
_landscape = {pmid: terms for pmid, terms in fulltext_only_by_pmid.items() if terms}
if _landscape:
_logger.info("landscape-mention citations flagged", extra={"data": {"papers": _landscape}})
return {
"papers": [
{
"pmid": r["pmid"],
"title": r["title"],
"year": r["year"],
"doi": r["doi"],
"citation_count": r["citation_count"],
"section": r["section"],
"excerpt": _term_excerpt(r["document"], keyword_terms),
"score": round(r["score"], 3),
"fulltext_only_mentions": fulltext_only_by_pmid.get(r["pmid"], []),
"evidence_tier": "landscape_mention" if fulltext_only_by_pmid.get(r["pmid"]) else "primary",
}
for r in paper_pool
],
"query_entities": query_entities,
"expanded_entities": expanded_entities,
"ungrounded_terms": ungrounded_terms,
"did_you_mean": did_you_mean,
"trials": related_trials,
"evidence_count": len(paper_pool),
"kg_expansion_active": graph is not None,
"grounding_note": grounding_note,
}
|