ibadhasnain commited on
Commit
dd2158e
·
verified ·
1 Parent(s): 58b6c67

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +290 -277
app.py CHANGED
@@ -1,321 +1,334 @@
1
  # app.py
2
- # ---------------------------------------------------------
3
- # Biomedical Troubleshooting Assistant (Topic-Locked + Guardrails)
4
- # - Only biomedical device troubleshooting.
5
- # - /manual to upload PDF/TXT (PHI redacted in excerpts), /clear to remove, /policy to view rules.
6
- # - Dual guardrails: local regex + LLM JSON classifier.
7
- # ---------------------------------------------------------
8
- import os, io, re, json
9
  import chainlit as cl
10
  from dotenv import load_dotenv
11
- from openai import AsyncOpenAI
12
- from pypdf import PdfReader
13
 
14
- # =========================
15
- # Config: auto provider
16
- # =========================
 
 
 
 
 
 
 
 
 
 
 
17
  load_dotenv()
18
- GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
19
- OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
 
20
 
21
  if GEMINI_API_KEY:
22
  PROVIDER = "gemini"
 
 
23
  MODEL_ID = "gemini-2.5-flash"
24
- client = AsyncOpenAI(
25
- api_key=GEMINI_API_KEY,
26
- base_url="https://generativelanguage.googleapis.com/v1beta/openai/"
27
- )
28
  elif OPENAI_API_KEY:
29
  PROVIDER = "openai"
30
- MODEL_ID = "gpt-4o-mini" # any chat-capable model you have
31
- client = AsyncOpenAI(api_key=OPENAI_API_KEY)
 
32
  else:
33
- raise RuntimeError("Missing GEMINI_API_KEY or OPENAI_API_KEY.")
34
-
35
- # =========================
36
- # Topic lock & guardrails
37
- # =========================
38
- ALLOWED_COMMANDS = ("/manual", "/clear", "/help", "/policy")
39
-
40
- TOPIC_KEYWORDS = [
41
- "biomedical","biomed","device","equipment","oem","service manual",
42
- "troubleshoot","troubleshooting","fault","error","alarm",
43
- "probe","sensor","lead","cable","battery","power","calibration","qc","verification","analyzer",
44
- "ecg","spo2","oximeter","nibp","ventilator","infusion","pump",
45
- "defibrillator","patient monitor","ultrasound","anesthesia","syringe pump",
46
- ]
47
-
48
- RE_FORBIDDEN_CLINICAL = re.compile(r"\b(diagnos(e|is|tic)|prescrib|medicat|treat(ment|ing)?|dose|drug|therapy)\b", re.I)
49
- RE_INVASIVE_REPAIR = re.compile(r"\b(open(ing)?\s+(the\s+)?(device|casing|cover)|remove\s+cover|solder|reflow|short\s+pin|jumper|board\s+level|replace\s+capacitor|tear\s+down)\b", re.I)
50
- RE_ALARM_BYPASS = re.compile(r"\b(bypass|disable|silence)\s+(alarm|alert|safety|interlock)\b", re.I)
51
- RE_FIRMWARE_TAMPER = re.compile(r"\b(firmware|bootloader|root|jailbreak|unlock\s+(service|engineer)\s*mode|password\s*override|service\s*code|backdoor)\b", re.I)
52
 
