ibadhasnain commited on
Commit
c837390
·
verified ·
1 Parent(s): bfb5ba6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +88 -56
app.py CHANGED
@@ -1,6 +1,7 @@
1
  # app.py — Biomedical Device Troubleshooting Assistant (Education-only, Agentic)
2
- # Minimal deps: chainlit==1.0.200, python-dotenv==1.0.1, openai==1.35.13
3
- # .env: GEMINI_API_KEY=... (preferred) OR OPENAI_API_KEY=...
 
4
 
5
  import os, re, json
6
  from dataclasses import dataclass, field
@@ -11,9 +12,9 @@ from dotenv import load_dotenv
11
  from openai import AsyncOpenAI as _SDKAsyncOpenAI
12
 
13
  # =========================
14
- # Small agents shim
15
  # =========================
16
- def set_tracing_disabled(_: bool = True): # placeholder (no-op)
17
  return True
18
 
19
  def function_tool(func: Callable):
@@ -25,10 +26,10 @@ class AsyncOpenAI:
25
  kwargs = {"api_key": api_key}
26
  if base_url:
27
  kwargs["base_url"] = base_url
28
- self._c = _SDKAsyncOpenAI(**kwargs)
29
  @property
30
  def client(self):
31
- return self._c
32
 
33
  class OpenAIChatCompletionsModel:
34
  def __init__(self, model: str, openai_client: AsyncOpenAI):
@@ -68,51 +69,87 @@ class Agent:
68
  return self._tool_specs
69
 
70
  class Runner:
 
71
  @staticmethod
72
- async def run(agent: Agent, user_input: str, context: Optional[Dict[str, Any]] = None, turns: int = 5):
 
 
 
 
 
 
73
  msgs = [
74
  {"role": "system", "content": agent.instructions},
 
 
 
 
 
 
 
 
75
  {"role": "user", "content": user_input},
76
  ]
 
77
  tool_specs = agent.tool_specs()
78
  tool_map = {t.__name__: t for t in (agent.tools or []) if getattr(t, "_is_tool", False)}
 
 
 
 
79
 
80
- for _ in range(max(1, min(8, turns))):
81
  resp = await agent.model.client.chat.completions.create(
82
  model=agent.model.model,
83
  messages=msgs,
84
- tools=tool_specs or None,
85
- tool_choice="auto" if tool_specs else None,
86
  )
87
  msg = resp.choices[0].message
88
- msgs.append({"role": "assistant", "content": msg.content or "", "tool_calls": msg.tool_calls})
89
-
90
- if msg.tool_calls:
91
- # Plan→Act→Observe
92
- for call in msg.tool_calls:
93
- fn_name = call.function.name
94
- args = json.loads(call.function.arguments or "{}")
95
- result = {"error": f"Unknown tool: {fn_name}"}
96
- tool = tool_map.get(fn_name)
97
- if tool:
98
- try: result = tool(**args)
99
- except Exception as e: result = {"error": str(e)}
100
- msgs.append({
101
- "role": "tool",
102
- "tool_call_id": call.id,
103
- "name": fn_name,
104
- "content": json.dumps(result),
105
- })
106
  continue
107
 
108
- out = type("Result", (), {})()
109
- out.final_output = msg.content or ""
110
- out.context = context or {}
111
- out.final_output_as = lambda *_: out.final_output
112
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
 
 
 
 
 
114
  out = type("Result", (), {})()
115
- out.final_output = "Sorry, I couldn't complete the request."
116
  out.context = context or {}
117
  out.final_output_as = lambda *_: out.final_output
118
  return out
@@ -178,7 +215,7 @@ def _bullets(items: List[str]) -> str:
178
  @function_tool
179
  def quick_reference(device_and_symptom: str) -> dict:
180
  """
181
- Generic, non-invasive reference for troubleshooting.
182
  """
183
  s = (device_and_symptom or "").lower()
184
  life = any(k in s for k in ["ventilator", "defibrillator", "infusion pump"])
@@ -197,13 +234,13 @@ def quick_reference(device_and_symptom: str) -> dict:
197
  ],
