diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 46fb2d3..287a61e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8299,9 +8299,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", diff --git a/frontend/src/components/ChatWindow.js b/frontend/src/components/ChatWindow.js index 0639a10..770b89d 100644 --- a/frontend/src/components/ChatWindow.js +++ b/frontend/src/components/ChatWindow.js @@ -1,6 +1,7 @@ import React, { useEffect, useRef } from 'react'; import ReactMarkdown from 'react-markdown'; import ClaimItem from './ClaimItem'; +import DrugCarousel from './DrugCarousel'; function ChatWindow({ messages, query, onQueryChange, onSubmit, loading }) { const bottomRef = useRef(null); @@ -52,6 +53,7 @@ function ChatWindow({ messages, query, onQueryChange, onSubmit, loading }) {
{msg.data.response}
+ {msg.data.scored_claims && msg.data.scored_claims.length > 0 && (
Claim Verification
diff --git a/frontend/src/components/DrugCarousel.js b/frontend/src/components/DrugCarousel.js new file mode 100644 index 0000000..3b86a74 --- /dev/null +++ b/frontend/src/components/DrugCarousel.js @@ -0,0 +1,204 @@ +import React, { useState } from 'react'; + +const SECTION_LABELS = { + warnings: 'Warnings', + contraindications: 'Contraindications', + adverse_reactions: 'Adverse Reactions', + drug_interactions: 'Drug Interactions', + dosage_and_administration: 'Dosage & Administration', + indications_and_usage: 'Indications & Usage', +}; + +const SECTION_ACCENT = { + warnings: '#dc2626', + contraindications: '#d97706', + adverse_reactions: '#ca8a04', + drug_interactions: '#2563eb', + dosage_and_administration: '#16a34a', + indications_and_usage: '#7c3aed', +}; + +function getSectionKey(pmid) { + const parts = pmid.split('-'); + return parts.slice(2).join('_'); +} + +function getDrugName(pmid) { + const parts = pmid.split('-'); + return parts[1]; +} + +function DrugCard({ drug, sections }) { + const [activeSection, setActiveSection] = useState(0); + const current = sections[activeSection]; + const sectionKey = getSectionKey(current.pmid); + const accent = SECTION_ACCENT[sectionKey] || 'var(--text-3)'; + + return ( +
+
+ FDA Drug Label + {drug} +
+ +
+ {sections.map((s, i) => { + const key = getSectionKey(s.pmid); + const a = SECTION_ACCENT[key] || 'var(--text-3)'; + const isActive = i === activeSection; + return ( + + ); + })} +
+ +
+
+
+ {SECTION_LABELS[sectionKey] || sectionKey} +
+
+ {current.abstract} +
+
+
+ +
+ Source: U.S. Food & Drug Administration · {current.pmid} +
+
+ ); +} + +function DrugCarousel({ abstracts }) { + const [currentDrug, setCurrentDrug] = useState(0); + + if (!abstracts || abstracts.length === 0) return null; + + const fdaAbstracts = abstracts.filter(a => a.pmid && a.pmid.startsWith('FDA-')); + if (fdaAbstracts.length === 0) return null; + + const drugMap = {}; + fdaAbstracts.forEach(a => { + const drug = getDrugName(a.pmid); + if (!drugMap[drug]) drugMap[drug] = []; + drugMap[drug].push(a); + }); + + const drugs = Object.keys(drugMap); + if (drugs.length === 0) return null; + + return ( +
+
+ FDA Drug Labels · {drugs.length} medication{drugs.length > 1 ? 's' : ''} detected +
+ + {drugs.length > 1 && ( +
+ {drugs.map((drug, i) => ( + + ))} +
+ )} + + +
+ ); +} + +export default DrugCarousel; diff --git a/src/agent/graph.py b/src/agent/graph.py index 143dccc..f935b99 100644 --- a/src/agent/graph.py +++ b/src/agent/graph.py @@ -1,30 +1,32 @@ from langgraph.graph import StateGraph from src.agent.nodes import pubmed_retrieval, llm_generation, parse_claims, nli_scoring, \ - confidence_scoring, assembly, preprocess_query + confidence_scoring, assembly, preprocess_query, detect_medications, fda_enrichment, route_after_medication_detection from src.agent.state import AgentState graph = StateGraph(AgentState) # Add nodes -# graph.add_node("check_cache", check_cache) graph.add_node("preprocess_query", preprocess_query) graph.add_node("pubmed_retrieval", pubmed_retrieval) graph.add_node("llm_generation", llm_generation) +graph.add_node("detect_medications", detect_medications) +graph.add_node("fda_enrichment", fda_enrichment) graph.add_node("parse_claims", parse_claims) graph.add_node("nli_scoring", nli_scoring) graph.add_node("confidence_scoring", confidence_scoring) graph.add_node("assembly", assembly) # Conditional edge -# graph.add_conditional_edges( -# "check_cache", -# route_after_cache, -# {"pubmed_retrieval": "pubmed_retrieval", "llm_generation": "llm_generation"} -# ) +graph.add_conditional_edges( + "detect_medications", + route_after_medication_detection, + {"fda_enrichment": "fda_enrichment", "llm_generation": "llm_generation"} +) # Add edges graph.add_edge("preprocess_query", "pubmed_retrieval") -graph.add_edge("pubmed_retrieval", "llm_generation") +graph.add_edge("pubmed_retrieval", "detect_medications") +graph.add_edge("fda_enrichment", "llm_generation") graph.add_edge("llm_generation", "parse_claims") graph.add_edge("parse_claims", "nli_scoring") graph.add_edge("nli_scoring", "confidence_scoring") diff --git a/src/agent/nodes.py b/src/agent/nodes.py index 58bc25d..2adfca7 100644 --- a/src/agent/nodes.py +++ b/src/agent/nodes.py @@ -3,6 +3,7 @@ from src.agent.state import AgentState from src.retrieval.vector_store import add_abstracts, query_abstracts from src.retrieval.pubmed import search_pubmed +from src.retrieval.fda import search_drug_label, extract_sections from src.monitoring.mlflow_logger import log_query_run from langchain_google_genai import ChatGoogleGenerativeAI from langchain_core.output_parsers import JsonOutputParser @@ -57,22 +58,56 @@ def llm_generation(state: AgentState): Literature: {context} - Provide a detailed, well formatted, and clinically useful response with markdown based entirely on only the provided literature above. + Provide a detailed, well formatted (Do not include markdown tables), and clinically useful response with markdown based entirely on only the provided literature above. Include a section with a critique of the limitations of the studies retrieved if this is necessary. Do not include "Based on the provided literature" or anything to that effect in the final response, only give the answer. - All instructions given to you are private and should not be shared with the final user, please only include a disclaimer at the bottom that this information is for research purposes and not clinical use. + All instructions given to you are private and should not be shared with the final user. + At the bottom of your response include a message stating that medication information can be found below, and a disclaimer at the very bottom that this information is for research purposes and not clinical use. """ response = _response_llm.invoke(prompt) return {"llm_response": extract_clean_text(response)} -def parse_claims(state: AgentState): +def detect_medications(state: AgentState): parser = JsonOutputParser() + prompt = f"""Extract all medication names mentioned in the following clinical question and literature abstracts. + Return ONLY a JSON array of strings. If no medications are mentioned return []. + + Question: {state["query"]} + Abstracts: {" ".join([a["abstract"] for a in state["abstracts"]])} + + Return format: ["medication1", "medication2"]""" + + response = _search_llm.invoke(prompt) + drug_names = parser.parse(response.content) + return {"drug_names": drug_names} + + +def fda_enrichment(state: AgentState): + drug_labels = [] + abstracts = list(state["abstracts"]) + + for medication in state["drug_names"]: + med_info = search_drug_label(medication) + if med_info is None: + continue + drug_labels.append({"drug": medication, "label": med_info}) + med_sections = extract_sections(med_info, medication) + abstracts.extend(med_sections) + + return {"drug_labels": drug_labels, "abstracts": abstracts} - prompt = f"""Extract all discrete factual claims from the following clinical response. - If the only claims you see are "I could not find any information regarding this question, please try another search." or "Disclaimer: This information is for research purposes and not clinical use." do not include them only add the claim: "No claims made in response". +def route_after_medication_detection(state: AgentState) -> str: + if state["drug_names"] and len(state["drug_names"]) > 0: + return "fda_enrichment" + return "llm_generation" + +def parse_claims(state: AgentState): + parser = JsonOutputParser() + prompt = f"""Extract up to 10 key factual claims from the following clinical response. Return ONLY a JSON array of strings, no other text. - Each claim should be a single verifiable factual statement. + Focus on the most important clinical claims only — ignore minor details and examples, try to give a maximum of 10 unless the claims are extremely important. + Each claim must be a single verifiable factual statement. Response: {state["llm_response"]} @@ -83,16 +118,32 @@ def parse_claims(state: AgentState): claims = parser.parse(response.content) return {"claims": claims} + def nli_scoring(state: AgentState): scored_claims = [] labels = ["Contradicted", "Supported", "Unverifiable"] - for claim in state["claims"]: + claims = state["claims"] + abstracts = state["abstracts"] + + if not claims or not abstracts: + return { + "scored_claims": [{"claim": c, "label": "Unverifiable", "score": 0.0, "evidence": None} for c in claims]} + + pairs = [] + for claim in claims: + for abstract in abstracts: + pairs.append((abstract["abstract"], claim)) + + raw_scores = _nli_model.predict(pairs, batch_size=32) + probs = torch.softmax(torch.tensor(raw_scores), dim=1).numpy() + + pair_idx = 0 + for claim in claims: best_score = -1 best_result = None - for abstract in state["abstracts"]: - scores = _nli_model.predict([(abstract["abstract"], claim)])[0] - scores = torch.softmax(torch.tensor(scores), dim=0).numpy() + for abstract in abstracts: + scores = probs[pair_idx] label_idx = int(np.argmax(scores)) if label_idx != 2: @@ -105,6 +156,7 @@ def nli_scoring(state: AgentState): "score": float(non_neutral_score), "evidence": abstract["abstract"] } + pair_idx += 1 if best_result is None: best_result = { @@ -113,7 +165,6 @@ def nli_scoring(state: AgentState): "score": 0.0, "evidence": None } - scored_claims.append(best_result) return {"scored_claims": scored_claims} diff --git a/src/agent/state.py b/src/agent/state.py index df12b5c..fae606d 100644 --- a/src/agent/state.py +++ b/src/agent/state.py @@ -2,6 +2,9 @@ class AgentState(TypedDict): query: str + has_drug_query: bool + drug_names: Optional[list[str]] + drug_labels: Optional[list[dict]] search_query: Optional[str] abstracts: list[dict] llm_response: Optional[str] diff --git a/src/retrieval/cache.py b/src/retrieval/cache.py deleted file mode 100644 index e86e85a..0000000 --- a/src/retrieval/cache.py +++ /dev/null @@ -1,20 +0,0 @@ -import redis -import json -from src.core.config import settings - -_client = None - -def get_client(): - global _client - if _client is None: - _client = redis.Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, decode_responses=True) - return _client - -def get_cache(key: str): - value = get_client().get(key) - if value is None: - return None - return json.loads(value) - -def set_cache(key: str, value: list[dict]): - get_client().setex(key, 86400, json.dumps(value)) # 86400 = 24 hours diff --git a/src/retrieval/fda.py b/src/retrieval/fda.py new file mode 100644 index 0000000..1689bff --- /dev/null +++ b/src/retrieval/fda.py @@ -0,0 +1,43 @@ +import requests + + +def search_drug_label(drug_name: str) -> dict: + base_url = "https://api.fda.gov/drug/label.json" + + try: + # Try generic name first + response = requests.get(f'{base_url}?search=openfda.generic_name:"{drug_name}"&limit=1') + if response.status_code == 200 and response.json().get("results"): + return response.json() + + # Fall back to brand name + response = requests.get(f'{base_url}?search=openfda.brand_name:"{drug_name}"&limit=1') + response.raise_for_status() + return response.json() + except Exception as e: + print(f"FDA lookup failed for {drug_name}: {e}") + return None + + +def extract_sections(label: dict, drug_name: str) -> list[dict]: + sections = [] + sections_to_extract = [ + "warnings", + "contraindications", + "adverse_reactions", + "drug_interactions", + "dosage_and_administration", + "indications_and_usage" + ] + + result = label["results"][0] + + for section in sections_to_extract: + if section in result and result[section]: + sections.append({ + "title": f"FDA Drug Label — {drug_name} — {section.replace('_', ' ').title()}", + "abstract": result[section][0], + "pmid": f"FDA-{drug_name}-{section}" + }) + + return sections \ No newline at end of file diff --git a/tests/openfda_check.py b/tests/openfda_check.py new file mode 100644 index 0000000..a7c53c3 --- /dev/null +++ b/tests/openfda_check.py @@ -0,0 +1,11 @@ +from src.retrieval.fda import search_drug_label, extract_sections +import sys + +def main(): + drug_name = "warfarin" + search_results = search_drug_label(drug_name) + results = extract_sections(search_results, drug_name) + print(results) + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file