Update app.py
Browse files
app.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
# app.py — Biomedical Device Troubleshooting Assistant (Education-only, Agentic)
|
| 2 |
-
#
|
| 3 |
-
# .env: GEMINI_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 |
-
#
|
| 15 |
# =========================
|
| 16 |
-
def set_tracing_disabled(_: bool = True): #
|
| 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.
|
| 29 |
@property
|
| 30 |
def client(self):
|
| 31 |
-
return self.
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 85 |
-
tool_choice="
|
| 86 |
)
|
| 87 |
msg = resp.choices[0].message
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
"content": json.dumps(result),
|
| 105 |
-
})
|
| 106 |
continue
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
out = type("Result", (), {})()
|
| 115 |
-
out.final_output =
|
| 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
|
| 201 |
"Inspect & reseat accessories; swap with a known-good.",
|
| 202 |
"Confirm settings; restore defaults if safe.",
|
| 203 |
-
"Run
|
| 204 |
],
|
| 205 |
"qc": [
|
| 206 |
-
"Verify against a reference/simulator
|
| 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
|
| 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 |
-
|
| 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
|
| 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
|
| 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 |
-
"""
|
| 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
|
| 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 |
-
"
|
| 361 |
-
"
|
| 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=
|
| 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
|
| 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 couldn’t 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"
|