198
  "quick_checks": [
199
  "Note model/serial/firmware and any error codes.",
200
- "Verify power source/battery; try another outlet; reseat user-removable battery.",
201
  "Inspect & reseat accessories; swap with a known-good.",
202
  "Confirm settings; restore defaults if safe.",
203
- "Run device self-test and review OEM quick-reference."
204
  ],
205
  "qc": [
206
- "Verify against a reference/simulator where applicable (ECG, SpO₂, NIBP, flow).",
207
  "Perform electrical safety tests per policy.",
208
  "Document & compare with prior QC."
209
  ],
@@ -216,7 +253,7 @@ def quick_reference(device_and_symptom: str) -> dict:
216
 
217
  @function_tool
218
  def safety_policy() -> dict:
219
- """Concise policy enforced by the assistant."""
220
  return {"policy": [
221
  "Education-only troubleshooting; no clinical advice.",
222
  "No invasive repairs; no alarm bypass; no firmware tampering.",
@@ -236,13 +273,11 @@ def record_step(note: str) -> dict:
236
 
237
  @function_tool
238
  def error_code_lookup(model: str, code: str) -> dict:
239
- """
240
- Friendly generic notes for common codes (extendable).
241
- """
242
  key = f"{(model or '').strip().lower()}::{(code or '').strip().lower()}"
243
  KB = {
244
  "generic::e01": {"meaning": "General startup/self-test failure.",
245
- "notes": ["Safe power-cycle (off-patient). Check mains/battery, connections. Re-run self-test. Escalate if persistent."]},
246
  "generic::occlusion": {"meaning": "Flow obstruction detected.",
247
  "notes": ["Check clamps/kinks/filters. Reload set per OEM. Do not bypass alarms."]},
248
  "monitor::leads_off": {"meaning": "ECG leads not detected / poor contact.",
@@ -276,7 +311,7 @@ def acronym_expand(term: str) -> dict:
276
  @function_tool
277
  def unit_convert(value: str, from_unit: str, to_unit: str) -> dict:
278
  """
279
- Limited, safe conversions used in QC:
280
  mmHg↔kPa (1 kPa = 7.50062 mmHg) | L/min↔mL/s (1 L/min = 16.6667 mL/s) | mV↔µV (1 mV = 1000 µV)
281
  """
282
  def f(x): return float(str(x).strip())
@@ -300,7 +335,7 @@ def triage_priority(device_and_symptom: str) -> dict:
300
 
301
  @function_tool
302
  def simulator_baselines(device: str) -> dict:
303
- """Educational simulator targets (generic, not OEM-specific)."""
304
  d = (device or "").lower()
305
  bank = {
306
  "ecg": ["1 mV @ 10 mm/mV (paper 25 mm/s).", "Check lead-fault detection & baseline stability."],
@@ -314,7 +349,7 @@ def simulator_baselines(device: str) -> dict:
314
 
315
  @function_tool
316
  def env_checklist(_: str) -> dict:
317
- """Environment/power/EMI checklist for safe troubleshooting."""
318
  return {"checks": [
319
  "Verify mains outlet & ground; power indicator on.",
320
  "Avoid extension cords; try a known-good outlet.",
@@ -325,7 +360,7 @@ def env_checklist(_: str) -> dict:
325
 
326
  @function_tool
327
  def accessory_checklist(_: str) -> dict:
328
- """Generic accessory fit/damage/compatibility checklist."""
329
  return {"checks": [
330
  "Inspect connectors for bent pins/corrosion; ensure tight seating.",
331
  "Check cables/tubing for cuts/kinks; confirm model compatibility.",
@@ -335,7 +370,7 @@ def accessory_checklist(_: str) -> dict:
335
 
336
  @function_tool
337
  def build_qc_template(device: str) -> dict:
338
- """Markdown QC template students can copy into notes/CMMS (education-only)."""
339
  d = (device or "Device").strip()
340
  md = f"""### QC/Verification — {d}
341
  - Date / Tech:
@@ -357,11 +392,8 @@ def build_qc_template(device: str) -> dict:
357
  PLANNER_INSTRUCTIONS = (
358
  "You are an **agentic biomedical device troubleshooting planner** for clinical engineers.\n"
359
  "SCOPE (strict): education-only. No clinical advice. No invasive repairs. No alarm bypass. No firmware tampering. No PHI.\n"
360
- "Loop up to 3 times:\n"
361
- "- PLAN what you need (e.g., quick_reference, safety_policy, acronym_expand, error_code_lookup, unit_convert, triage_priority,\n"
362
- " simulator_baselines, env_checklist, accessory_checklist, build_qc_template).\n"
363
- "- ACT by calling a tool.\n"
364
- "- OBSERVE and add a short summary via record_step.\n\n"
365
  "Finally output exactly these sections:\n"
366
  "1) Safety First\n"
367
  "2) Likely Causes (ranked)\n"
@@ -417,7 +449,6 @@ REFUSAL = (
417
  "🚫 I can’t help with diagnosis/treatment, invasive repair, alarm bypass, firmware hacks, or handling personal identifiers.\n"
418
  "I can guide safe, non-invasive troubleshooting and educational QC steps."
419
  )
420
-
421
  TOOLS_LIST = (
422
  "🔧 **Available Tools**\n"
423
  "- quick_reference(text)\n"
@@ -475,9 +506,10 @@ async def on_message(msg: cl.Message):
475
  mode = cl.user_session.get("mode") or "agent"
476
  trace_before = len(cl.user_session.get("agent_trace") or [])
477
 
 
478
  if mode == "agent":
479
  try:
480
- result = await Runner.run(planner_agent, text, turns=5)
481
  answer = result.final_output or "I couldn’t generate a plan."
482
  except Exception as e:
483
  await cl.Message(content=f"⚠️ Planner error: {e}\nFalling back to simple mode.").send()
@@ -490,7 +522,7 @@ async def on_message(msg: cl.Message):
490
  new_trace = trace[trace_before:]
491
  trace_md = "### 🧭 Agent Trace (summary)\n" + "\n".join(f"- {t}" for t in new_trace) + "\n\n" if new_trace else ""
492
 
493
- # Deterministic quick reference (always helpful)
494
  qref = quick_reference(text)
495
  quick_md = (
496
  f"### 📘 Quick Reference\n"
 
1
  # app.py — Biomedical Device Troubleshooting Assistant (Education-only, Agentic)
2
+ # Deps: chainlit==1.0.200, python-dotenv==1.0.1, openai==1.35.13
3
+ # .env: GEMINI_API_KEY=... (preferred; uses OpenAI-compatible Gemini endpoint)
4
+ # OR OPENAI_API_KEY=...
5
 
6
  import os, re, json
7
  from dataclasses import dataclass, field
 
12
  from openai import AsyncOpenAI as _SDKAsyncOpenAI
13
 
14
  # =========================
15
+ # Minimal "agents" shim
16
  # =========================
17
+ def set_tracing_disabled(_: bool = True): # no-op placeholder
18
  return True
19
 
20
  def function_tool(func: Callable):
 
26
  kwargs = {"api_key": api_key}
27
  if base_url:
28
  kwargs["base_url"] = base_url
29
+ self._client = _SDKAsyncOpenAI(**kwargs)
30
  @property
31
  def client(self):
32
+ return self._client
33
 
34
  class OpenAIChatCompletionsModel:
35
  def __init__(self, model: str, openai_client: AsyncOpenAI):
 
69
  return self._tool_specs
70
 
71
  class Runner:
72
+ """Robust tool loop with forced finalization (prevents endless Plan→Act cycles)."""
73
  @staticmethod
74
+ async def run(
75
+ agent: Agent,
76
+ user_input: str,
77
+ context: Optional[Dict[str, Any]] = None,
78
+ turns: int = 7,
79
+ max_tools: int = 3,
80
+ ):
81
  msgs = [
82
  {"role": "system", "content": agent.instructions},
83
+ {
84
+ "role": "system",
85
+ "content": (
86
+ "You have access to these tools: "
87
+ + ", ".join(t.__name__ for t in (agent.tools or []) if getattr(t, "_is_tool", False))
88
+ + f". Use at most {max_tools} tool calls, then produce the final answer in the required sections."
89
+ ),
90
+ },
91
  {"role": "user", "content": user_input},
92
  ]
93
+
94
  tool_specs = agent.tool_specs()
95
  tool_map = {t.__name__: t for t in (agent.tools or []) if getattr(t, "_is_tool", False)}
96
+ tool_uses = 0
97
+
98
+ for step in range(max(1, min(12, turns))):
99
+ force_final = (tool_uses >= max_tools) or (step == turns - 1)
100
 
 
101
  resp = await agent.model.client.chat.completions.create(
102
  model=agent.model.model,
103
  messages=msgs,
104
+ tools=None if force_final else tool_specs,
105
+ tool_choice="none" if force_final else "auto",
106
  )
107
  msg = resp.choices[0].message
108
+ tool_calls = getattr(msg, "tool_calls", None)
109
+
110
+ # Normal completion (no tool calls) => return
111
+ if not tool_calls:
112
+ out = type("Result", (), {})()
113
+ out.final_output = (msg.content or "").strip()
114
+ out.context = context or {}
115
+ out.final_output_as = lambda *_: out.final_output
116
+ return out
117
+
118
+ # If we forced finalization but the model still tried tools, nudge and iterate
119
+ if force_final:
120
+ msgs.append({
121
+ "role": "system",
122
+ "content": "Do not call tools anymore. Produce the final answer now in the required sections."
123
+ })
 
 
124
  continue
125
 
126
+ # Execute tool calls
127
+ msgs.append({"role": "assistant", "content": msg.content or "", "tool_calls": tool_calls})
128
+ for call in tool_calls:
129
+ fn_name = call.function.name
130
+ args = json.loads(call.function.arguments or "{}")
131
+ if fn_name not in tool_map:
132
+ result = {"error": f"Unknown tool '{fn_name}'. Available: " + ", ".join(tool_map.keys())}
133
+ else:
134
+ try:
135
+ result = tool_map[fn_name](**args)
136
+ except Exception as e:
137
+ result = {"error": str(e)}
138
+ msgs.append({
139
+ "role": "tool",
140
+ "tool_call_id": call.id,
141
+ "name": fn_name,
142
+ "content": json.dumps(result),
143
+ })
144
+ tool_uses += 1
145
 
146
+ # One last attempt to finalize with tools disabled
147
+ msgs.append({"role": "system", "content": "Stop tool use. Provide the final answer now."})
148
+ resp = await agent.model.client.chat.completions.create(
149
+ model=agent.model.model, messages=msgs, tools=None, tool_choice="none"
150
+ )
151
  out = type("Result", (), {})()
152
+ out.final_output = (resp.choices[0].message.content or "I couldnt generate a plan.").strip()
153
  out.context = context or {}
154
  out.final_output_as = lambda *_: out.final_output
155
  return out
 
215
  @function_tool
216
  def quick_reference(device_and_symptom: str) -> dict:
217
  """
218
+ Generic, non-invasive reference for troubleshooting (education-only).
219
  """
220
  s = (device_and_symptom or "").lower()
221
  life = any(k in s for k in ["ventilator", "defibrillator", "infusion pump"])
 
234
  ],
235
  "quick_checks": [
236
  "Note model/serial/firmware and any error codes.",
237
+ "Verify power or battery; try another outlet; reseat user-removable battery.",
238
  "Inspect & reseat accessories; swap with a known-good.",
239
  "Confirm settings; restore defaults if safe.",
240
+ "Run self-test and review OEM quick-reference."
241
  ],
242
  "qc": [
243
+ "Verify against a reference/simulator (ECG, SpO₂, NIBP, flow) where applicable.",
244
  "Perform electrical safety tests per policy.",
245
  "Document & compare with prior QC."
246
  ],
 
253
 
254
  @function_tool
255
  def safety_policy() -> dict:
256
+ """Concise policy block enforced by the assistant."""
257
  return {"policy": [
258
  "Education-only troubleshooting; no clinical advice.",
259
  "No invasive repairs; no alarm bypass; no firmware tampering.",
 
273
 
274
  @function_tool
275
  def error_code_lookup(model: str, code: str) -> dict:
276
+ """Friendly generic notes for common codes (extendable)."""
 
 
277
  key = f"{(model or '').strip().lower()}::{(code or '').strip().lower()}"
278
  KB = {
279
  "generic::e01": {"meaning": "General startup/self-test failure.",
280
+ "notes": ["Safe power-cycle (off-patient). Check mains/battery & connections. Re-run self-test. Escalate if persistent."]},
281
  "generic::occlusion": {"meaning": "Flow obstruction detected.",
282
  "notes": ["Check clamps/kinks/filters. Reload set per OEM. Do not bypass alarms."]},
283
  "monitor::leads_off": {"meaning": "ECG leads not detected / poor contact.",
 
311
  @function_tool
312
  def unit_convert(value: str, from_unit: str, to_unit: str) -> dict:
313
  """
314
+ QC-friendly conversions:
315
  mmHg↔kPa (1 kPa = 7.50062 mmHg) | L/min↔mL/s (1 L/min = 16.6667 mL/s) | mV↔µV (1 mV = 1000 µV)
316
  """
317
  def f(x): return float(str(x).strip())
 
335
 
336
  @function_tool
337
  def simulator_baselines(device: str) -> dict:
338
+ """Educational simulator targets (generic; not OEM-specific)."""
339
  d = (device or "").lower()
340
  bank = {
341
  "ecg": ["1 mV @ 10 mm/mV (paper 25 mm/s).", "Check lead-fault detection & baseline stability."],
 
349
 
350
  @function_tool
351
  def env_checklist(_: str) -> dict:
352
+ """Environment/power/EMI checklist (safe)."""
353
  return {"checks": [
354
  "Verify mains outlet & ground; power indicator on.",
355
  "Avoid extension cords; try a known-good outlet.",
 
360
 
361
  @function_tool
362
  def accessory_checklist(_: str) -> dict:
363
+ """Accessory fit/damage/compatibility checklist."""
364
  return {"checks": [
365
  "Inspect connectors for bent pins/corrosion; ensure tight seating.",
366
  "Check cables/tubing for cuts/kinks; confirm model compatibility.",
 
370
 
371
  @function_tool
372
  def build_qc_template(device: str) -> dict:
373
+ """Markdown QC template for notes/CMMS (education-only)."""
374
  d = (device or "Device").strip()
375
  md = f"""### QC/Verification — {d}
376
  - Date / Tech:
 
392
  PLANNER_INSTRUCTIONS = (
393
  "You are an **agentic biomedical device troubleshooting planner** for clinical engineers.\n"
394
  "SCOPE (strict): education-only. No clinical advice. No invasive repairs. No alarm bypass. No firmware tampering. No PHI.\n"
395
+ "Use at most **3 tool calls** and then **produce the final answer with no further tool calls**.\n"
396
+ "Loop up to 3 times: PLAN ACT (tool) OBSERVE (summarize via record_step).\n\n"
 
 
 
397
  "Finally output exactly these sections:\n"
398
  "1) Safety First\n"
399
  "2) Likely Causes (ranked)\n"
 
449
  "🚫 I can’t help with diagnosis/treatment, invasive repair, alarm bypass, firmware hacks, or handling personal identifiers.\n"
450
  "I can guide safe, non-invasive troubleshooting and educational QC steps."
451
  )
 
452
  TOOLS_LIST = (
453
  "🔧 **Available Tools**\n"
454
  "- quick_reference(text)\n"
 
506
  mode = cl.user_session.get("mode") or "agent"
507
  trace_before = len(cl.user_session.get("agent_trace") or [])
508
 
509
+ # Generate answer
510
  if mode == "agent":
511
  try:
512
+ result = await Runner.run(planner_agent, text, turns=7, max_tools=3)
513
  answer = result.final_output or "I couldn’t generate a plan."
514
  except Exception as e:
515
  await cl.Message(content=f"⚠️ Planner error: {e}\nFalling back to simple mode.").send()
 
522
  new_trace = trace[trace_before:]
523
  trace_md = "### 🧭 Agent Trace (summary)\n" + "\n".join(f"- {t}" for t in new_trace) + "\n\n" if new_trace else ""
524
 
525
+ # Deterministic quick reference
526
  qref = quick_reference(text)
527
  quick_md = (
528
  f"### 📘 Quick Reference\n"