Yogeshwarirj commited on
Commit
376d861
Β·
verified Β·
1 Parent(s): 149dc8b

Update medpanel.py

Browse files
Files changed (1) hide show
  1. medpanel.py +102 -131
medpanel.py CHANGED
@@ -1,7 +1,7 @@
1
  # medpanel.py
2
- # This is the brain of MedPanel β€” all 4 agents, the orchestrator, and the RAG pipeline live here.
3
- # app.py just calls run_medpanel() at the bottom and handles the UI.
4
- # If something's broken, it's probably in this file.
5
 
6
  import os
7
  import json
@@ -16,59 +16,45 @@ from Bio import Entrez
16
  from PIL import Image
17
 
18
 
19
- # ── Config ───────────────────────────────────────────────────────────
20
-
21
  MODEL_ID = "google/medgemma-4b-it"
22
 
23
- # NCBI needs an email to use their API β€” doesn't have to be real, just has to be there
24
  Entrez.email = "medpanel@example.com"
25
 
26
- # ── Device Setup ─────────────────────────────────────────────────────
27
- # force everything onto one device β€” avoids the tensor shape mismatch error
28
- # that happens when accelerate tries to split layers across CPU and GPU
29
- DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
30
- print(f"πŸ–₯️ Using device: {DEVICE}")
31
-
32
 
33
- # ── Model Loading ─────────────────────────────────────────────────────
34
 
35
  def load_models():
36
- # we load everything once at startup and keep it in memory
37
- # reloading per request would take 2+ minutes each time β€” not an option
 
 
 
38
 
39
  print("Loading MedGemma model...")
40
 
41
- # processor handles both text tokenization and image preprocessing
42
- # it's what turns "65yo male with cough" into tokens the model understands
43
  processor = AutoProcessor.from_pretrained(
44
  MODEL_ID,
45
  token=os.environ.get("HF_TOKEN")
 
46
  )
47
 
48
- # float16 instead of bfloat16 β€” fits better on the T4 GPU HF Spaces gives us
49
- # device_map={"": DEVICE} forces all layers to one device
50
- # without this, accelerate splits layers across CPU/GPU and things break badly
51
  model = AutoModelForImageTextToText.from_pretrained(
52
  MODEL_ID,
53
- torch_dtype=torch.float16,
54
- device_map={"": DEVICE},
55
  token=os.environ.get("HF_TOKEN"),
56
- low_cpu_mem_usage=True,
57
- attn_implementation="eager"
58
  )
59
  model.eval()
60
-
61
- # if pad_token isn't set, the model hits EOS immediately and generates 0 tokens
62
- # this was the cause of the empty response bug β€” one line fix
63
- if processor.tokenizer.pad_token is None:
64
- processor.tokenizer.pad_token = processor.tokenizer.eos_token
65
- processor.tokenizer.pad_token_id = processor.tokenizer.eos_token_id
66
- print("βœ… pad_token set to eos_token")
67
-
68
  print("βœ… MedGemma loaded!")
69
 
70
- # PubMedBERT for semantic search β€” regular sentence transformers don't know
71
- # medical terminology well enough, this one was trained on PubMed abstracts
72
  print("Loading PubMed embedding model...")
73
  embed_model = SentenceTransformer("pritamdeka/S-PubMedBert-MS-MARCO")
74
  print("βœ… Embedding model loaded!")
@@ -76,119 +62,97 @@ def load_models():
76
  return processor, model, embed_model
77
 
78
 
79
- # load once at import time β€” all agents share the same model instance
80
  processor, model, embed_model = load_models()
81
 
82
 
83
- # ── Core Model Caller ─────────────────────────────────────────────────
84
 
85
  def call_medgemma(prompt, image=None, max_tokens=400):
86
- # every agent goes through this function β€” it's the single point of contact with MedGemma
87
- # keeps things consistent and makes it easy to add logging or retry logic later
 
 
88
 
 
89
  messages = [
90
  {
91
  "role": "user",
92
  "content": [
93
  {"type": "text", "text": prompt},
94
- # only include the image block if there's actually an image
95
  *([{"type": "image", "image": image}] if image else [])
96
  ]
97
  }
98
  ]
