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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +211 -74
app.py CHANGED
@@ -1,9 +1,9 @@
1
  # app.py
2
  # ---------------------------------------------------------
3
- # Simple Biomedical Troubleshooting Assistant
4
- # - Describe device & symptom in plain text.
5
- # - Optional: /manual to upload PDF/TXT; /clear to remove it.
6
- # - LLM returns a structured, education-only troubleshooting plan.
7
  # ---------------------------------------------------------
8
  import os, io, re, json
9
  import chainlit as cl
@@ -11,17 +11,20 @@ from dotenv import load_dotenv
11
  from openai import AsyncOpenAI
12
  from pypdf import PdfReader
13
 
14
- # ------------------ Config: auto provider ------------------
 
 
15
  load_dotenv()
16
-
17
- GEMINI_API_KEY = os.getenv("Gem")
18
  OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
19
 
20
  if GEMINI_API_KEY:
21
  PROVIDER = "gemini"
22
  MODEL_ID = "gemini-2.5-flash"
23
- client = AsyncOpenAI(api_key=GEMINI_API_KEY,
24
- base_url="https://generativelanguage.googleapis.com/v1beta/openai/")
 
 
25
  elif OPENAI_API_KEY:
26
  PROVIDER = "openai"
27
  MODEL_ID = "gpt-4o-mini" # any chat-capable model you have
@@ -29,8 +32,97 @@ elif OPENAI_API_KEY:
29
  else:
30
  raise RuntimeError("Missing GEMINI_API_KEY or OPENAI_API_KEY.")
31
 
32
- # ------------------ Manual helpers ------------------
33
- def _extract_pdf_pages(data: bytes):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  pages = []
35
  reader = PdfReader(io.BytesIO(data))
36
  for i, pg in enumerate(reader.pages, start=1):
@@ -41,14 +133,14 @@ def _extract_pdf_pages(data: bytes):
41
  pages.append({"page": i, "text": txt})
42
  return pages
43
 
44
- def _extract_txt_pages(data: bytes, chunk_chars: int = 1400):
45
  try:
46
  txt = data.decode("utf-8", errors="ignore")
47
  except Exception:
48
  txt = ""
49
  return [{"page": i + 1, "text": txt[i:i + chunk_chars]} for i in range(0, len(txt), chunk_chars)] or [{"page": 1, "text": ""}]
50
 
51
- def _manual_search(pages, query: str, topk: int = 3):
52
  if not pages:
53
  return []
54
  terms = [w for w in re.findall(r"\w+", (query or "").lower()) if len(w) > 2]
@@ -59,56 +151,69 @@ def _manual_search(pages, query: str, topk: int = 3):
59
  if score > 0:
60
  scored.append((score, p))
61
  scored.sort(key=lambda x: x[0], reverse=True)
62
- return [p for _, p in scored[:topk]] or pages[:1]
63
-
64
- def _excerpt(text: str, terms, window: int = 380):
65
- t = text or ""
66
- low = t.lower()
67
- idxs = [low.find(term) for term in terms if term in low]
68
- start = max(0, min([i for i in idxs if i >= 0], default=0) - window)
69
- end = min(len(t), start + 2 * window)
70
- return re.sub(r"\s+", " ", t[start:end]).strip()
 
71
 
72
- # ------------------ Prompt builder ------------------
 
 
73
  SYSTEM_PROMPT = (
74
  "You are a biomedical device troubleshooting assistant for clinical engineers.\n"
75
- "Education-only. No diagnosis or treatment advice. No invasive repairs, no alarm bypass, no firmware hacks, "
76
- "no collection of patient identifiers. Defer to OEM manuals and local policy if anything conflicts.\n\n"
77
- "Given a user description of a device and symptom, produce **concise bullet lists** with sections in this exact order:\n"
 
78
  "1) Safety First (non-invasive, patient-first)\n"
79
  "2) Likely Causes (ranked)\n"
80
  "3) Step-by-Step Checks (do-not-open device; do-not-bypass alarms)\n"
81
  "4) Quick Tests / Verification (what, how, with what reference/simulator)\n"
82
  "5) Escalate When (clear triggers)\n"
83
- "End with a one-line summary. If manual excerpts are provided, incorporate them but state that OEM manual prevails.\n"
84
  )
85
 
86
  async def call_llm(user_desc: str, manual_excerpts: str = "") -> str:
