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,
    }