From 2d5309a6b38f299e6f0a6fa4f7651b284f6d7789 Mon Sep 17 00:00:00 2001 From: AndrewVFranco <129307231+AndrewVFranco@users.noreply.github.com> Date: Tue, 21 Apr 2026 03:14:45 -0700 Subject: [PATCH 1/3] Update backend API to support live status streaming --- src/api/main.py | 125 +++++++++++++++++++++++++++++------------------- 1 file changed, 77 insertions(+), 48 deletions(-) diff --git a/src/api/main.py b/src/api/main.py index b821df9..96b3161 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -3,7 +3,10 @@ from src.agent.graph import agent from pydantic import BaseModel from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse from typing import Optional +import json +import asyncio class QueryRequest(BaseModel): query: str = "" @@ -21,7 +24,7 @@ async def lifespan(app): CORSMiddleware, allow_origins=[ "http://localhost:3000", - "127.0.0.1:3000" + "127.0.0.1:3000", "https://andrewvfranco-sentinelmd.hf.space", "https://*.hf.space", ], @@ -30,58 +33,84 @@ async def lifespan(app): allow_headers=["*"], ) + @app.post("/query") async def query(request: QueryRequest): - try: - result = agent.invoke({ - "api_key": request.api_key, - "query": request.query, - "search_query": None, - "abstracts": [], - "llm_response": None, - "claims": None, - "scored_claims": None, - "confidence_score": None, - "final_response": None, - "has_drug_query": False, - "drug_names": None, - "drug_labels": None, - "has_fhir": request.has_fhir, - "fhir_resource_type": request.fhir_resource_type, - "fhir_resource_id": request.fhir_resource_id, - "fhir_output": None - }) - - return result - except Exception as e: - print(e) - raise HTTPException(status_code=500, detail=f"Inference failed: {str(e)}") + initial_state = { + "api_key": request.api_key, + "query": request.query, + "search_query": None, + "abstracts": [], + "llm_response": None, + "claims": None, + "scored_claims": None, + "confidence_score": None, + "final_response": None, + "has_drug_query": False, + "drug_names": None, + "drug_labels": None, + "has_fhir": request.has_fhir, + "fhir_resource_type": request.fhir_resource_type, + "fhir_resource_id": request.fhir_resource_id, + "fhir_output": None + } + + async def event_generator(): + try: + async for output in agent.astream(initial_state): + node_name = list(output.keys())[0] + yield f"data: {json.dumps({'status': node_name})}\n\n" + await asyncio.sleep(0.01) + + if node_name == "assembly": + final_state = output["assembly"] + yield f"data: {json.dumps(final_state)}\n\n" + + except Exception as e: + print(f"Streaming error: {e}") + yield f"data: {json.dumps({'error': str(e)})}\n\n" + + return StreamingResponse(event_generator(), media_type="text/event-stream") + @app.post("/fhir") async def fhir(request: QueryRequest): - try: - result = agent.invoke({ - "api_key": request.api_key, - "query": request.query, - "search_query": None, - "abstracts": [], - "llm_response": None, - "claims": None, - "scored_claims": None, - "confidence_score": None, - "final_response": None, - "has_drug_query": False, - "drug_names": None, - "drug_labels": None, - "has_fhir": True, - "fhir_resource_type": request.fhir_resource_type, - "fhir_resource_id": request.fhir_resource_id, - "fhir_output": None - }) - return result - except Exception as e: - print(e) - raise HTTPException(status_code=500, detail=f"FHIR inference failed: {str(e)}") + initial_state = { + "api_key": request.api_key, + "query": request.query, + "search_query": None, + "abstracts": [], + "llm_response": None, + "claims": None, + "scored_claims": None, + "confidence_score": None, + "final_response": None, + "has_drug_query": False, + "drug_names": None, + "drug_labels": None, + "has_fhir": True, # Hardcoded to True for this endpoint + "fhir_resource_type": request.fhir_resource_type, + "fhir_resource_id": request.fhir_resource_id, + "fhir_output": None + } + + async def event_generator(): + try: + async for output in agent.astream(initial_state): + node_name = list(output.keys())[0] + yield f"data: {json.dumps({'status': node_name})}\n\n" + await asyncio.sleep(0.01) + + if node_name == "assembly": + final_state = output["assembly"] + yield f"data: {json.dumps(final_state)}\n\n" + + except Exception as e: + print(f"Streaming error: {e}") + yield f"data: {json.dumps({'error': str(e)})}\n\n" + + return StreamingResponse(event_generator(), media_type="text/event-stream") + @app.get("/health") async def health(): From 0d4dd41e53f836915a2f60178576fc6de3e74ac5 Mon Sep 17 00:00:00 2001 From: AndrewVFranco <129307231+AndrewVFranco@users.noreply.github.com> Date: Tue, 21 Apr 2026 03:15:24 -0700 Subject: [PATCH 2/3] Add SSE stream handling and live status updates --- frontend/src/App.js | 54 ++++++++++++++++++++++----- frontend/src/components/ChatWindow.js | 51 ++++++++++++++++--------- frontend/src/components/Sidebar.js | 12 +++--- 3 files changed, 84 insertions(+), 33 deletions(-) diff --git a/frontend/src/App.js b/frontend/src/App.js index 4e3bc1d..e3e04b7 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -11,6 +11,7 @@ function App() { const [apiKey, setApiKey] = useState(''); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); + const [loadingPhase, setLoadingPhase] = useState(null); const [result, setResult] = useState(null); const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [darkMode, setDarkMode] = useState(() => { @@ -35,16 +36,17 @@ function App() { const handleSubmit = async (fhirData = {}) => { if ((!query.trim() && !fhirData.has_fhir) || loading) return; - // Close the sidebar automatically on mobile when a query is sent closeSidebar(); const userMessage = { role: 'user', text: query || `FHIR ${fhirData.fhir_resource_type}/${fhirData.fhir_resource_id}` }; + setMessages(prev => [...prev, userMessage]); setQuery(''); setLoading(true); + setLoadingPhase(null); setResult(null); try { @@ -55,12 +57,42 @@ function App() { }); if (!response.ok) throw new Error('Query failed'); - const data = await response.json(); - console.log('full response:', data); - console.log('final_response:', data.final_response); - const final = data.final_response; - setResult(final); - setMessages(prev => [...prev, { role: 'assistant', data: final }]); + + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8"); + + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + const parts = buffer.split('\n\n'); + + buffer = parts.pop(); + + for (const part of parts) { + const line = part.trim(); + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.substring(6)); + + if (data.status) { + setLoadingPhase(data.status); + } + + if (data.final_response) { + setResult(data.final_response); + setMessages(prev => [...prev, { role: 'assistant', data: data.final_response }]); + } + } catch (parseError) { + console.error("JSON Parse Error on complete chunk:", line, parseError); + } + } + } + } } catch (err) { let errorText = 'Failed to connect to the analysis server. Ensure the backend is running.'; @@ -78,6 +110,7 @@ function App() { }]); } finally { setLoading(false); + setLoadingPhase(null); } }; @@ -99,8 +132,8 @@ function App() {
-
@@ -120,10 +153,11 @@ function App() { onQueryChange={setQuery} onSubmit={handleSubmit} loading={loading} + loadingPhase={loadingPhase} /> ); } -export default App; +export default App; \ No newline at end of file diff --git a/frontend/src/components/ChatWindow.js b/frontend/src/components/ChatWindow.js index 1325512..092d149 100644 --- a/frontend/src/components/ChatWindow.js +++ b/frontend/src/components/ChatWindow.js @@ -7,24 +7,33 @@ const FHIR_RESOURCE_TYPES = ['Condition', 'MedicationRequest', 'DiagnosticReport const SAMPLE_PLACEHOLDERS = [ 'e.g. What are the evidence-based treatments for heart failure?', - 'e.g. Are there any contraindications for prescribing Tylenol?', + 'e.g. Are there any contraindications for prescribing Metoprolol?', 'e.g. Summarize the latest clinical guidelines for managing Type 2 Diabetes.', - 'e.g. What is the NNT for statins in primary prevention?' + 'e.g. What is the primary reason to prescribe statins?' ]; -function ChatWindow({ messages, query, onQueryChange, onSubmit, loading }) { +const NODE_MESSAGES = { + fhir_input: "Connecting to HAPI FHIR server...", + preprocess_query: "Analyzing query intent...", + pubmed_retrieval: "Querying PubMed abstracts...", + detect_medications: "Scanning for medication entities...", + fda_enrichment: "Retrieving openFDA medication data...", + llm_generation: "Drafting initial clinical response...", + parse_claims: "Extracting testable claims...", + nli_scoring: "Running NLI verification model...", + confidence_scoring: "Calculating overall reliability...", + assembly: "Finalizing evidence-based response..." +}; + +function ChatWindow({ messages, query, onQueryChange, onSubmit, loading, loadingPhase }) { const bottomRef = useRef(null); const textareaRef = useRef(null); const [showFhir, setShowFhir] = useState(false); const [fhirType, setFhirType] = useState('Condition'); const [fhirId, setFhirId] = useState(''); - - // Track which placeholder is currently active const [placeholderIndex, setPlaceholderIndex] = useState(0); - // Swap the placeholder every 5 seconds useEffect(() => { - // Stop the carousel if a message has already been sent if (messages.length > 0) return; const timer = setInterval(() => { @@ -71,7 +80,6 @@ function ChatWindow({ messages, query, onQueryChange, onSubmit, loading }) { const canSubmit = query.trim() || (showFhir && fhirId.trim()); - // Determine what the current placeholder should be const currentPlaceholder = showFhir ? 'Optional: add a specific question about this resource…' : messages.length > 0 @@ -137,10 +145,21 @@ function ChatWindow({ messages, query, onQueryChange, onSubmit, loading }) { {loading && (
-
-
-
-
+
+
+
+
+
+
+
+ {NODE_MESSAGES[loadingPhase] || "Initializing analysis server..."} +
)} @@ -159,8 +178,6 @@ function ChatWindow({ messages, query, onQueryChange, onSubmit, loading }) { · HAPI R4 Server
- - {/* Added flexWrap: 'wrap' here so the inputs stack cleanly on small mobile screens */}