53
- # PHI patterns (best-effort)
54
- RE_EMAIL = re.compile(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", re.I)
55
- RE_PHONE = re.compile(r"(?:\+\d{1,3}[-\s.]*)?(?:\(?\d{3,4}\)?[-\s.]*)?\d{3}[-\s.]?\d{4}")
56
- RE_CNIC = re.compile(r"\b\d{5}-\d{7}-\d\b") # Pakistan CNIC
57
- RE_MRN = re.compile(r"\b(MRN|Medical\s*Record(?:\s*Number)?)[:\s]*\d{4,}\b", re.I)
58
- RE_ADDRESS_HINT = re.compile(r"\b(address|street|road|block|apt|flat|house)\b", re.I)
59
 
60
- def on_topic(text: str) -> bool:
61
- low = (text or "").lower().strip()
62
- if not low:
63
- return False
64
- if any(low.startswith(cmd) for cmd in ALLOWED_COMMANDS):
65
- return True
66
- return any(k in low for k in TOPIC_KEYWORDS)
 
67
 
68
- def local_guard_check(text: str) -> list:
69
- """Return a list of violations detected locally (fast, offline)."""
70
- low = (text or "").lower()
71
- issues = []
72
- if RE_FORBIDDEN_CLINICAL.search(low): issues.append("clinical_advice")
73
- if RE_INVASIVE_REPAIR.search(low): issues.append("invasive_repair")
74
- if RE_ALARM_BYPASS.search(low): issues.append("alarm_bypass")
75
- if RE_FIRMWARE_TAMPER.search(low): issues.append("firmware_tamper")
76
- # PHI sharing/collection indicators
77
- if RE_EMAIL.search(text) or RE_PHONE.search(text) or RE_CNIC.search(text) or RE_MRN.search(text) or RE_ADDRESS_HINT.search(text):
78
- issues.append("phi_share_or_collect")
79
- return issues
 
 
 
 
 
 
 
 
80
 
81
- async def llm_guard_check(text: str) -> dict:
82
- """Ask model for a strict JSON classification; fallback to {} on error."""
83
- guard_system = (
84
- "You are a strict safety classifier. Return ONLY a compact JSON object with boolean fields:\n"
85
- "{in_scope, clinical_advice, invasive_repair, alarm_bypass, firmware_tamper, phi_share_or_collect}.\n"
86
- "Definitions:\n"
87
- "- in_scope: true only if user asks about biomedical equipment troubleshooting.\n"
88
- "- clinical_advice: true if asking diagnosis/treatment/dosing/medication/therapy.\n"
89
- "- invasive_repair: true if asking to open casing, solder, board-level repair.\n"
90
- "- alarm_bypass: true if asking to silence/disable/bypass alarms/interlocks.\n"
91
- "- firmware_tamper: true if asking to root, jailbreak, firmware hacks, unlock service mode, passwords.\n"
92
- "- phi_share_or_collect: true if asking to share/collect personal identifiers or appears to share them.\n"
93
- "Respond with JSON only, no prose."
94
- )
95
- try:
96
- resp = await client.chat.completions.create(
97
- model=MODEL_ID,
98
- messages=[
99
- {"role": "system", "content": guard_system},
100
- {"role": "user", "content": text}
101
  ],
102
- )
103
- raw = resp.choices[0].message.content or "{}"
104
- # best-effort JSON extraction
105
- m = re.search(r"\{.*\}", raw, re.S)
106
- if m:
107
- return json.loads(m.group(0))
108
- return json.loads(raw) # may already be JSON
109
- except Exception:
110
- return {}
111
-
112
- def redact_phi(s: str) -> str:
113
- if not s: return s
114
- s = RE_EMAIL.sub("[REDACTED_EMAIL]", s)
115
- s = RE_CNIC.sub("[REDACTED_CNIC]", s)
116
- s = RE_PHONE.sub("[REDACTED_PHONE]", s)
117
- s = RE_MRN.sub("[REDACTED_MRN]", s)
118
- # light address hint → don't remove whole text; just bracket note near keyword
119
- s = RE_ADDRESS_HINT.sub("[ADDRESS]", s)
120
- return s
121
-
122
- # =========================
123
- # Manual helpers
124
- # =========================
125
- def extract_pdf_pages(data: bytes):
126
- pages = []
127
- reader = PdfReader(io.BytesIO(data))
128
- for i, pg in enumerate(reader.pages, start=1):
129
- try:
130
- txt = pg.extract_text() or ""
131
- except Exception:
132
- txt = ""
133
- pages.append({"page": i, "text": txt})
134
- return pages
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
- def extract_txt_pages(data: bytes, chunk_chars: int = 1600):
137
- try:
138
- txt = data.decode("utf-8", errors="ignore")
139
- except Exception:
140
- txt = ""
141
- return [{"page": i + 1, "text": txt[i:i + chunk_chars]} for i in range(0, len(txt), chunk_chars)] or [{"page": 1, "text": ""}]
142
 
143
- def manual_hits(pages, query: str, topk: int = 3):
144
- if not pages:
145
- return []
146
- terms = [w for w in re.findall(r"\w+", (query or "").lower()) if len(w) > 2]
147
- scored = []
148
- for p in pages:
149
- low = (p.get("text") or "").lower()
150
- score = sum(low.count(t) for t in terms)
151
- if score > 0:
152
- scored.append((score, p))
153
- scored.sort(key=lambda x: x[0], reverse=True)
154
- hits = [p for _, p in scored[:topk]] or pages[:1]
155
- def excerpt(text: str, window: int = 380):
156
- t = text or ""
157
- low = t.lower()
158
- idxs = [low.find(tk) for tk in terms if tk in low]
159
- start = max(0, min([i for i in idxs if i >= 0], default=0) - window)
160
- end = min(len(t), start + 2 * window)
161
- return re.sub(r"\s+", " ", t[start:end]).strip()
162
- # Redact PHI in excerpts
163
- return [f"[p.{h['page']}] {redact_phi(excerpt(h.get('text','')))}" for h in hits]
164
 
165
- # =========================
166
- # Tutor prompt
167
- # =========================
168
- SYSTEM_PROMPT = (
169
- "You are a biomedical device troubleshooting assistant for clinical engineers.\n"
170
- "STRICT SCOPE: Only biomedical equipment troubleshooting. If any content appears clinical, you must refuse.\n"
171
- "Safety: Education-only. No diagnosis/treatment. No invasive repairs. No alarm bypass. No firmware hacks. "
172
- "Do not ask users to share personal identifiers. Defer to OEM manuals and local policy if any conflict.\n\n"
173
- "Given a user description of a device and symptom, produce concise bullet lists with exactly these sections:\n"
174
- "1) Safety First (non-invasive, patient-first)\n"
175
- "2) Likely Causes (ranked)\n"
176
- "3) Step-by-Step Checks (do-not-open device; do-not-bypass alarms)\n"
177
- "4) Quick Tests / Verification (what, how, with what reference/simulator)\n"
178
- "5) Escalate When (clear triggers)\n"
179
- "End with a one-line summary. If manual excerpts are provided, incorporate them but state OEM manual prevails.\n"
 
180
  )
181
 
182
- async def call_llm(user_desc: str, manual_excerpts: str = "") -> str:
183
- user_block = f"Device/symptom:\n{user_desc.strip()}"
184
- if manual_excerpts:
185
- user_block += f"\n\nManual excerpts (for reference):\n{manual_excerpts.strip()}"
186
- resp = await client.chat.completions.create(
187
- model=MODEL_ID,
188
- messages=[
189
- {"role": "system", "content": SYSTEM_PROMPT},
190
- {"role": "user", "content": user_block},
191
- ],
192
- )
193
- return resp.choices[0].message.content or ""
194
-
195
- # =========================
196
- # Session utils & UI
197
- # =========================
198
- def set_manual(m): cl.user_session.set("manual", m)
199
- def get_manual(): return cl.user_session.get("manual")
200
 
201
- POLICY_TEXT = (
202
- "🛡️ **Safety & Scope Policy**\n"
203
- "- Scope: Only **biomedical equipment troubleshooting**.\n"
204
- "- No clinical advice: diagnosis, treatment, dosing, medications.\n"
205
- "- No invasive repairs: opening casing, soldering, board-level fixes.\n"
206
- "- No alarm bypass / interlock disable.\n"
207
- "- No firmware tampering / service mode hacks / passwords.\n"
208
- "- No collection or sharing of personal identifiers (emails, phone, CNIC, MRN, addresses).\n"
209
- "- OEM manuals & local policy take priority."
210
  )
211
 
 
 
 
212
  WELCOME = (
213
- "🛠️ **Biomedical Troubleshooting Assistant (Topic-Locked + Guardrails)**\n"
214
- "Describe the **device & symptom** (e.g., “ECG noisy baseline”).\n"
215
- "Commands: **/manual** upload PDF/TXT (PHI auto-redacted in excerpts), **/clear** remove manual, **/policy** view rules, **/help** usage.\n"
216
- "Education-only. OEM manual & policy take priority."
217
  )
218
 
 
 
 
 
 
 
 
 
219
  @cl.on_chat_start
220
- async def start():
221
- set_manual(None)
222
  await cl.Message(content=WELCOME).send()
 
 
 
 
 
 
 
223
 
224
- # =========================
225
- # Main handler
226
- # =========================
227
- @cl.on_message
228
- async def main(message: cl.Message):
229
- text = (message.content or "").strip()
230
 
231
- # Commands
232
- if text.lower().startswith("/help"):
233
- await cl.Message(content=WELCOME).send(); return
234
- if text.lower().startswith("/policy"):
235
- await cl.Message(content=POLICY_TEXT).send(); return
236
 
237
- if text.lower().startswith("/manual"):
238
- files = await cl.AskFileMessage(
239
- content="Upload the **service manual** (PDF or TXT). Max ~20 MB.",
240
- accept=["application/pdf", "text/plain"],
241
- max_files=1, max_size_mb=20, timeout=240
242
- ).send()
243
- if not files:
244
- await cl.Message(content="No file received.").send(); return
245
- f = files[0]
246
- data = getattr(f, "content", None)
247
- if data is None and getattr(f, "path", None):
248
- with open(f.path, "rb") as fh: data = fh.read()
249
- try:
250
- pages = extract_pdf_pages(data) if (f.mime == "application/pdf" or f.name.lower().endswith(".pdf")) else extract_txt_pages(data)
251
- except Exception as e:
252
- await cl.Message(content=f"Couldn't read the manual: {e}").send(); return
253
- set_manual({"name": f.name, "pages": pages})
254
- await cl.Message(content=f"✅ Manual indexed: **{f.name}** — {len(pages)} page-chunks.").send()
255
- return
256
 
257
- if text.lower().startswith("/clear"):
258
- set_manual(None)
259
- await cl.Message(content="Manual cleared.").send()
 
 
260
  return
261
 
262
- # Topic lock (coarse)
263
- if not on_topic(text):
264
- await cl.Message(
265
- content="🚫 I only handle **biomedical device troubleshooting**.\n"
266
- "Describe the *device & symptom* (e.g., “Infusion pump occlusion alarm”).\n"
267
- "Use **/manual** to upload a service manual."
268
- ).send()
269
- return
 
 
 
270
 
271
- # Local guard (fast)
272
- local_issues = local_guard_check(text)
273
- if local_issues:
274
- reason_map = {
275
- "clinical_advice": "clinical diagnosis/treatment",
276
- "invasive_repair": "invasive repair steps",
277
- "alarm_bypass": "bypassing alarms/interlocks",
278
- "firmware_tamper": "firmware tampering or service-mode hacks",
279
- "phi_share_or_collect": "sharing or collecting personal identifiers",
280
- }
281
- reasons = ", ".join(reason_map[k] for k in local_issues if k in reason_map)
282
- await cl.Message(
283
- content=f"🚫 I can’t help with {reasons}. I only provide **safe, non-invasive biomedical equipment troubleshooting**.\n{POLICY_TEXT}"
284
- ).send()
285
- return
286
 
287
- # LLM guard (nuanced)
288
- verdict = await llm_guard_check(text)
289
- if verdict:
290
- if not verdict.get("in_scope", True):
291
- await cl.Message(
292
- content="🚫 Off-topic. I only support **biomedical device troubleshooting**.\n" + POLICY_TEXT
293
- ).send(); return
294
- for key, msg in [
295
- ("clinical_advice", "clinical diagnosis/treatment"),
296
- ("invasive_repair", "invasive repair steps"),
297
- ("alarm_bypass", "bypassing alarms/interlocks"),
298
- ("firmware_tamper", "firmware tampering or service-mode hacks"),
299
- ("phi_share_or_collect", "sharing or collecting personal identifiers"),
300
- ]:
301
- if verdict.get(key):
302
- await cl.Message(
303
- content=f"🚫 I can’t help with {msg}. I only provide **safe, non-invasive biomedical equipment troubleshooting**.\n{POLICY_TEXT}"
304
- ).send()
305
- return
306
 
307
- # Manual excerpts (with PHI redaction)
308
- manual = cl.user_session.get("manual")
309
- excerpts = ""
310
- if manual and manual.get("pages"):
311
- excerpts = "\n".join(manual_hits(manual["pages"], text, topk=3))
312
 
313
- # Call LLM for the plan
314
  try:
315
- answer = await call_llm(text, excerpts)
 
 
 
316
  except Exception as e:
317
- await cl.Message(content=f"⚠️ LLM call failed ({PROVIDER}): {e}").send()
318
  return
319
 
320
- prefix = f"📄 Using manual: **{manual['name']}**\n\n" if (manual and manual.get("name")) else ""
321
- await cl.Message(content=prefix + (answer or "I couldn’t generate a plan.")).send()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # app.py
2
+ import os, re
3
+ from typing import List
 
 
 
 
 
4
  import chainlit as cl
5
  from dotenv import load_dotenv
6
+ from pydantic import BaseModel, Field
 
7
 
8
+ # === Your agents framework (shim included in ./agents) ===
9
+ from agents import (
10
+ Agent,
11
+ Runner,
12
+ AsyncOpenAI,
13
+ OpenAIChatCompletionsModel,
14
+ set_tracing_disabled,
15
+ function_tool,
16
+ )
17
+ from agents.exceptions import InputGuardrailTripwireTriggered
18
+
19
+ # -----------------------------
20
+ # Setup: auto provider
21
+ # -----------------------------
22
  load_dotenv()
23
+
24
+ GEMINI_API_KEY = os.environ.get("Gem")
25
+ OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
26
 
27
  if GEMINI_API_KEY:
28
  PROVIDER = "gemini"
29
+ API_KEY = GEMINI_API_KEY
30
+ BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
31
  MODEL_ID = "gemini-2.5-flash"
 
 
 
 
32
  elif OPENAI_API_KEY:
33
  PROVIDER = "openai"
34
+ API_KEY = OPENAI_API_KEY
35
+ BASE_URL = None
36
+ MODEL_ID = "gpt-4o-mini" # any chat-capable OpenAI model
37
  else:
38
+ raise RuntimeError(
39
+ "Missing GEMINI_API_KEY or OPENAI_API_KEY. Add it to a .env or Space secrets."
40
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
+ set_tracing_disabled(disabled=True)
 
 
 
 
 
43
 
44
+ ext_client: AsyncOpenAI = AsyncOpenAI(
45
+ api_key=API_KEY,
46
+ base_url=BASE_URL,
47
+ )
48
+ llm_model: OpenAIChatCompletionsModel = OpenAIChatCompletionsModel(
49
+ model=MODEL_ID,
50
+ openai_client=ext_client,
51
+ )
52
 
53
+ # -----------------------------
54
+ # Tools (function calling)
55
+ # -----------------------------
56
+ @function_tool
57
+ def infer_modality_from_filename(filename: str) -> dict:
58
+ """
59
+ Guess modality (MRI / X-ray / CT / Ultrasound) from filename hints.
60
+ Returns: {"modality": "<guess or unknown>"}
61
+ """
62
+ f = (filename or "").lower()
63
+ mapping = {
64
+ "xray": "X-ray", "x_ray": "X-ray", "xr": "X-ray", "chest": "X-ray",
65
+ "mri": "MRI", "t1": "MRI", "t2": "MRI", "flair": "MRI", "dwi": "MRI",
66
+ "ct": "CT", "cta": "CT",
67
+ "ultrasound": "Ultrasound", "usg": "Ultrasound", "echo": "Ultrasound",
68
+ }
69
+ for key, mod in mapping.items():
70
+ if key in f:
71
+ return {"modality": mod}
72
+ return {"modality": "unknown"}
73
 
74
+ @function_tool
75
+ def imaging_reference_guide(modality: str) -> dict:
76
+ """
77
+ Educational bullets for acquisition, artifacts, preprocessing, and study tips by modality.
78
+ No diagnosis. Teaching focus only.
79
+ """
80
+ mod = (modality or "").strip().lower()
81
+ if mod in ["xray", "x-ray", "x_ray"]:
82
+ return {
83
+ "acquisition": [
84
+ "Projection radiography with ionizing radiation.",
85
+ "Common views: AP/PA/lateral; adjust kVp/mAs and positioning.",
86
+ "Grids/collimation reduce scatter and improve contrast."
 
 
 
 
 
 
 
87
  ],
88
+ "artifacts": [
89
+ "Motion blur; under/overexposure.",
90
+ "Grid cut-off, foreign objects (jewelry, buttons).",
91
+ "Magnification/distortion from object-detector distance."
92
+ ],
93
+ "preprocessing": [
94
+ "Denoising (median/NLM); contrast equalization.",
95
+ "Window/level exploration for teaching (bone, soft tissue).",
96
+ "Edge enhancement (unsharp) used sparingly to avoid halos."
97
+ ],
98
+ "study_tips": [
99
+ "Use a systematic pattern (e.g., ABCDE for chest).",
100
+ "Compare sides; verify markers/labels/devices.",
101
+ "Relate to clinical scenario during study practice."
102
+ ],
103
+ }
104
+ if mod in ["mri", "mr"]:
105
+ return {
106
+ "acquisition": [
107
+ "MR signal via RF pulses in a magnetic field; sequences set contrast.",
108
+ "Common: T1, T2, FLAIR, DWI/ADC, GRE/SWI.",
109
+ "TR/TE/flip angle trade off SNR, contrast, and scan time."
110
+ ],
111
+ "artifacts": [
112
+ "Motion/ghosting; susceptibility near metal/air.",
113
+ "Chemical shift; Gibbs ringing.",
114
+ "B0/B1 inhomogeneity causing intensity non-uniformity."
115
+ ],
116
+ "preprocessing": [
117
+ "Bias-field correction (N4).",
118
+ "Denoising (NLM); spatial normalization/registration.",
119
+ "Skull stripping (brain) and intensity standardization."
120
+ ],
121
+ "study_tips": [
122
+ "Know each sequence��s emphasis (T1 anatomy; T2 fluid; FLAIR edema).",
123
+ "Always review diffusion for acute ischemia (with ADC).",
124
+ "Match window/level across timepoints for fair comparison."
125
+ ],
126
+ }
127
+ if mod in ["ct"]:
128
+ return {
129
+ "acquisition": [
130
+ "Helical CT; HU reflect X-ray attenuation.",
131
+ "Reconstruction kernels (soft tissue vs bone) affect sharpness/noise.",
132
+ "Contrast timing (arterial/venous) tailored to clinical question."
133
+ ],
134
+ "artifacts": [
135
+ "Beam hardening streaks; partial volume; motion.",
136
+ "Metal artifacts; MAR algorithms can help."
137
+ ],
138
+ "preprocessing": [
139
+ "Denoising (bilateral/NLM) with edge preservation.",
140
+ "Window/level by organ system; iterative recon options.",
141
+ "Metal artifact reduction if available."
142
+ ],
143
+ "study_tips": [
144
+ "Use standard planes; scroll systematically.",
145
+ "Check multiple windows (lung, mediastinum, bone).",
146
+ "Note size/location; reference HU where teaching-appropriate."
147
+ ],
148
+ }
149
+ # Generic fallback
150
+ return {
151
+ "acquisition": [
152
+ "Acquisition parameters impact contrast, resolution, and noise.",
153
+ "Positioning and motion control are key to quality."
154
+ ],
155
+ "artifacts": [
156
+ "Motion blur/ghosting; foreign objects can degrade interpretability.",
157
+ "Under/overexposure or parameter misconfiguration."
158
+ ],
159
+ "preprocessing": [
160
+ "Denoising and contrast normalization for clearer teaching images.",
161
+ "Registration and standard planes for consistent reviews."
162
+ ],
163
+ "study_tips": [
164
+ "Adopt a checklist and be consistent.",
165
+ "Understand modality-specific controls (window/level; sequence choice)."
166
+ ],
167
+ }
168
 
169
+ @function_tool
170
+ def file_facts(filename: str, size_bytes: int) -> dict:
171
+ """
172
+ Lightweight file facts (name and size).
173
+ """
174
+ return {"filename": filename, "size_bytes": size_bytes}
175
 
176
+ # -----------------------------
177
+ # Guardrails (LLM classifier)
178
+ # -----------------------------
179
+ class SafetyCheck(BaseModel):
180
+ in_scope: bool = Field(description="True if about biomedical imaging education/troubleshooting.")
181
+ clinical_advice: bool = Field(description="True if asking diagnosis/treatment/dosing/medication.")
182
+ invasive_repair: bool = Field(description="True if asking to open device/board-level fixes.")
183
+ alarm_bypass: bool = Field(description="True if asking to bypass/disable safety alarms.")
184
+ firmware_tamper: bool = Field(description="True if asking for firmware hacks/unlock codes/passwords.")
185
+ phi_share_or_collect: bool = Field(description="True if trying to share/collect personal identifiers.")
186
+ reasoning: str
 
 
 
 
 
 
 
 
 
 
187
 
188
+ guardrail_agent = Agent(
189
+ name="Safety Classifier",
190
+ instructions=(
191
+ "You are a strict safety classifier for a **biomedical imaging education tutor**.\n"
192
+ "Return ONLY JSON with keys: {in_scope, clinical_advice, invasive_repair, alarm_bypass, "
193
+ "firmware_tamper, phi_share_or_collect, reasoning}.\n"
194
+ "- in_scope: true ONLY if the message is about biomedical imaging education/troubleshooting (no diagnosis).\n"
195
+ "- clinical_advice: diagnosis/treatment/dose/medication/therapy requests.\n"
196
+ "- invasive_repair: opening casing, soldering, board-level steps.\n"
197
+ "- alarm_bypass: silencing/disabling alarms or interlocks.\n"
198
+ "- firmware_tamper: rooting/jailbreaking/unlocking firmware/service modes/passwords.\n"
199
+ "- phi_share_or_collect: asking to share or store personal identifiers.\n"
200
+ "Respond with compact JSON, no extra text."
201
+ ),
202
+ model=llm_model,
203
+ output_type=SafetyCheck,
204
  )
205
 
206
+ # -----------------------------
207
+ # Tutor Agent
208
+ # -----------------------------
209
+ tutor_instructions = (
210
+ "You are a Biomedical Imaging **Education** Tutor. You explain **how images are acquired**, common **artifacts**, "
211
+ "and **preprocessing for study/teaching**. You do **NOT** diagnose, identify diseases, or give clinical advice.\n\n"
212
+ "Always produce a concise, structured answer with bullet points and the following sections in this order:\n"
213
+ "1) Acquisition overview\n"
214
+ "2) Common artifacts\n"
215
+ "3) Preprocessing methods (education/study only)\n"
216
+ "4) Study tips\n"
217
+ "5) Caution (one line: education-only; consult qualified clinicians for clinical questions)\n\n"
218
+ "Use tools to infer modality (from filename) and to fetch a modality-specific reference guide. "
219
+ "If the modality is unclear, provide a generic but accurate overview and invite the user to specify."
220
+ )
 
 
 
221
 
222
+ tutor_agent = Agent(
223
+ name="Biomedical Imaging Tutor",
224
+ instructions=tutor_instructions,
225
+ model=llm_model,
226
+ tools=[infer_modality_from_filename, imaging_reference_guide, file_facts],
 
 
 
 
227
  )
228
 
229
+ # -----------------------------
230
+ # UI strings
231
+ # -----------------------------
232
  WELCOME = (
233
+ "🎓 **Multimodal Biomedical Imaging Tutor**\n\n"
234
+ "Upload an **MRI/X-ray/CT/Ultrasound** image (PNG/JPG), then ask what you’d like to learn.\n"
235
+ "I’ll explain acquisition, common artifacts, and preprocessing methods for study.\n\n"
236
+ "⚠️ Education only no diagnosis or clinical advice."
237
  )
238
 
239
+ REFUSAL = (
240
+ "🚫 I can’t help with diagnosis/treatment, invasive repair, alarm bypass, firmware hacks, or collecting personal data.\n"
241
+ "I can explain **imaging acquisition, artifacts, and preprocessing** for education."
242
+ )
243
+
244
+ # -----------------------------
245
+ # Chainlit flows
246
+ # -----------------------------
247
  @cl.on_chat_start
248
+ async def on_chat_start():
 
249
  await cl.Message(content=WELCOME).send()
250
+ files = await cl.AskFileMessage(
251
+ content="Please upload an **MRI/X-ray/CT/Ultrasound** image (PNG/JPG).",
252
+ accept=["image/png", "image/jpeg"],
253
+ max_size_mb=15,
254
+ max_files=1,
255
+ timeout=180,
256
+ ).send()
257
 
258
+ if not files:
259
+ await cl.Message(content="No file uploaded yet. You can still ask general imaging questions.").send()
260
+ return
 
 
 
261
 
262
+ f = files[0]
263
+ cl.user_session.set("last_file_name", f.name)
264
+ cl.user_session.set("last_file_size", f.size)
 
 
265
 
266
+ await cl.Message(
267
+ content=f"Received **{f.name}** ({f.size} bytes). "
268
+ "Now type what you want to learn (e.g., *Explain acquisition & artifacts for this image*)."
269
+ ).send()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
 
271
+ @cl.on_message
272
+ async def on_message(message: cl.Message):
273
+ text = (message.content or "").strip()
274
+ if not text:
275
+ await cl.Message(content="Please describe what you want to learn about the uploaded image.").send()
276
  return
277
 
278
+ # ---- Guardrails pass
279
+ try:
280
+ verdict = await Runner.run(guardrail_agent, text)
281
+ flags = verdict.final_output_as(SafetyCheck)
282
+ if (not flags.in_scope) or flags.clinical_advice or flags.invasive_repair or flags.alarm_bypass \
283
+ or flags.firmware_tamper or flags.phi_share_or_collect:
284
+ await cl.Message(content=REFUSAL).send()
285
+ return
286
+ except Exception:
287
+ # If guard fails, continue but still remain educational-only in the tutor prompt.
288
+ pass
289
 
290
+ # Context from uploaded file
291
+ file_name = cl.user_session.get("last_file_name")
292
+ file_size = cl.user_session.get("last_file_size")
 
 
 
 
 
 
 
 
 
 
 
 
293
 
294
+ context_note = []
295
+ if file_name: context_note.append(f"File: {file_name}")
296
+ if file_size is not None: context_note.append(f"Size: {file_size} bytes")
297
+ context_block = "\n".join(context_note)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
 
299
+ # Compose user query for the tutor
300
+ user_query = text
301
+ if context_block:
302
+ user_query = f"{text}\n\n[Context]\n{context_block}"
 
303
 
304
+ # Run tutor agent
305
  try:
306
+ result = await Runner.run(tutor_agent, user_query)
307
+ except InputGuardrailTripwireTriggered:
308
+ await cl.Message(content=REFUSAL).send()
309
+ return
310
  except Exception as e:
311
+ await cl.Message(content=f"⚠️ Tutor failed: {e}").send()
312
  return
313
 
314
+ # Tool-derived quick guide (deterministic; helpful even if the model didn’t call tools)
315
+ try:
316
+ mod_guess = infer_modality_from_filename(file_name or "")
317
+ modality = mod_guess.get("modality", "unknown")
318
+ guide = imaging_reference_guide(modality)
319
+ def bullets(arr: List[str]): return "\n".join(f"- {x}" for x in (arr or [])) or "- (general)"
320
+ facts_md = (
321
+ f"### 📁 File\n- Name: `{file_name or 'unknown'}`\n- Size: `{file_size if file_size is not None else 'unknown'} bytes`\n\n"
322
+ f"### 🔎 Modality (guess)\n- {modality}\n\n"
323
+ f"### 📚 Reference Guide\n"
324
+ f"**Acquisition**\n{bullets(guide.get('acquisition'))}\n\n"
325
+ f"**Common Artifacts**\n{bullets(guide.get('artifacts'))}\n\n"
326
+ f"**Preprocessing**\n{bullets(guide.get('preprocessing'))}\n\n"
327
+ f"**Study Tips**\n{bullets(guide.get('study_tips'))}\n\n"
328
+ f"> ⚠️ Education only — no diagnosis.\n"
329
+ )
330
+ except Exception:
331
+ facts_md = ""
332
+
333
+ answer = result.final_output or "I couldn’t generate an explanation."
334
+ await cl.Message(content=f"{facts_md}\n---\n{answer}").send()