99
 
 
100
  inputs = processor.apply_chat_template(
101
  messages,
102
  add_generation_prompt=True,
103
  tokenize=True,
104
  return_dict=True,
105
  return_tensors="pt"
106
- ).to(DEVICE)
107
-
108
- # track input length so we can slice it off later
109
- # much more reliable than trying to split on "model\n" which breaks constantly
110
- input_len = inputs["input_ids"].shape[-1]
111
- print(f"[MedGemma] Input tokens: {input_len}, max_new_tokens: {max_tokens}")
112
 
 
113
  with torch.no_grad():
114
  output_tokens = model.generate(
115
  **inputs,
116
  max_new_tokens=max_tokens,
117
- do_sample=False,
118
- # without these, the model sometimes stops after 0 tokens
119
- pad_token_id=processor.tokenizer.eos_token_id,
120
- eos_token_id=processor.tokenizer.eos_token_id,
121
- # slight repetition penalty β€” stops the model looping on the same phrase
122
- repetition_penalty=1.1,
123
  )
124
 
125
- # slice off the input tokens β€” we only want what the model actually generated
126
- new_tokens = output_tokens[0][input_len:]
127
- print(f"[MedGemma] Generated {len(new_tokens)} new tokens")
128
-
129
- response = processor.decode(new_tokens, skip_special_tokens=True).strip()
130
- print(f"[MedGemma] Response ({len(response)} chars): {response[:120]}")
131
-
132
- return response
133
 
134
 
135
  def safe_json(text):
136
- # the model doesn't always return clean JSON
137
- # sometimes it wraps it in markdown, sometimes it truncates mid-object,
138
- # sometimes it just writes prose β€” this function handles all of that
139
- # and always returns a dict, never raises an exception
 
140
 
141
- if not text or not text.strip():
142
- return {"raw_response": ""}
143
-
144
- # strip markdown code fences if present β€” ```json ... ``` or ``` ... ```
145
  for fence_start, fence_end in [("```json", "```"), ("```", "```")]:
146
  if fence_start in text:
147
  text = text.split(fence_start)[1].split(fence_end)[0].strip()
148
  break
149
 
150
- # try clean JSON first
151
  try:
152
  return json.loads(text)
153
  except json.JSONDecodeError:
154
  pass
155
 
156
- # if the model got cut off mid-JSON, try to close the open brackets
157
- # this saves a lot of orchestrator outputs that were just one } short
158
- try:
159
- open_count = text.count('{')
160
- close_count = text.count('}')
161
- if open_count > close_count:
162
- recovered = text + ('}' * (open_count - close_count))
163
- return json.loads(recovered)
164
- except json.JSONDecodeError:
165
- pass
166
-
167
- # last resort β€” find any {...} block in the response and try to parse that
168
  json_match = re.search(r'\{.*\}', text, re.DOTALL)
169
  try:
170
  return json.loads(json_match.group()) if json_match else {"raw_response": text}
171
  except json.JSONDecodeError:
172
- # give up and return the raw text so at least something shows up in the UI
173
  return {"raw_response": text}
174
 
175
 
176
  # ── PubMed RAG ───────────────────────────────────────────────────────
177
 
178
  def fetch_and_retrieve(query, top_k=3):
179
- # searches PubMed and returns the most semantically relevant abstracts
180
- # using keyword search here would miss too much β€” medical terminology is inconsistent
181
- # so we embed the abstracts and do vector similarity instead
 
 
 
182
 
183
  try:
184
- # get paper IDs from PubMed's search API
185
  handle = Entrez.esearch(db="pubmed", term=query, retmax=8)
186
  ids = Entrez.read(handle)["IdList"]
187
 
188
  if not ids:
189
  return []
190
 
191
- # fetch the actual abstract text for those IDs
192
  handle = Entrez.efetch(
193
  db="pubmed",
194
  id=ids,
@@ -196,10 +160,8 @@ def fetch_and_retrieve(query, top_k=3):
196
  retmode="text"
197
  )
