ibadhasnain commited on
Commit
247cb5b
·
verified ·
1 Parent(s): d35ecfe

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +124 -51
app.py CHANGED
@@ -1,4 +1,4 @@
1
- # app.py — Biomedical Device Troubleshooting Assistant (Education-only)
2
  # Self-contained: no external 'agents' package needed.
3
 
4
  import os, io, re, json
@@ -74,7 +74,7 @@ class Runner:
74
  tools = agent.tool_specs()
75
  tool_map = {t.__name__: t for t in (agent.tools or []) if getattr(t, "_is_tool", False)}
76
 
77
- for _ in range(4):
78
  resp = await agent.model.client.chat.completions.create(
79
  model=agent.model.model,
80
  messages=msgs,
@@ -140,7 +140,7 @@ llm_model = OpenAIChatCompletionsModel(model=MODEL_ID, openai_client=ext_client)
140
  # =========================
141
  # Topic/safety guardrails
142
  # =========================
143
- ALLOWED_COMMANDS = ("/help", "/policy", "/manual", "/clear", "/hits")
144
 
145
  TOPIC_KEYWORDS = [
146
  "biomedical","biomed","device","equipment","oem","service manual",
@@ -213,22 +213,34 @@ def manual_hits(pages, query: str, topk: int = 3):
213
  start = max(0, min([i for i in idxs if i >= 0], default=0) - window)
214
  end = min(len(t), start + 2*window)
215
  snippet = re.sub(r"\s+", " ", t[start:end]).strip()
216
- # basic PHI redaction
217
  snippet = RE_EMAIL.sub("[REDACTED_EMAIL]", snippet)
218
  snippet = RE_PHONE.sub("[REDACTED_PHONE]", snippet)
219
  snippet = RE_IDHINT.sub("[ID]", snippet)
220
  return snippet
221
  return [f"[p.{h['page']}] {excerpt(h.get('text',''))}" for h in hits]
222
 
 
 
 
 
 
223
  # =========================
224
- # Tools (deterministic helpers)
225
  # =========================
226
  @function_tool
227
- def quick_reference(device_desc: str) -> dict:
 
 
 
 
 
 
 
 
228
  """
229
  Deterministic education-only bullets based on free-text device description.
230
  """
231
- d = (device_desc or "").lower()
232
  life_critical = any(k in d for k in ["ventilator","defibrillator","infusion pump"])
233
  return {
234
  "safety": [
@@ -261,34 +273,71 @@ def quick_reference(device_desc: str) -> dict:
261
  ]
262
  }
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  # =========================
265
- # LLM troubleshooting plan
266
  # =========================
267
- SYSTEM_PROMPT = (
268
- "You are a biomedical device troubleshooting assistant for clinical engineers.\n"
269
- "STRICT SCOPE: Education-only device troubleshooting. No diagnosis/treatment, no invasive repair, no alarm bypass, no firmware hacks, "
270
- "no collection of personal identifiers. Defer to OEM manuals and local policy if any conflict.\n\n"
271
- "Given a device description and symptom, produce concise bullet lists with exactly these sections:\n"
272
- "1) Safety First (non-invasive, patient-first)\n"
 
 
 
 
273
  "2) Likely Causes (ranked)\n"
274
- "3) Step-by-Step Checks (do-not-open device; do-not-bypass alarms)\n"
275
- "4) QC/Calibration (what to verify and with what reference/simulator)\n"
276
- "5) Escalate When (clear triggers)\n"
277
- "End with a one-line summary.\n"
278
- "If the text suggests a life-critical device (ventilator/defibrillator/infusion pump) and patient connected, start with: "
279
- "REMOVE FROM SERVICE & USE BACKUP — then proceed with safe checks off-patient."
 
 
 
 
 
 
280
  )
281
 
282
- async def call_llm(user_desc: str, manual_excerpts: str = "") -> str:
 
 
 
 
 
 
 
 
283
  user_block = f"Device & symptom:\n{user_desc.strip()}"
284
  if manual_excerpts:
285
- user_block += f"\n\nManual excerpts (for reference; OEM prevails if conflict):\n{manual_excerpts.strip()}"
286
  resp = await ext_client.client.chat.completions.create(
287
  model=MODEL_ID,
288
- messages=[
289
- {"role": "system", "content": SYSTEM_PROMPT},
290
- {"role": "user", "content": user_block},
291
- ],
292
  )
293
  return resp.choices[0].message.content or ""
294
 
@@ -296,10 +345,11 @@ async def call_llm(user_desc: str, manual_excerpts: str = "") -> str:
296
  # UI strings
297
  # =========================
298
  WELCOME = (
299
- "🛠️ **Biomedical Device Troubleshooting Assistant**\n"
300
  "Education-only. No diagnosis, no invasive service, no alarm bypass. OEM manual & policy rule the day.\n\n"
301
  "Type your **device & symptom** (e.g., “Infusion pump occlusion alarm”).\n"
302
- "Commands: **/manual** upload PDF/TXT, **/hits** show top manual matches, **/clear** remove manual, **/policy** view rules."
 
303
  )
304
  POLICY = (
305
  "🛡️ **Safety & Scope Policy**\n"
@@ -321,18 +371,26 @@ REFUSAL = (
321
  @cl.on_chat_start
322
  async def start():
323
  cl.user_session.set("manual", None)
 
 
324
  await cl.Message(content=WELCOME).send()
325
 
326
  @cl.on_message
327
  async def main(message: cl.Message):
328
  text = (message.content or "").strip()
 
329
 
330
  # Commands
331
- low = text.lower()
332
  if low.startswith("/help"):
333
  await cl.Message(content=WELCOME).send(); return
334
  if low.startswith("/policy"):
335
  await cl.Message(content=POLICY).send(); return
 
 
 
 
 
 
336
 
337
  if low.startswith("/manual"):
338
  files = await cl.AskFileMessage(
@@ -363,8 +421,8 @@ async def main(message: cl.Message):
363
  m = cl.user_session.get("manual")
364
  if not m or not m.get("pages"):
365
  await cl.Message(content="No manual attached. Use **/manual** to upload one.").send(); return
366
- hits = manual_hits(m["pages"], text.replace("/hits", "").strip() or "device fault error alarm", topk=3)
367
- if not hits: hits = ["No obvious matches — try different wording."]
368
  await cl.Message(content="### 🔎 Manual Matches\n" + "\n\n".join(hits)).send()
369
  return
370
 
@@ -380,34 +438,49 @@ async def main(message: cl.Message):
380
  await cl.Message(content=REFUSAL + "\n\n" + POLICY).send()
381
  return
382
 
383
- # Manual excerpts (optional)
384
  manual = cl.user_session.get("manual")
 
385
  excerpts = ""
386
  if manual and manual.get("pages"):
387
- query = text
388
- hits = manual_hits(manual["pages"], query, topk=3)
389
- excerpts = "\n".join(hits)
 
390
 
391
- # Deterministic quick reference (for reliability)
392
- ref = quick_reference(text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  def bullets(arr: List[str]): return "\n".join(f"- {x}" for x in (arr or [])) or "-"
394
 
395
  quick_md = (
396
  f"### 📘 Quick Reference\n"
397
- f"**Safety**\n{bullets(ref.get('safety'))}\n\n"
398
- f"**Common Faults**\n{bullets(ref.get('common_faults'))}\n\n"
399
- f"**Quick Checks**\n{bullets(ref.get('quick_checks'))}\n\n"
400
- f"**QC / Calibration**\n{bullets(ref.get('qc'))}\n\n"
401
- f"**Escalate If**\n{bullets(ref.get('escalate'))}\n\n"
402
  f"> ⚠️ Education-only. Refer to OEM manual & policy. No invasive service.\n"
403
  )
404
 
405
- # LLM troubleshooting plan
406
- try:
407
- answer = await call_llm(text, excerpts)
408
- except Exception as e:
409
- await cl.Message(content=f"{quick_md}\n---\n⚠️ LLM call failed: {e}").send()
410
- return
411
-
412
- prefix = f"📄 Using manual: **{manual['name']}**\n\n" if (manual and manual.get('name')) else ""
413
- await cl.Message(content=f"{quick_md}\n---\n{prefix}{answer or 'I couldn’t generate a plan.'}").send()
 
1
+ # app.py — Biomedical Device Troubleshooting Assistant (Education-only, Agentic)
2
  # Self-contained: no external 'agents' package needed.
3
 
4
  import os, io, re, json
 
74
  tools = agent.tool_specs()
75
  tool_map = {t.__name__: t for t in (agent.tools or []) if getattr(t, "_is_tool", False)}
76
 
77
+ for _ in range(6): # allow a few PLAN→ACT→OBSERVE loops
78
  resp = await agent.model.client.chat.completions.create(
79
  model=agent.model.model,
80
  messages=msgs,
 
140
  # =========================
141
  # Topic/safety guardrails
142
  # =========================
143
+ ALLOWED_COMMANDS = ("/help", "/policy", "/manual", "/clear", "/hits", "/agent", "/simple")
144
 
145
  TOPIC_KEYWORDS = [
146
  "biomedical","biomed","device","equipment","oem","service manual",
 
213
  start = max(0, min([i for i in idxs if i >= 0], default=0) - window)
214
  end = min(len(t), start + 2*window)
215
  snippet = re.sub(r"\s+", " ", t[start:end]).strip()
 
216
  snippet = RE_EMAIL.sub("[REDACTED_EMAIL]", snippet)
217
  snippet = RE_PHONE.sub("[REDACTED_PHONE]", snippet)
218
  snippet = RE_IDHINT.sub("[ID]", snippet)
219
  return snippet
220
  return [f"[p.{h['page']}] {excerpt(h.get('text',''))}" for h in hits]
221
 
222
+ # Keep manual in session; agent tools will read it live
223
+ def _current_manual():
224
+ m = cl.user_session.get("manual")
225
+ return (m or {}).get("pages", [])
226
+
227
  # =========================
228
+ # Tools (agent can call)
229
  # =========================
230
  @function_tool
231
+ def search_manual(query: str, topk: str = "3") -> dict:
232
+ """Search the uploaded manual (PDF/TXT) for the query. Returns {'hits': [snippets...]}. If no manual, hits=[]"""
233
+ pages = _current_manual()
234
+ k = int(topk) if str(topk).isdigit() else 3
235
+ hits = manual_hits(pages, query, topk=k) if pages else []
236
+ return {"hits": hits}
237
+
238
+ @function_tool
239
+ def quick_reference(device_and_symptom: str) -> dict:
240
  """
241
  Deterministic education-only bullets based on free-text device description.
242
  """
243
+ d = (device_and_symptom or "").lower()
244
  life_critical = any(k in d for k in ["ventilator","defibrillator","infusion pump"])
245
  return {
246
  "safety": [
 
273
  ]
274
  }
275
 
276
+ @function_tool
277
+ def safety_policy() -> dict:
278
+ """Return a concise policy block enforced by the assistant."""
279
+ return {
280
+ "policy": [
281
+ "Education-only troubleshooting; no clinical advice.",
282
+ "No invasive repairs, no alarm bypass, no firmware tampering.",
283
+ "No collecting/sharing personal identifiers.",
284
+ "OEM manuals and local policy take precedence."
285
+ ]
286
+ }
287
+
288
+ @function_tool
289
+ def record_step(note: str) -> dict:
290
+ """Append an action/observation to the agent trace shown to the user."""
291
+ trace: List[str] = cl.user_session.get("agent_trace") or []
292
+ clean = re.sub(r"\s+", " ", (note or "")).strip()
293
+ if clean:
294
+ trace.append(clean[:500])
295
+ cl.user_session.set("agent_trace", trace)
296
+ return {"ok": True, "len": len(trace)}
297
+
298
  # =========================
299
+ # Agentic Planner
300
  # =========================
301
+ PLANNER_INSTRUCTIONS = (
302
+ "You are an agentic biomedical device troubleshooting planner for clinical engineers.\n"
303
+ "STRICT SCOPE: Education-only. Do NOT give clinical advice, do NOT instruct invasive repairs, do NOT bypass alarms, "
304
+ "do NOT suggest firmware tampering. Respect policy.\n\n"
305
+ "Loop up to 3 times:\n"
306
+ "- PLAN: Decide what you need (e.g., search_manual for specific error, or quick_reference).\n"
307
+ "- ACT: Call a tool (search_manual, quick_reference, safety_policy, record_step).\n"
308
+ "- OBSERVE: Read tool result. Summarize via record_step.\n"
309
+ "Then produce a final plan with exactly these sections:\n"
310
+ "1) Safety First\n"
311
  "2) Likely Causes (ranked)\n"
312
+ "3) Step-by-Step Checks (non-invasive; no alarm bypass)\n"
313
+ "4) QC/Calibration\n"
314
+ "5) Escalate When\n"
315
+ "End with a one-line summary. If life-critical & patient-connected is implied, start with: "
316
+ "REMOVE FROM SERVICE & USE BACKUP then proceed off-patient.\n"
317
+ )
318
+
319
+ planner_agent = Agent(
320
+ name="Agentic Planner",
321
+ instructions=PLANNER_INSTRUCTIONS,
322
+ model=llm_model,
323
+ tools=[search_manual, quick_reference, safety_policy, record_step],
324
  )
325
 
326
+ # =========================
327
+ # Simple (non-agentic) LLM (fallback)
328
+ # =========================
329
+ SIMPLE_SYSTEM = (
330
+ "You are a biomedical device troubleshooting assistant (education-only). "
331
+ "Output sections: Safety First; Likely Causes; Step-by-Step Checks; QC/Calibration; Escalate When; one-line summary."
332
+ )
333
+
334
+ async def simple_llm_plan(user_desc: str, manual_excerpts: str = "") -> str:
335
  user_block = f"Device & symptom:\n{user_desc.strip()}"
336
  if manual_excerpts:
337
+ user_block += f"\n\nManual excerpts:\n{manual_excerpts.strip()}"
338
  resp = await ext_client.client.chat.completions.create(
339
  model=MODEL_ID,
340
+ messages=[{"role":"system","content":SIMPLE_SYSTEM},{"role":"user","content":user_block}],
 
 
 
341
  )
342
  return resp.choices[0].message.content or ""
343
 
 
345
  # UI strings
346
  # =========================
347
  WELCOME = (
348
+ "🛠️ **Biomedical Device Troubleshooting Assistant** (Agentic)\n"
349
  "Education-only. No diagnosis, no invasive service, no alarm bypass. OEM manual & policy rule the day.\n\n"
350
  "Type your **device & symptom** (e.g., “Infusion pump occlusion alarm”).\n"
351
+ "Commands: **/manual** upload PDF/TXT, **/hits** show top manual matches, **/clear** remove manual,\n"
352
+ "**/agent** agentic mode (default), **/simple** single-pass mode, **/policy** view rules."
353
  )
354
  POLICY = (
355
  "🛡️ **Safety & Scope Policy**\n"
 
371
  @cl.on_chat_start
372
  async def start():
373
  cl.user_session.set("manual", None)
374
+ cl.user_session.set("agent_mode", "agent") # 'agent' or 'simple'
375
+ cl.user_session.set("agent_trace", [])
376
  await cl.Message(content=WELCOME).send()
377
 
378
  @cl.on_message
379
  async def main(message: cl.Message):
380
  text = (message.content or "").strip()
381
+ low = text.lower()
382
 
383
  # Commands
 
384
  if low.startswith("/help"):
385
  await cl.Message(content=WELCOME).send(); return
386
  if low.startswith("/policy"):
387
  await cl.Message(content=POLICY).send(); return
388
+ if low.startswith("/agent"):
389
+ cl.user_session.set("agent_mode", "agent")
390
+ await cl.Message(content="✅ Agentic mode enabled (planner will Plan→Act→Observe).").send(); return
391
+ if low.startswith("/simple"):
392
+ cl.user_session.set("agent_mode", "simple")
393
+ await cl.Message(content="✅ Simple mode enabled (single LLM pass).").send(); return
394
 
395
  if low.startswith("/manual"):
396
  files = await cl.AskFileMessage(
 
421
  m = cl.user_session.get("manual")
422
  if not m or not m.get("pages"):
423
  await cl.Message(content="No manual attached. Use **/manual** to upload one.").send(); return
424
+ query = text.replace("/hits", "").strip() or "device fault error alarm"
425
+ hits = manual_hits(m["pages"], query, topk=3) or ["No obvious matches — try different wording."]
426
  await cl.Message(content="### 🔎 Manual Matches\n" + "\n\n".join(hits)).send()
427
  return
428
 
 
438
  await cl.Message(content=REFUSAL + "\n\n" + POLICY).send()
439
  return
440
 
441
+ # Optional manual context for the simple path
442
  manual = cl.user_session.get("manual")
443
+ manual_name = manual.get("name") if manual else None
444
  excerpts = ""
445
  if manual and manual.get("pages"):
446
+ excerpts = "\n".join(manual_hits(manual["pages"], text, topk=3))
447
+
448
+ mode = cl.user_session.get("agent_mode") or "agent"
449
+ trace_before = len(cl.user_session.get("agent_trace") or [])
450
 
451
+ if mode == "agent":
452
+ # Agentic Planner run
453
+ try:
454
+ # give planner the user text; it will call tools search_manual/quick_reference/record_step itself
455
+ result = await Runner.run(planner_agent, text)
456
+ answer = result.final_output or "I couldn’t generate a plan."
457
+ except Exception as e:
458
+ await cl.Message(content=f"⚠️ Planner failed: {e}\nFalling back to simple mode.").send()
459
+ answer = await simple_llm_plan(text, excerpts)
460
+ else:
461
+ # Simple single-pass
462
+ answer = await simple_llm_plan(text, excerpts)
463
+
464
+ # Show agent trace if any new steps were recorded
465
+ trace = cl.user_session.get("agent_trace") or []
466
+ new_trace = trace[trace_before:]
467
+ trace_md = ""
468
+ if new_trace:
469
+ trace_md = "### 🧭 Agent Trace (summary)\n" + "\n".join(f"- {t}" for t in new_trace) + "\n\n"
470
+
471
+ # Deterministic quick reference (always helpful)
472
+ qref = quick_reference(text)
473
  def bullets(arr: List[str]): return "\n".join(f"- {x}" for x in (arr or [])) or "-"
474
 
475
  quick_md = (
476
  f"### 📘 Quick Reference\n"
477
+ f"**Safety**\n{bullets(qref.get('safety'))}\n\n"
478
+ f"**Common Faults**\n{bullets(qref.get('common_faults'))}\n\n"
479
+ f"**Quick Checks**\n{bullets(qref.get('quick_checks'))}\n\n"
480
+ f"**QC / Calibration**\n{bullets(qref.get('qc'))}\n\n"
481
+ f"**Escalate If**\n{bullets(qref.get('escalate'))}\n\n"
482
  f"> ⚠️ Education-only. Refer to OEM manual & policy. No invasive service.\n"
483
  )
484
 
485
+ prefix = f"📄 Using manual: **{manual_name}**\n\n" if manual_name else ""
486
+ await cl.Message(content=f"{trace_md}{quick_md}\n---\n{prefix}{answer}").send()