Update app.py
Browse files
app.py
CHANGED
|
@@ -1,13 +1,18 @@
|
|
| 1 |
-
# app.py —
|
| 2 |
-
|
|
|
|
|
|
|
| 3 |
from typing import Any, Callable, Dict, List, Optional
|
| 4 |
from dataclasses import dataclass, field
|
| 5 |
|
| 6 |
import chainlit as cl
|
| 7 |
from dotenv import load_dotenv
|
| 8 |
from openai import AsyncOpenAI as _SDKAsyncOpenAI
|
|
|
|
| 9 |
|
| 10 |
-
#
|
|
|
|
|
|
|
| 11 |
def set_tracing_disabled(disabled: bool = True):
|
| 12 |
return disabled
|
| 13 |
|
|
@@ -97,7 +102,7 @@ class Runner:
|
|
| 97 |
})
|
| 98 |
continue
|
| 99 |
|
| 100 |
-
# Final
|
| 101 |
result_obj = type("Result", (), {})()
|
| 102 |
result_obj.final_output = msg.content or ""
|
| 103 |
result_obj.context = context or {}
|
|
@@ -110,7 +115,9 @@ class Runner:
|
|
| 110 |
result_obj.final_output_as = lambda *_: result_obj.final_output
|
| 111 |
return result_obj
|
| 112 |
|
| 113 |
-
#
|
|
|
|
|
|
|
| 114 |
load_dotenv()
|
| 115 |
GEMINI_API_KEY = os.getenv("Gem")
|
| 116 |
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
|
@@ -124,145 +131,32 @@ elif OPENAI_API_KEY:
|
|
| 124 |
BASE_URL = None
|
| 125 |
MODEL_ID = "gpt-4o-mini"
|
| 126 |
else:
|
| 127 |
-
raise RuntimeError("Missing GEMINI_API_KEY or OPENAI_API_KEY in
|
| 128 |
|
| 129 |
set_tracing_disabled(True)
|
| 130 |
ext_client = AsyncOpenAI(api_key=API_KEY, base_url=BASE_URL)
|
| 131 |
llm_model = OpenAIChatCompletionsModel(model=MODEL_ID, openai_client=ext_client)
|
| 132 |
|
| 133 |
-
#
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
Guess modality (MRI / X-ray / CT / Ultrasound) from filename hints.
|
| 138 |
-
Returns: {"modality": "<guess or unknown>"}
|
| 139 |
-
"""
|
| 140 |
-
f = (filename or "").lower()
|
| 141 |
-
mapping = {
|
| 142 |
-
"xray": "X-ray", "x_ray": "X-ray", "xr": "X-ray", "chest": "X-ray",
|
| 143 |
-
"mri": "MRI", "t1": "MRI", "t2": "MRI", "flair": "MRI", "dwi": "MRI", "adc": "MRI", "swi": "MRI",
|
| 144 |
-
"ct": "CT", "cta": "CT",
|
| 145 |
-
"ultrasound": "Ultrasound", "usg": "Ultrasound", "echo": "Ultrasound",
|
| 146 |
-
}
|
| 147 |
-
for k, v in mapping.items():
|
| 148 |
-
if k in f:
|
| 149 |
-
return {"modality": v}
|
| 150 |
-
return {"modality": "unknown"}
|
| 151 |
|
| 152 |
-
@function_tool
|
| 153 |
-
def imaging_reference_guide(modality: str) -> dict:
|
| 154 |
-
"""
|
| 155 |
-
Educational bullets for acquisition, artifacts, preprocessing, and study tips by modality.
|
| 156 |
-
No diagnosis. Teaching focus only.
|
| 157 |
-
"""
|
| 158 |
-
mod = (modality or "").strip().lower()
|
| 159 |
-
if mod in ["xray", "x-ray", "x_ray"]:
|
| 160 |
-
return {
|
| 161 |
-
"acquisition": [
|
| 162 |
-
"Projection radiography with ionizing radiation.",
|
| 163 |
-
"Views: AP/PA/lateral; tune kVp/mAs and positioning.",
|
| 164 |
-
"Grids & collimation reduce scatter to improve contrast."
|
| 165 |
-
],
|
| 166 |
-
"artifacts": [
|
| 167 |
-
"Motion blur; under/overexposure.",
|
| 168 |
-
"Grid cut-off; foreign objects (jewelry, buttons).",
|
| 169 |
-
"Magnification/distortion from object-detector distance."
|
| 170 |
-
],
|
| 171 |
-
"preprocessing": [
|
| 172 |
-
"Denoising (median/NLM); histogram equalization.",
|
| 173 |
-
"Window/level exploration for bone vs soft tissue.",
|
| 174 |
-
"Edge enhancement (unsharp) sparingly to avoid halos."
|
| 175 |
-
],
|
| 176 |
-
"study_tips": [
|
| 177 |
-
"Use ABCDE (for CXR), check markers/labels/devices.",
|
| 178 |
-
"Compare sides and prior images.",
|
| 179 |
-
"Practice with checklists for consistency."
|
| 180 |
-
],
|
| 181 |
-
}
|
| 182 |
-
if mod in ["mri", "mr"]:
|
| 183 |
-
return {
|
| 184 |
-
"acquisition": [
|
| 185 |
-
"MR signal via RF pulses; sequences define contrast.",
|
| 186 |
-
"Common: T1, T2, FLAIR, DWI/ADC, GRE/SWI.",
|
| 187 |
-
"TR/TE/flip angle trade off SNR, contrast, scan time."
|
| 188 |
-
],
|
| 189 |
-
"artifacts": [
|
| 190 |
-
"Motion/ghosting; susceptibility near metal/air.",
|
| 191 |
-
"Chemical shift; Gibbs ringing.",
|
| 192 |
-
"B0/B1 inhomogeneity causing intensity bias."
|
| 193 |
-
],
|
| 194 |
-
"preprocessing": [
|
| 195 |
-
"Bias-field correction (N4).",
|
| 196 |
-
"Denoising (NLM); registration/normalization.",
|
| 197 |
-
"Skull stripping (brain); intensity standardization."
|
| 198 |
-
],
|
| 199 |
-
"study_tips": [
|
| 200 |
-
"Know sequence emphases (T1 anatomy; T2 fluid; FLAIR edema).",
|
| 201 |
-
"Review diffusion for acute ischemia (check ADC).",
|
| 202 |
-
"Keep window/level consistent across timepoints."
|
| 203 |
-
],
|
| 204 |
-
}
|
| 205 |
-
if mod in ["ct"]:
|
| 206 |
-
return {
|
| 207 |
-
"acquisition": [
|
| 208 |
-
"Helical CT; HU reflect attenuation.",
|
| 209 |
-
"Recon kernels affect sharpness vs noise.",
|
| 210 |
-
"Contrast timing (arterial/venous) per question."
|
| 211 |
-
],
|
| 212 |
-
"artifacts": [
|
| 213 |
-
"Beam hardening streaks; partial volume; motion.",
|
| 214 |
-
"Metal artifacts; MAR/iterative recon help."
|
| 215 |
-
],
|
| 216 |
-
"preprocessing": [
|
| 217 |
-
"Denoising (bilateral/NLM) with edge preservation.",
|
| 218 |
-
"Window/level by organ system (lung, mediastinum, bone).",
|
| 219 |
-
"Metal artifact reduction if available."
|
| 220 |
-
],
|
| 221 |
-
"study_tips": [
|
| 222 |
-
"Use standard planes; scroll systematically.",
|
| 223 |
-
"Check multiple windows; annotate size/location; HU if needed.",
|
| 224 |
-
"Compare with priors when teaching cases."
|
| 225 |
-
],
|
| 226 |
-
}
|
| 227 |
-
# Fallback (generic)
|
| 228 |
-
return {
|
| 229 |
-
"acquisition": [
|
| 230 |
-
"Acquisition parameters set contrast, resolution, noise.",
|
| 231 |
-
"Positioning & motion control drive image quality."
|
| 232 |
-
],
|
| 233 |
-
"artifacts": [
|
| 234 |
-
"Motion blur or ghosting; foreign objects/hardware can streak.",
|
| 235 |
-
"Under/overexposure or parameter misconfiguration."
|
| 236 |
-
],
|
| 237 |
-
"preprocessing": [
|
| 238 |
-
"Denoising & contrast normalization aid teaching clarity.",
|
| 239 |
-
"Registration & standard planes for consistent review."
|
| 240 |
-
],
|
| 241 |
-
"study_tips": [
|
| 242 |
-
"Adopt a checklist; compare bilaterally or across time.",
|
| 243 |
-
"Understand modality-specific controls (window/level, sequences)."
|
| 244 |
-
],
|
| 245 |
-
}
|
| 246 |
-
|
| 247 |
-
@function_tool
|
| 248 |
-
def file_facts(filename: str, size_bytes: str) -> dict:
|
| 249 |
-
"""Return simple file facts (name and size)."""
|
| 250 |
-
return {"filename": filename, "size_bytes": size_bytes}
|
| 251 |
-
|
| 252 |
-
# ========= Guardrails =========
|
| 253 |
-
ALLOWED_COMMANDS = ("/help", "/policy")
|
| 254 |
TOPIC_KEYWORDS = [
|
| 255 |
-
"
|
| 256 |
-
"
|
| 257 |
-
"
|
|
|
|
|
|
|
| 258 |
]
|
|
|
|
| 259 |
RE_FORBIDDEN_CLINICAL = re.compile(r"\b(diagnos(e|is|tic)|prescrib|medicat|treat(ment|ing)?|dose|drug|therapy)\b", re.I)
|
| 260 |
-
RE_INVASIVE_REPAIR = re.compile(r"\b(open(ing)?\s+(device|casing|cover)|solder|board[- ]level|
|
| 261 |
RE_ALARM_BYPASS = re.compile(r"\b(bypass|disable|silence)\s+(alarm|alert|safety|interlock)\b", re.I)
|
| 262 |
-
RE_FIRMWARE_TAMPER = re.compile(r"\b(firmware|bootloader|root|jailbreak|unlock\s+(service|engineer)\s*mode|password\s*override)\b", re.I)
|
| 263 |
RE_EMAIL = re.compile(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", re.I)
|
| 264 |
RE_PHONE = re.compile(r"(?:\+\d{1,3}[-\s.]*)?(?:\(?\d{3,4}\)?[-\s.]*)?\d{3}[-\s.]?\d{4}")
|
| 265 |
-
RE_IDHINT = re.compile(r"\b(CNIC|MRN|passport|nid|national\s*id)\b", re.I)
|
| 266 |
|
| 267 |
def on_topic(text: str) -> bool:
|
| 268 |
low = (text or "").lower().strip()
|
|
@@ -283,124 +177,237 @@ def local_guard(text: str) -> List[str]:
|
|
| 283 |
issues.append("phi_share_or_collect")
|
| 284 |
return issues
|
| 285 |
|
| 286 |
-
#
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
)
|
| 305 |
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
WELCOME = (
|
| 308 |
-
"
|
| 309 |
-
"
|
| 310 |
-
"
|
| 311 |
-
"
|
| 312 |
)
|
| 313 |
POLICY = (
|
| 314 |
"🛡️ **Safety & Scope Policy**\n"
|
| 315 |
-
"- Scope: biomedical **
|
| 316 |
"- No clinical advice (diagnosis/treatment/dosing/medications).\n"
|
| 317 |
-
"- No invasive
|
| 318 |
"- No alarm bypass or firmware tampering.\n"
|
| 319 |
"- No collecting/sharing personal identifiers.\n"
|
| 320 |
"- OEM manuals & local policy take priority."
|
| 321 |
)
|
| 322 |
REFUSAL = (
|
| 323 |
"🚫 I can’t help with diagnosis/treatment, invasive repair, alarm bypass, firmware hacks, or collecting personal data.\n"
|
| 324 |
-
"I can
|
| 325 |
)
|
| 326 |
|
| 327 |
-
#
|
|
|
|
|
|
|
| 328 |
@cl.on_chat_start
|
| 329 |
-
async def
|
|
|
|
| 330 |
await cl.Message(content=WELCOME).send()
|
| 331 |
-
files = await cl.AskFileMessage(
|
| 332 |
-
content="Please upload an **MRI/X-ray/CT/Ultrasound** image (PNG/JPG).",
|
| 333 |
-
accept=["image/png", "image/jpeg"],
|
| 334 |
-
max_size_mb=15,
|
| 335 |
-
max_files=1,
|
| 336 |
-
timeout=180,
|
| 337 |
-
).send()
|
| 338 |
-
if files:
|
| 339 |
-
f = files[0]
|
| 340 |
-
cl.user_session.set("last_file_name", f.name)
|
| 341 |
-
cl.user_session.set("last_file_size", f.size)
|
| 342 |
-
await cl.Message(content=f"Received **{f.name}** ({f.size} bytes). Now tell me what to explain.").send()
|
| 343 |
-
else:
|
| 344 |
-
await cl.Message(content="No file uploaded yet. You can still ask general imaging questions.").send()
|
| 345 |
|
| 346 |
@cl.on_message
|
| 347 |
-
async def
|
| 348 |
text = (message.content or "").strip()
|
| 349 |
|
| 350 |
# Commands
|
| 351 |
-
|
|
|
|
| 352 |
await cl.Message(content=WELCOME).send(); return
|
| 353 |
-
if
|
| 354 |
await cl.Message(content=POLICY).send(); return
|
| 355 |
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
if not on_topic(text):
|
| 358 |
await cl.Message(
|
| 359 |
-
content="I only
|
| 360 |
-
"
|
| 361 |
-
).send()
|
| 362 |
-
|
| 363 |
issues = local_guard(text)
|
| 364 |
if issues:
|
| 365 |
-
await cl.Message(content=REFUSAL + "\n\n" + POLICY).send()
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
if
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
try:
|
| 380 |
-
|
| 381 |
-
except InputGuardrailTripwireTriggered:
|
| 382 |
-
await cl.Message(content=REFUSAL).send(); return
|
| 383 |
except Exception as e:
|
| 384 |
-
await cl.Message(content=f"⚠️
|
|
|
|
| 385 |
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
mod_guess = infer_modality_from_filename(file_name or "")
|
| 389 |
-
modality = mod_guess.get("modality", "unknown")
|
| 390 |
-
guide = imaging_reference_guide(modality)
|
| 391 |
-
def bullets(arr: List[str]): return "\n".join(f"- {x}" for x in (arr or [])) or "- (general)"
|
| 392 |
-
facts_md = (
|
| 393 |
-
f"### 📁 File\n- Name: `{file_name or 'unknown'}`\n- Size: `{file_size if file_size is not None else 'unknown'} bytes`\n\n"
|
| 394 |
-
f"### 🔎 Modality (guess)\n- {modality}\n\n"
|
| 395 |
-
f"### 📚 Reference Guide\n"
|
| 396 |
-
f"**Acquisition**\n{bullets(guide.get('acquisition'))}\n\n"
|
| 397 |
-
f"**Common Artifacts**\n{bullets(guide.get('artifacts'))}\n\n"
|
| 398 |
-
f"**Preprocessing**\n{bullets(guide.get('preprocessing'))}\n\n"
|
| 399 |
-
f"**Study Tips**\n{bullets(guide.get('study_tips'))}\n\n"
|
| 400 |
-
f"> ⚠️ Education only — no diagnosis.\n"
|
| 401 |
-
)
|
| 402 |
-
except Exception:
|
| 403 |
-
facts_md = ""
|
| 404 |
-
|
| 405 |
-
answer = result.final_output or "I couldn’t generate an explanation."
|
| 406 |
-
await cl.Message(content=f"{facts_md}\n---\n{answer}").send()
|
|
|
|
| 1 |
+
# app.py — Biomedical Device Troubleshooting Assistant (Education-only)
|
| 2 |
+
# Self-contained: no external 'agents' package needed.
|
| 3 |
+
|
| 4 |
+
import os, io, re, json
|
| 5 |
from typing import Any, Callable, Dict, List, Optional
|
| 6 |
from dataclasses import dataclass, field
|
| 7 |
|
| 8 |
import chainlit as cl
|
| 9 |
from dotenv import load_dotenv
|
| 10 |
from openai import AsyncOpenAI as _SDKAsyncOpenAI
|
| 11 |
+
from pypdf import PdfReader
|
| 12 |
|
| 13 |
+
# =========================
|
| 14 |
+
# Minimal "agents" shim
|
| 15 |
+
# =========================
|
| 16 |
def set_tracing_disabled(disabled: bool = True):
|
| 17 |
return disabled
|
| 18 |
|
|
|
|
| 102 |
})
|
| 103 |
continue
|
| 104 |
|
| 105 |
+
# Final
|
| 106 |
result_obj = type("Result", (), {})()
|
| 107 |
result_obj.final_output = msg.content or ""
|
| 108 |
result_obj.context = context or {}
|
|
|
|
| 115 |
result_obj.final_output_as = lambda *_: result_obj.final_output
|
| 116 |
return result_obj
|
| 117 |
|
| 118 |
+
# =========================
|
| 119 |
+
# Provider setup (Gemini or OpenAI)
|
| 120 |
+
# =========================
|
| 121 |
load_dotenv()
|
| 122 |
GEMINI_API_KEY = os.getenv("Gem")
|
| 123 |
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
|
|
|
| 131 |
BASE_URL = None
|
| 132 |
MODEL_ID = "gpt-4o-mini"
|
| 133 |
else:
|
| 134 |
+
raise RuntimeError("Missing GEMINI_API_KEY or OPENAI_API_KEY in your environment.")
|
| 135 |
|
| 136 |
set_tracing_disabled(True)
|
| 137 |
ext_client = AsyncOpenAI(api_key=API_KEY, base_url=BASE_URL)
|
| 138 |
llm_model = OpenAIChatCompletionsModel(model=MODEL_ID, openai_client=ext_client)
|
| 139 |
|
| 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",
|
| 147 |
+
"troubleshoot","fault","error","alarm","probe","sensor","lead","cable",
|
| 148 |
+
"battery","power","calibration","qc","verification","analyzer","self-test",
|
| 149 |
+
"ventilator","defibrillator","infusion","pump","ecg","oximeter","nibp",
|
| 150 |
+
"monitor","ultrasound","anesthesia","syringe","suction","spirometer","glucometer"
|
| 151 |
]
|
| 152 |
+
|
| 153 |
RE_FORBIDDEN_CLINICAL = re.compile(r"\b(diagnos(e|is|tic)|prescrib|medicat|treat(ment|ing)?|dose|drug|therapy)\b", re.I)
|
| 154 |
+
RE_INVASIVE_REPAIR = re.compile(r"\b(open(ing)?\s+(device|casing|cover)|solder|reflow|board[- ]level|replace\s+(capacitor|ic))\b", re.I)
|
| 155 |
RE_ALARM_BYPASS = re.compile(r"\b(bypass|disable|silence)\s+(alarm|alert|safety|interlock)\b", re.I)
|
| 156 |
+
RE_FIRMWARE_TAMPER = re.compile(r"\b(firmware|bootloader|root|jailbreak|unlock\s+(service|engineer)\s*mode|password\s*override|backdoor)\b", re.I)
|
| 157 |
RE_EMAIL = re.compile(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", re.I)
|
| 158 |
RE_PHONE = re.compile(r"(?:\+\d{1,3}[-\s.]*)?(?:\(?\d{3,4}\)?[-\s.]*)?\d{3}[-\s.]?\d{4}")
|
| 159 |
+
RE_IDHINT = re.compile(r"\b(CNIC|MRN|passport|nid|national\s*id|social\s*security|aadhaar)\b", re.I)
|
| 160 |
|
| 161 |
def on_topic(text: str) -> bool:
|
| 162 |
low = (text or "").lower().strip()
|
|
|
|
| 177 |
issues.append("phi_share_or_collect")
|
| 178 |
return issues
|
| 179 |
|
| 180 |
+
# =========================
|
| 181 |
+
# Manual handling + search
|
| 182 |
+
# =========================
|
| 183 |
+
def extract_pdf_pages(data: bytes):
|
| 184 |
+
pages = []
|
| 185 |
+
reader = PdfReader(io.BytesIO(data))
|
| 186 |
+
for i, pg in enumerate(reader.pages, start=1):
|
| 187 |
+
try:
|
| 188 |
+
txt = pg.extract_text() or ""
|
| 189 |
+
except Exception:
|
| 190 |
+
txt = ""
|
| 191 |
+
pages.append({"page": i, "text": txt})
|
| 192 |
+
return pages
|
| 193 |
+
|
| 194 |
+
def extract_txt_pages(data: bytes, chunk_chars: int = 1600):
|
| 195 |
+
try: txt = data.decode("utf-8", errors="ignore")
|
| 196 |
+
except Exception: txt = ""
|
| 197 |
+
return [{"page": i+1, "text": txt[i:i+chunk_chars]} for i in range(0, len(txt), chunk_chars)] or [{"page": 1, "text": ""}]
|
| 198 |
+
|
| 199 |
+
def manual_hits(pages, query: str, topk: int = 3):
|
| 200 |
+
if not pages: return []
|
| 201 |
+
terms = [w for w in re.findall(r"\w+", (query or "").lower()) if len(w) > 2]
|
| 202 |
+
scored = []
|
| 203 |
+
for p in pages:
|
| 204 |
+
low = (p.get("text") or "").lower()
|
| 205 |
+
score = sum(low.count(t) for t in terms)
|
| 206 |
+
if score > 0: scored.append((score, p))
|
| 207 |
+
scored.sort(key=lambda x: x[0], reverse=True)
|
| 208 |
+
hits = [p for _, p in scored[:topk]] or pages[:1]
|
| 209 |
+
def excerpt(text: str, window: int = 420):
|
| 210 |
+
t = text or ""
|
| 211 |
+
low = t.lower()
|
| 212 |
+
idxs = [low.find(tk) for tk in terms if tk in low]
|
| 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": [
|
| 235 |
+
"If attached to a patient, ensure backup/alternative monitoring before checks.",
|
| 236 |
+
"No invasive service; do not open casing; follow OEM and facility policy.",
|
| 237 |
+
"Do not bypass or silence alarms beyond OEM instructions."
|
| 238 |
+
] + (["Life-support device: remove from service and use backup if malfunction suspected."] if life_critical else []),
|
| 239 |
+
"common_faults": [
|
| 240 |
+
"Power/battery supply issues or loose connections.",
|
| 241 |
+
"Damaged/incorrect accessories (leads, probes, tubing).",
|
| 242 |
+
"Configuration/profile mismatch for the clinical setup.",
|
| 243 |
+
"Environmental interference (EMI), filters/clogs, or mechanical obstruction."
|
| 244 |
+
],
|
| 245 |
+
"quick_checks": [
|
| 246 |
+
"Note model/serial/firmware and any error codes.",
|
| 247 |
+
"Verify power source/battery; try another outlet; reseat user-removable battery.",
|
| 248 |
+
"Inspect and reseat accessories; swap with a known-good if available.",
|
| 249 |
+
"Confirm settings match procedure; restore defaults if safe.",
|
| 250 |
+
"Run device self-test if available and review OEM quick-reference."
|
| 251 |
+
],
|
| 252 |
+
"qc": [
|
| 253 |
+
"Verify against a reference/simulator where applicable (ECG, SpO2, NIBP, flow, etc.).",
|
| 254 |
+
"Confirm scheduled electrical safety tests per policy.",
|
| 255 |
+
"Document results and compare with historical QC."
|
| 256 |
+
],
|
| 257 |
+
"escalate": [
|
| 258 |
+
"Any failed self-test or QC/safety test.",
|
| 259 |
+
"Persistent faults after basic checks or physical damage/liquid ingress.",
|
| 260 |
+
"Life-critical pathway: remove from service immediately and escalate to Biomed/OEM."
|
| 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 |
+
|
| 295 |
+
# =========================
|
| 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"
|
| 306 |
+
"- Scope: biomedical **device troubleshooting** (education-only).\n"
|
| 307 |
"- No clinical advice (diagnosis/treatment/dosing/medications).\n"
|
| 308 |
+
"- No invasive repairs (opening casing, soldering, board-level).\n"
|
| 309 |
"- No alarm bypass or firmware tampering.\n"
|
| 310 |
"- No collecting/sharing personal identifiers.\n"
|
| 311 |
"- OEM manuals & local policy take priority."
|
| 312 |
)
|
| 313 |
REFUSAL = (
|
| 314 |
"🚫 I can’t help with diagnosis/treatment, invasive repair, alarm bypass, firmware hacks, or collecting personal data.\n"
|
| 315 |
+
"I can guide **safe, non-invasive troubleshooting** and educational QC steps."
|
| 316 |
)
|
| 317 |
|
| 318 |
+
# =========================
|
| 319 |
+
# Chainlit flow
|
| 320 |
+
# =========================
|
| 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(
|
| 339 |
+
content="Upload the **service manual** (PDF or TXT). Max ~20 MB.",
|
| 340 |
+
accept=["application/pdf", "text/plain"],
|
| 341 |
+
max_files=1, max_size_mb=20, timeout=240
|
| 342 |
+
).send()
|
| 343 |
+
if not files:
|
| 344 |
+
await cl.Message(content="No file received.").send(); return
|
| 345 |
+
f = files[0]
|
| 346 |
+
data = getattr(f, "content", None)
|
| 347 |
+
if data is None and getattr(f, "path", None):
|
| 348 |
+
with open(f.path, "rb") as fh: data = fh.read()
|
| 349 |
+
try:
|
| 350 |
+
pages = extract_pdf_pages(data) if (f.mime == "application/pdf" or f.name.lower().endswith(".pdf")) else extract_txt_pages(data)
|
| 351 |
+
except Exception as e:
|
| 352 |
+
await cl.Message(content=f"Couldn't read the manual: {e}").send(); return
|
| 353 |
+
cl.user_session.set("manual", {"name": f.name, "pages": pages})
|
| 354 |
+
await cl.Message(content=f"✅ Manual indexed: **{f.name}** — {len(pages)} page-chunks.").send()
|
| 355 |
+
return
|
| 356 |
+
|
| 357 |
+
if low.startswith("/clear"):
|
| 358 |
+
cl.user_session.set("manual", None)
|
| 359 |
+
await cl.Message(content="Manual cleared.").send()
|
| 360 |
+
return
|
| 361 |
+
|
| 362 |
+
if low.startswith("/hits"):
|
| 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 |
+
|
| 371 |
+
# Topic & local safety guard
|
| 372 |
if not on_topic(text):
|
| 373 |
await cl.Message(
|
| 374 |
+
content="I only support **biomedical device troubleshooting**. "
|
| 375 |
+
"Describe the device & symptom (e.g., “ECG noisy baseline”), or upload a manual with **/manual**."
|
| 376 |
+
).send()
|
| 377 |
+
return
|
| 378 |
issues = local_guard(text)
|
| 379 |
if issues:
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|