87
- messages = [{"role": "system", "content": SYSTEM_PROMPT}]
88
- user_block = f"User description:\n{user_desc.strip()}"
89
  if manual_excerpts:
90
  user_block += f"\n\nManual excerpts (for reference):\n{manual_excerpts.strip()}"
91
- messages.append({"role": "user", "content": user_block})
92
-
93
  resp = await client.chat.completions.create(
94
  model=MODEL_ID,
95
- messages=messages,
 
 
 
96
  )
97
  return resp.choices[0].message.content or ""
98
 
99
- # ------------------ Session utils ------------------
100
- def set_manual(manual_dict):
101
- cl.user_session.set("manual", manual_dict)
 
 
102
 
103
- def get_manual():
104
- return cl.user_session.get("manual")
 
 
 
 
 
 
 
 
105
 
106
- # ------------------ Chainlit handlers ------------------
107
  WELCOME = (
108
- "🛠️ **Biomedical Troubleshooting Assistant**\n"
109
- "Describe the **device & symptom** in plain text (e.g., “ECG shows noisy baseline”).\n"
110
- "Optional: type **/manual** to upload a PDF/TXT service manual for better guidance. Use **/clear** to remove it.\n\n"
111
- "Education-only. Refer to OEM manuals & policy. No diagnosis or invasive service."
112
  )
113
 
114
  @cl.on_chat_start
@@ -116,11 +221,19 @@ async def start():
116
  set_manual(None)
117
  await cl.Message(content=WELCOME).send()
118
 
 
 
 
119
  @cl.on_message
120
  async def main(message: cl.Message):
121
  text = (message.content or "").strip()
122
 
123
- # Upload manual
 
 
 
 
 
124
  if text.lower().startswith("/manual"):
125
  files = await cl.AskFileMessage(
126
  content="Upload the **service manual** (PDF or TXT). Max ~20 MB.",
@@ -128,57 +241,81 @@ async def main(message: cl.Message):
128
  max_files=1, max_size_mb=20, timeout=240
129
  ).send()
130
  if not files:
131
- await cl.Message(content="No file received.").send()
132
- return
133
  f = files[0]
134
  data = getattr(f, "content", None)
135
  if data is None and getattr(f, "path", None):
136
- with open(f.path, "rb") as fh:
137
- data = fh.read()
138
  try:
139
- if f.mime == "application/pdf" or f.name.lower().endswith(".pdf"):
140
- pages = _extract_pdf_pages(data)
141
- else:
142
- pages = _extract_txt_pages(data)
143
  except Exception as e:
144
- await cl.Message(content=f"Couldn't read the manual: {e}").send()
145
- return
146
  set_manual({"name": f.name, "pages": pages})
147
  await cl.Message(content=f"✅ Manual indexed: **{f.name}** — {len(pages)} page-chunks.").send()
148
  return
149
 
150
- # Clear manual
151
  if text.lower().startswith("/clear"):
152
  set_manual(None)
153
  await cl.Message(content="Manual cleared.").send()
154
  return
155
 
156
- # Regular troubleshooting request
157
- if not text:
158
- await cl.Message(content="Please describe the device & symptom (e.g., “ECG noisy baseline”).").send()
 
 
 
 
159
  return
160
 
161
- manual = get_manual()
162
- manual_excerpts = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  if manual and manual.get("pages"):
164
- # simple keyword search of the manual using the user's text
165
- terms = [w for w in re.findall(r"\w+", text.lower()) if len(w) > 2]
166
- hits = _manual_search(manual["pages"], text, topk=3)
167
- parts = []
168
- for h in hits:
169
- snippet = _excerpt(h.get("text",""), terms, window=420)
170
- parts.append(f"[p.{h.get('page')}] {snippet}")
171
- manual_excerpts = "\n".join(parts)
172
-
173
- # Call the LLM
174
  try:
175
- answer = await call_llm(text, manual_excerpts)
176
  except Exception as e:
177
  await cl.Message(content=f"⚠️ LLM call failed ({PROVIDER}): {e}").send()
178
  return
179
 
180
- # Present result
181
- prefix = ""
182
- if manual and manual.get("name"):
183
- prefix = f"📄 Using manual: **{manual['name']}**\n\n"
184
  await cl.Message(content=prefix + (answer or "I couldn’t generate a plan.")).send()
 
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
 
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
 
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):
 
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]
 
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
 
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.",
 
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()