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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +228 -221
app.py CHANGED
@@ -1,13 +1,18 @@
1
- # app.py — Self-contained (no external 'agents' package needed)
2
- import os, re, json
 
 
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
- # ========= Minimal "agents" shim (inline) =========
 
 
11
  def set_tracing_disabled(disabled: bool = True):
12
  return disabled
13
 
@@ -97,7 +102,7 @@ class Runner:
97
  })
98
  continue
99
 
100
- # Final answer
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
- # ========= Setup: provider auto-detect =========
 
 
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 env/secrets.")
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
- # ========= Tools =========
134
- @function_tool
135
- def infer_modality_from_filename(filename: str) -> dict:
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
- "imaging","image","radiology","biomedical","device","equipment","oem","modality",
256
- "acquisition","artifact","preprocessing","window","level","sequence","kVp","mAs",
257
- "mri","t1","t2","flair","dwi","adc","swi","ct","xray","x-ray","ultrasound","usg","echo"
 
 
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|reflow|replace\s+(capacitor|ic))\b", re.I)
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
- # ========= Tutor Agent =========
287
- tutor_instructions = (
288
- "You are a Biomedical Imaging **Education** Tutor. Explain how images are acquired, common artifacts, "
289
- "and preprocessing for study/teaching. Do NOT diagnose or give clinical advice.\n\n"
290
- "Output a concise, structured answer with sections in this order:\n"
291
- "1) Acquisition overview\n"
292
- "2) Common artifacts\n"
293
- "3) Preprocessing methods (education/study only)\n"
294
- "4) Study tips\n"
295
- "5) Caution (one line: education-only; consult qualified clinicians for clinical questions)\n\n"
296
- "Use tools to infer modality (from filename) and fetch a modality-specific reference guide. "
297
- "If modality unclear, provide a generic overview and invite the user to specify."
298
- )
299
- tutor_agent = Agent(
300
- name="Biomedical Imaging Tutor",
301
- instructions=tutor_instructions,
302
- model=llm_model,
303
- tools=[infer_modality_from_filename, imaging_reference_guide, file_facts],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  )
305
 
306
- # ========= UI strings =========
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  WELCOME = (
308
- "🎓 **Multimodal Biomedical Imaging Tutor**\n\n"
309
- "Upload an **MRI/X-ray/CT/Ultrasound** image (PNG/JPG), then ask what you’d like to learn.\n"
310
- "I’ll explain acquisition, common artifacts, and preprocessing for study.\n\n"
311
- "⚠️ Education only no diagnosis or clinical advice."
312
  )
313
  POLICY = (
314
  "🛡️ **Safety & Scope Policy**\n"
315
- "- Scope: biomedical **imaging education/troubleshooting** only.\n"
316
  "- No clinical advice (diagnosis/treatment/dosing/medications).\n"
317
- "- No invasive repair steps (opening casing, soldering, board-level).\n"
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 explain **imaging acquisition, artifacts, and preprocessing** for education."
325
  )
326
 
327
- # ========= Chainlit flow =========
 
 
328
  @cl.on_chat_start
329
- async def on_chat_start():
 
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 on_message(message: cl.Message):
348
  text = (message.content or "").strip()
349
 
350
  # Commands
351
- if text.lower().startswith("/help"):
 
352
  await cl.Message(content=WELCOME).send(); return
353
- if text.lower().startswith("/policy"):
354
  await cl.Message(content=POLICY).send(); return
355
 
356
- # Topic & guardrails
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  if not on_topic(text):
358
  await cl.Message(
359
- content="I only discuss **biomedical imaging education** (acquisition, artifacts, preprocessing). "
360
- "Please ask about MRI/X-ray/CT/Ultrasound imaging."
361
- ).send(); return
362
-
363
  issues = local_guard(text)
364
  if issues:
365
- await cl.Message(content=REFUSAL + "\n\n" + POLICY).send(); return
366
-
367
- # Context from uploaded file
368
- file_name = cl.user_session.get("last_file_name")
369
- file_size = cl.user_session.get("last_file_size")
370
- context_lines = []
371
- if file_name: context_lines.append(f"File: {file_name}")
372
- if file_size is not None: context_lines.append(f"Size: {file_size} bytes")
373
- context_block = "\n".join(context_lines)
374
-
375
- # Compose query for tutor
376
- user_query = text if not context_block else f"{text}\n\n[Context]\n{context_block}"
377
-
378
- # Run tutor
 
 
 
 
 
 
 
 
 
 
 
 
379
  try:
380
- result = await Runner.run(tutor_agent, user_query)
381
- except InputGuardrailTripwireTriggered:
382
- await cl.Message(content=REFUSAL).send(); return
383
  except Exception as e:
384
- await cl.Message(content=f"⚠️ Tutor failed: {e}").send(); return
 
385
 
386
- # Deterministic quick reference (tools)
387
- try:
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()