198
 
 
199
  raw_text = handle.read()
200
-
201
- # PubMed returns everything as one big blob of text
202
- # split on double newlines and filter out the short header/footer chunks
203
  abstracts = [
204
  chunk.strip()
205
  for chunk in raw_text.split("\n\n")
@@ -209,13 +171,12 @@ def fetch_and_retrieve(query, top_k=3):
209
  if not abstracts:
210
  return []
211
 
212
- # embed all abstracts and build a FAISS index on the fly
213
- # yes, we rebuild the index every time β€” it's fast enough and keeps things simple
214
  embeddings = embed_model.encode(abstracts)
215
  index = faiss.IndexFlatL2(embeddings.shape[1])
216
  index.add(np.array(embeddings))
217
 
218
- # find the top_k abstracts closest to our query in embedding space
219
  query_embedding = embed_model.encode([query])
220
  _, best_indices = index.search(
221
  np.array(query_embedding),
@@ -225,26 +186,26 @@ def fetch_and_retrieve(query, top_k=3):
225
  return [abstracts[i] for i in best_indices[0]]
226
 
227
  except Exception as e:
228
- # PubMed goes down sometimes, internet is flaky on HF Spaces
229
- # just return empty and let the pipeline continue without evidence
230
  print(f"PubMed fetch failed for '{query}': {e}")
231
  return []
232
 
233
 
234
- # ── Agent 1: Radiologist ──────────────────────────────────────────────
235
 
236
  def radiologist_agent(image, notes):
237
- # looks at the image and returns what it sees
238
- # deliberately kept separate from the internist β€” we don't want them anchoring each other
 
 
239
 
240
  if not image:
241
- # no image is fine β€” the internist and devil's advocate can still run
242
  return {
243
  "suspected_conditions": [],
244
  "note": "No image provided β€” skipping radiology analysis"
245
  }
246
 
247
- # MedGemma needs RGB β€” grayscale X-rays need to be converted
248
  if image.mode != "RGB":
249
  image = image.convert("RGB")
250
 
@@ -261,11 +222,14 @@ Return only the JSON object, no extra explanation."""
261
  return safe_json(call_medgemma(prompt, image))
262
 
263
 
264
- # ── Agent 2: Internist ────────────────────────────────────────────────
265
 
266
  def internist_agent(notes):
267
- # works from text only β€” never sees the image
268
- # this is intentional: we want independent reasoning, not anchoring off the radiologist
 
 
 
269
 
270
  prompt = f"""You are an experienced internal medicine physician.
271
  Patient clinical notes: {notes}
@@ -279,37 +243,41 @@ Return only the JSON object, no extra explanation."""
279
  return safe_json(call_medgemma(prompt))
280
 
281
 
282
- # ── Agent 3: Evidence Reviewer ────────────────────────────────────────
283
 
284
  def evidence_agent(r1, r2):
285
- # doesn't do any diagnosing β€” just fetches literature relevant to what the other agents found
286
- # the goal is to ground the Devil's Advocate and Orchestrator in actual published research,
287
- # not just what's in MedGemma's training data
 
 
288
 
289
- # combine top suspects from both agents into search queries
290
  queries = (
291
  r1.get("suspected_conditions", [])[:2] +
292
  r2.get("differential_diagnoses", [])[:2]
293
  )
294
 
 
295
  evidence = []
296
  for query in queries:
297
  results = fetch_and_retrieve(str(query), top_k=2)
298
  evidence.extend(results)
299
 
300
- # cap at 4 β€” more than that starts overflowing the prompt context window
301
  return evidence[:4]
302
 
303
 
304
- # ── Agent 4: Devil's Advocate ─────────────────────────────────────────
305
 
306
  def devils_advocate_agent(image, notes, r1, r2, evidence):
307
- # this is the one that matters most
308
- # its only job is to look at what everyone else concluded and find what they missed
309
- # dangerous diagnoses, rare conditions, overlooked red flags
310
- # it sees all the other agents' outputs β€” that's the point, it needs context to challenge them
 
311
 
312
- # truncate evidence so we don't blow up the prompt
313
  evidence_snippet = "\n".join(evidence[:2]) if evidence else "None available"
314
 
315
  prompt = f"""You are a critical care specialist and patient safety advocate.
@@ -328,20 +296,20 @@ Return a JSON object with:
328
  - requires_human_review: true or false
329
  Return only the JSON object, no extra explanation."""
330
 
331
- # give it the image too β€” it might catch something the radiologist missed
332
  if image and image.mode != "RGB":
333
  image = image.convert("RGB")
334
 
335
  return safe_json(call_medgemma(prompt, image))
336
 
337
 
338
- # ── Orchestrator ──────────────────────────────────────────────────────
339
 
340
  def orchestrator_agent(notes, r1, r2, evidence, devil):
341
- # reads everything the other agents produced and makes the final call
342
- # primary diagnosis, escalation decision, next steps, patient summary β€” all here
343
- # gets 1000 tokens because it has the longest prompt and we learned the hard way
344
- # that 400 tokens cuts off mid-JSON and produces a blank report
345
 
346
  prompt = f"""You are the lead physician synthesizing a multi-specialist panel review.
347
  RADIOLOGIST findings:
@@ -362,38 +330,41 @@ Synthesize everything into a final clinical report as a JSON object with:
362
  - patient_summary: 2-sentence plain English summary for the patient
363
  Return only the JSON object, no extra explanation."""
364
 
365
- return safe_json(call_medgemma(prompt, max_tokens=1000))
366
 
367
 
368
- # ── Master Pipeline ───────────────────────────────────────────────────
369
 
370
  def run_medpanel(image, notes):
371
- # runs all 5 agents in sequence and returns the full trace + final report
372
- # this is the only function app.py needs to call
 
 
 
373
 
374
  trace = []
375
 
376
- # radiologist first β€” image analysis, independent of everything else
377
  print("🩻 Running Radiologist agent...")
378
  r1 = radiologist_agent(image, notes)
379
  trace.append({"agent": "Radiologist", "output": r1})
380
 
381
- # internist next β€” clinical notes only, never sees the image
382
  print("🩺 Running Internist agent...")
383
  r2 = internist_agent(notes)
384
  trace.append({"agent": "Internist", "output": r2})
385
 
386
- # evidence reviewer β€” fetches PubMed literature based on what the first two found
387
  print("πŸ“š Fetching PubMed evidence...")
388
  evidence = evidence_agent(r1, r2)
389
  trace.append({"agent": "Evidence Reviewer", "abstracts_retrieved": len(evidence)})
390
 
391
- # devil's advocate β€” sees everything and tries to find what was missed
392
  print("😈 Running Devil's Advocate agent...")
393
  devil = devils_advocate_agent(image, notes, r1, r2, evidence)
394
  trace.append({"agent": "Devil's Advocate", "output": devil})
395
 
396
- # orchestrator β€” synthesizes all 4 outputs into the final report
397
  print("πŸ₯ Synthesizing final report...")
398
  final_report = orchestrator_agent(notes, r1, r2, evidence, devil)
399
  trace.append({"agent": "Orchestrator", "output": final_report})
@@ -401,6 +372,6 @@ def run_medpanel(image, notes):
401
  print("βœ… MedPanel analysis complete!")
402
 
403
  return {
404
- "panel_trace": trace, # full agent-by-agent breakdown for the trace tab
405
- "final_report": final_report # what actually shows up in the report tab
406
  }
 
1
  # medpanel.py
2
+ # Core logic for the MedPanel multi-agent diagnostic system.
3
+ # This file contains all 4 agents + orchestrator + RAG pipeline.
4
+ # Imported by app.py which runs the Gradio interface on HuggingFace Spaces.
5
 
6
  import os
7
  import json
 
16
  from PIL import Image
17
 
18
 
19
+ # ── Model Configuration ──────────────────────────────────────────────
20
+ # We load these once at startup so they're ready for every request
21
  MODEL_ID = "google/medgemma-4b-it"
22
 
23
+ # NCBI requires an email for PubMed access β€” just for identification purposes
24
  Entrez.email = "medpanel@example.com"
25
 
 
 
 
 
 
 
26
 
27
+ # ── Load Models ──────────────────────────────────────────────────────
28
 
29
  def load_models():
30
+ """
31
+ Loads MedGemma and the PubMed embedding model into memory.
32
+ Called once when the app starts up on HuggingFace Spaces.
33
+ Returns processor, model, and embed_model.
34
+ """
35
 
36
  print("Loading MedGemma model...")
37
 
38
+ # Load the processor β€” handles both text tokenization and image preprocessing
 
39
  processor = AutoProcessor.from_pretrained(
40
  MODEL_ID,
41
  token=os.environ.get("HF_TOKEN")
42
+
43
  )
44
 
45
+ # Load MedGemma in bfloat16 to fit within GPU memory limits
 
 
46
  model = AutoModelForImageTextToText.from_pretrained(
47
  MODEL_ID,
48
+ torch_dtype=torch.bfloat16,
49
+ device_map="auto",
50
  token=os.environ.get("HF_TOKEN"),
51
+ low_cpu_mem_usage=True,
52
+ attn_implementation="eager"
53
  )
54
  model.eval()
 
 
 
 
 
 
 
 
55
  print("βœ… MedGemma loaded!")
56
 
57
+ # Load the PubMed-specific embedding model for semantic search
 
58
  print("Loading PubMed embedding model...")
59
  embed_model = SentenceTransformer("pritamdeka/S-PubMedBert-MS-MARCO")
60
  print("βœ… Embedding model loaded!")
 
62
  return processor, model, embed_model
63
 
64
 
65
+ # Initialize all models at module load time
66
  processor, model, embed_model = load_models()
67
 
68
 
69
+ # ── Base Caller ──────────────────────────────────────────────────────
70
 
71
  def call_medgemma(prompt, image=None, max_tokens=400):
72
+ """
73
+ Sends a prompt (and optional image) to MedGemma and returns the response.
74
+ This is the single point of contact with the model for all agents.
75
+ """
76
 
77
+ # Build message in MedGemma's expected chat format
78
  messages = [
79
  {
80
  "role": "user",
81
  "content": [
82
  {"type": "text", "text": prompt},
 
83
  *([{"type": "image", "image": image}] if image else [])
84
  ]
85
  }
86
  ]
87
 
88
+ # Tokenize and move to the same device as the model
89
  inputs = processor.apply_chat_template(
90
  messages,
91
  add_generation_prompt=True,
92
  tokenize=True,
93
  return_dict=True,
94
  return_tensors="pt"
95
+ ).to(model.device)
 
 
 
 
 
96
 
97
+ # Generate response β€” no_grad saves memory, do_sample=False is deterministic
98
  with torch.no_grad():
99
  output_tokens = model.generate(
100
  **inputs,
101
  max_new_tokens=max_tokens,
102
+ do_sample=False
 
 
 
 
 
103
  )
104
 
105
+ # Decode and strip the echoed prompt β€” we only want the model's reply
106
+ full_response = processor.decode(output_tokens[0], skip_special_tokens=True)
107
+ return full_response.split("model\n")[-1].strip()
 
 
 
 
 
108
 
109
 
110
  def safe_json(text):
111
+ """
112
+ Safely extracts a JSON object from the model's response.
113
+ Handles markdown code fences, extra text, and malformed JSON.
114
+ Always returns a dict β€” never crashes.
115
+ """
116
 
117
+ # Strip markdown fences like ```json ... ``` if present
 
 
 
118
  for fence_start, fence_end in [("```json", "```"), ("```", "```")]:
119
  if fence_start in text:
120
  text = text.split(fence_start)[1].split(fence_end)[0].strip()
121
  break
122
 
123
+ # Try standard JSON parsing first
124
  try:
125
  return json.loads(text)
126
  except json.JSONDecodeError:
127
  pass
128
 
129
+ # Fall back to regex β€” find any { ... } block in the response
 
 
 
 
 
 
 
 
 
 
 
130
  json_match = re.search(r'\{.*\}', text, re.DOTALL)
131
  try:
132
  return json.loads(json_match.group()) if json_match else {"raw_response": text}
133
  except json.JSONDecodeError:
 
134
  return {"raw_response": text}
135
 
136
 
137
  # ── PubMed RAG ───────────────────────────────────────────────────────
138
 
139
  def fetch_and_retrieve(query, top_k=3):
140
+ """
141
+ Searches PubMed for relevant abstracts using the given query.
142
+ Uses FAISS + PubMedBERT embeddings to find the most semantically
143
+ similar abstracts rather than just keyword matching.
144
+ Returns a list of abstract strings.
145
+ """
146
 
147
  try:
148
+ # Search PubMed for matching paper IDs
149
  handle = Entrez.esearch(db="pubmed", term=query, retmax=8)
150
  ids = Entrez.read(handle)["IdList"]
151
 
152
  if not ids:
153
  return []
154
 
155
+ # Fetch the actual abstract text for those papers
156
  handle = Entrez.efetch(
157
  db="pubmed",
158
  id=ids,
 
160
  retmode="text"
161
  )
162
 
163
+ # Split the bulk text into individual abstracts, filter out short chunks
164
  raw_text = handle.read()
 
 
 
165
  abstracts = [
166
  chunk.strip()
167
  for chunk in raw_text.split("\n\n")
 
171
  if not abstracts:
172
  return []
173
 
174
+ # Build FAISS index from abstract embeddings
 
175
  embeddings = embed_model.encode(abstracts)
176
  index = faiss.IndexFlatL2(embeddings.shape[1])
177
  index.add(np.array(embeddings))
178
 
179
+ # Find the top_k most relevant abstracts for our query
180
  query_embedding = embed_model.encode([query])
181
  _, best_indices = index.search(
182
  np.array(query_embedding),
 
186
  return [abstracts[i] for i in best_indices[0]]
187
 
188
  except Exception as e:
189
+ # If PubMed is unavailable, return empty rather than crashing
 
190
  print(f"PubMed fetch failed for '{query}': {e}")
191
  return []
192
 
193
 
194
+ # ── Agent 1: Radiologist ─────────────────────────────────────────────
195
 
196
  def radiologist_agent(image, notes):
197
+ """
198
+ Analyzes the medical image and returns structured radiology findings.
199
+ If no image is provided, returns a safe empty result.
200
+ """
201
 
202
  if not image:
 
203
  return {
204
  "suspected_conditions": [],
205
  "note": "No image provided β€” skipping radiology analysis"
206
  }
207
 
208
+ # Convert to RGB if the image is grayscale β€” MedGemma requires RGB
209
  if image.mode != "RGB":
210
  image = image.convert("RGB")
211
 
 
222
  return safe_json(call_medgemma(prompt, image))
223
 
224
 
225
+ # ── Agent 2: Internist ───────────────────────────────────────────────
226
 
227
  def internist_agent(notes):
228
+ """
229
+ Analyzes clinical notes as an internal medicine physician.
230
+ Returns differential diagnoses, risk factors, and urgency level.
231
+ Works from text only β€” no image.
232
+ """
233
 
234
  prompt = f"""You are an experienced internal medicine physician.
235
  Patient clinical notes: {notes}
 
243
  return safe_json(call_medgemma(prompt))
244
 
245
 
246
+ # ── Agent 3: Evidence Reviewer ───────────────────────────────────────
247
 
248
  def evidence_agent(r1, r2):
249
+ """
250
+ Fetches supporting medical literature from PubMed based on what
251
+ the Radiologist and Internist suspected.
252
+ Returns up to 4 relevant abstracts.
253
+ """
254
 
255
+ # Combine top conditions from both agents into search queries
256
  queries = (
257
  r1.get("suspected_conditions", [])[:2] +
258
  r2.get("differential_diagnoses", [])[:2]
259
  )
260
 
261
+ # Search PubMed for each condition and collect abstracts
262
  evidence = []
263
  for query in queries:
264
  results = fetch_and_retrieve(str(query), top_k=2)
265
  evidence.extend(results)
266
 
267
+ # Cap at 4 to avoid overflowing the model's context window
268
  return evidence[:4]
269
 
270
 
271
+ # ── Agent 4: Devil's Advocate ────────────────────────────────────────
272
 
273
  def devils_advocate_agent(image, notes, r1, r2, evidence):
274
+ """
275
+ Adversarial agent that challenges the other agents' conclusions.
276
+ Specifically looks for dangerous diagnoses that were missed.
277
+ This is the agent that catches TB when base MedGemma misses it.
278
+ """
279
 
280
+ # Short evidence snippet so we don't overflow the prompt
281
  evidence_snippet = "\n".join(evidence[:2]) if evidence else "None available"
282
 
283
  prompt = f"""You are a critical care specialist and patient safety advocate.
 
296
  - requires_human_review: true or false
297
  Return only the JSON object, no extra explanation."""
298
 
299
+ # Pass image if available so the devil's advocate can see it too
300
  if image and image.mode != "RGB":
301
  image = image.convert("RGB")
302
 
303
  return safe_json(call_medgemma(prompt, image))
304
 
305
 
306
+ # ── Orchestrator ─────────────────────────────────────────────────────
307
 
308
  def orchestrator_agent(notes, r1, r2, evidence, devil):
309
+ """
310
+ Synthesizes all four agents' outputs into a single final report.
311
+ Decides on the primary diagnosis, confidence, escalation, and next steps.
312
+ """
313
 
314
  prompt = f"""You are the lead physician synthesizing a multi-specialist panel review.
315
  RADIOLOGIST findings:
 
330
  - patient_summary: 2-sentence plain English summary for the patient
331
  Return only the JSON object, no extra explanation."""
332
 
333
+ return safe_json(call_medgemma(prompt))
334
 
335
 
336
+ # ── Master Pipeline ──────────────────────────────────────────────────
337
 
338
  def run_medpanel(image, notes):
339
+ """
340
+ Runs the full MedPanel multi-agent pipeline.
341
+ Accepts a PIL image (or None) and a string of clinical notes.
342
+ Returns a dict with panel_trace (each agent's output) and final_report.
343
+ """
344
 
345
  trace = []
346
 
347
+ # Step 1: Radiologist β€” analyze the image
348
  print("🩻 Running Radiologist agent...")
349
  r1 = radiologist_agent(image, notes)
350
  trace.append({"agent": "Radiologist", "output": r1})
351
 
352
+ # Step 2: Internist β€” analyze the clinical notes
353
  print("🩺 Running Internist agent...")
354
  r2 = internist_agent(notes)
355
  trace.append({"agent": "Internist", "output": r2})
356
 
357
+ # Step 3: Evidence Reviewer β€” fetch PubMed literature
358
  print("πŸ“š Fetching PubMed evidence...")
359
  evidence = evidence_agent(r1, r2)
360
  trace.append({"agent": "Evidence Reviewer", "abstracts_retrieved": len(evidence)})
361
 
362
+ # Step 4: Devil's Advocate β€” challenge the findings
363
  print("😈 Running Devil's Advocate agent...")
364
  devil = devils_advocate_agent(image, notes, r1, r2, evidence)
365
  trace.append({"agent": "Devil's Advocate", "output": devil})
366
 
367
+ # Step 5: Orchestrator β€” synthesize the final report
368
  print("πŸ₯ Synthesizing final report...")
369
  final_report = orchestrator_agent(notes, r1, r2, evidence, devil)
370
  trace.append({"agent": "Orchestrator", "output": final_report})
 
372
  print("βœ… MedPanel analysis complete!")
373
 
374
  return {
375
+ "panel_trace": trace,
376
+ "final_report": final_report
377
  }