Update app.py
Browse files
app.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
# app.py
|
| 2 |
# ---------------------------------------------------------
|
| 3 |
-
#
|
| 4 |
-
# -
|
| 5 |
-
# -
|
| 6 |
-
# -
|
| 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 |
-
#
|
|
|
|
|
|
|
| 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(
|
| 24 |
-
|
|
|
|
|
|
|
| 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 |
-
#
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
| 71 |
|
| 72 |
-
#
|
|
|
|
|
|
|
| 73 |
SYSTEM_PROMPT = (
|
| 74 |
"You are a biomedical device troubleshooting assistant for clinical engineers.\n"
|
| 75 |
-
"
|
| 76 |
-
"
|
| 77 |
-
"
|
|
|
|
| 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
|
| 84 |
)
|
| 85 |
|
| 86 |
async def call_llm(user_desc: str, manual_excerpts: str = "") -> str:
|
| 87 |
-
|
| 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=
|
|
|
|
|
|
|
|
|
|
| 96 |
)
|
| 97 |
return resp.choices[0].message.content or ""
|
| 98 |
|
| 99 |
-
#
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
-
# ------------------ Chainlit handlers ------------------
|
| 107 |
WELCOME = (
|
| 108 |
-
"🛠️ **Biomedical Troubleshooting Assistant**\n"
|
| 109 |
-
"Describe the **device & symptom**
|
| 110 |
-
"
|
| 111 |
-
"Education-only.
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 157 |
-
if not text:
|
| 158 |
-
await cl.Message(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
return
|
| 160 |
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
if manual and manual.get("pages"):
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 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,
|
| 176 |
except Exception as e:
|
| 177 |
await cl.Message(content=f"⚠️ LLM call failed ({PROVIDER}): {e}").send()
|
| 178 |
return
|
| 179 |
|
| 180 |
-
|
| 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()
|