Update app.py
Browse files
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(
|
| 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 (
|
| 225 |
# =========================
|
| 226 |
@function_tool
|
| 227 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
"""
|
| 229 |
Deterministic education-only bullets based on free-text device description.
|
| 230 |
"""
|
| 231 |
-
d = (
|
| 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 |
-
#
|
| 266 |
# =========================
|
| 267 |
-
|
| 268 |
-
"You are
|
| 269 |
-
"STRICT SCOPE: Education-only
|
| 270 |
-
"
|
| 271 |
-
"
|
| 272 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
"2) Likely Causes (ranked)\n"
|
| 274 |
-
"3) Step-by-Step Checks (
|
| 275 |
-
"4) QC/Calibration
|
| 276 |
-
"5) Escalate When
|
| 277 |
-
"End with a one-line summary
|
| 278 |
-
"
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
)
|
| 281 |
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
user_block = f"Device & symptom:\n{user_desc.strip()}"
|
| 284 |
if manual_excerpts:
|
| 285 |
-
user_block += f"\n\nManual excerpts
|
| 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
|
| 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
|
|
|
|
| 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 |
-
|
| 367 |
-
|
| 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 |
-
#
|
| 384 |
manual = cl.user_session.get("manual")
|
|
|
|
| 385 |
excerpts = ""
|
| 386 |
if manual and manual.get("pages"):
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
|
|
|
| 390 |
|
| 391 |
-
|
| 392 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 398 |
-
f"**Common Faults**\n{bullets(
|
| 399 |
-
f"**Quick Checks**\n{bullets(
|
| 400 |
-
f"**QC / Calibration**\n{bullets(
|
| 401 |
-
f"**Escalate If**\n{bullets(
|
| 402 |
f"> ⚠️ Education-only. Refer to OEM manual & policy. No invasive service.\n"
|
| 403 |
)
|
| 404 |
|
| 405 |
-
|
| 406 |
-
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|