ibadhasnain commited on
Commit
36f3ff8
·
verified ·
1 Parent(s): 69d965c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +490 -232
app.py CHANGED
@@ -1,8 +1,10 @@
1
  # ---------------------------------------------------------
2
- # Chainlit app (Method A)
3
- # Project: Anatomy & Physiology Tutor + /gen Diagram (SD-XL)
 
 
4
  # ---------------------------------------------------------
5
- import os, json, time
6
  from dataclasses import dataclass, field
7
  from typing import Any, Callable, Dict, List, Optional
8
 
@@ -12,9 +14,10 @@ from pydantic import BaseModel
12
  from openai import AsyncOpenAI as _SDKAsyncOpenAI
13
  from huggingface_hub import InferenceClient
14
  from PIL import Image
 
15
 
16
  # =============================
17
- # Inline "agents" shim (no extra package needed)
18
  # =============================
19
  def set_tracing_disabled(disabled: bool = True):
20
  return disabled
@@ -23,29 +26,18 @@ def function_tool(func: Callable):
23
  func._is_tool = True
24
  return func
25
 
26
- def handoff(*args, **kwargs):
27
- return None
28
-
29
- class InputGuardrail:
30
- def __init__(self, guardrail_function: Callable):
31
- self.guardrail_function = guardrail_function
32
-
33
  @dataclass
34
  class GuardrailFunctionOutput:
35
  output_info: Any
36
  tripwire_triggered: bool = False
37
  tripwire_message: str = ""
38
 
39
- class InputGuardrailTripwireTriggered(Exception):
40
- pass
41
-
42
  class AsyncOpenAI:
43
  def __init__(self, api_key: str, base_url: Optional[str] = None):
44
  kwargs = {"api_key": api_key}
45
  if base_url:
46
  kwargs["base_url"] = base_url
47
  self._client = _SDKAsyncOpenAI(**kwargs)
48
-
49
  @property
50
  def client(self):
51
  return self._client
@@ -61,10 +53,6 @@ class Agent:
61
  instructions: str
62
  model: OpenAIChatCompletionsModel
63
  tools: Optional[List[Callable]] = field(default_factory=list)
64
- handoff_description: Optional[str] = None
65
- output_type: Optional[type] = None
66
- input_guardrails: Optional[List[InputGuardrail]] = field(default_factory=list)
67
-
68
  def tool_specs(self) -> List[Dict[str, Any]]:
69
  specs = []
70
  for t in (self.tools or []):
@@ -95,8 +83,6 @@ class Runner:
95
  ]
96
  tools = agent.tool_specs()
97
  tool_map = {t.__name__: t for t in (agent.tools or []) if getattr(t, "_is_tool", False)}
98
-
99
- # up to 4 tool-use rounds
100
  for _ in range(4):
101
  resp = await agent.model.client.chat.completions.create(
102
  model=agent.model.model,
@@ -107,41 +93,29 @@ class Runner:
107
  choice = resp.choices[0]
108
  msg = choice.message
109
  msgs.append({"role": "assistant", "content": msg.content or "", "tool_calls": msg.tool_calls})
110
-
111
  if msg.tool_calls:
112
  for call in msg.tool_calls:
113
  fn_name = call.function.name
114
  args = json.loads(call.function.arguments or "{}")
 
115
  if fn_name in tool_map:
116
  try:
117
  result = tool_map[fn_name](**args)
118
  except Exception as e:
119
  result = {"error": str(e)}
120
- else:
121
- result = {"error": f"Unknown tool: {fn_name}"}
122
  msgs.append({
123
  "role": "tool",
124
  "tool_call_id": call.id,
125
  "name": fn_name,
126
  "content": json.dumps(result),
127
  })
128
- continue # next round with tool outputs
129
-
130
  final_text = msg.content or ""
131
  final_obj = type("Result", (), {})()
132
  final_obj.final_output = final_text
133
  final_obj.context = context or {}
134
- if agent.output_type and issubclass(agent.output_type, BaseModel):
135
- try:
136
- data = agent.output_type.model_validate_json(final_text)
137
- final_obj.final_output = data.model_dump_json()
138
- final_obj.final_output_as = lambda t: data
139
- except Exception:
140
- final_obj.final_output_as = lambda t: final_text
141
- else:
142
- final_obj.final_output_as = lambda t: final_text
143
  return final_obj
144
-
145
  final_obj = type("Result", (), {})()
146
  final_obj.final_output = "Sorry, I couldn't complete the request."
147
  final_obj.context = context or {}
@@ -149,7 +123,7 @@ class Runner:
149
  return final_obj
150
 
151
  # =============================
152
- # App configuration
153
  # =============================
154
  load_dotenv()
155
  API_KEY = os.environ.get("GEMINI_API_KEY") or os.environ.get("OPENAI_API_KEY")
@@ -158,230 +132,322 @@ if not API_KEY:
158
  HF_TOKEN = os.environ.get("HF_TOKEN") # for SD-XL generation
159
 
160
  set_tracing_disabled(True)
161
-
162
  external_client: AsyncOpenAI = AsyncOpenAI(
163
  api_key=API_KEY,
164
  base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
165
  )
166
- llm_model: OpenAIChatCompletionsModel = OpenAIChatCompletionsModel(
167
- model="gemini-2.5-flash",
168
- openai_client=external_client,
169
- )
170
 
171
  # =============================
172
- # A&P tools
173
  # =============================
174
- @function_tool
175
- def topic_reference_guide(topic: str) -> dict:
176
- """
177
- Educational bullets for common Anatomy & Physiology topics.
178
- Returns dict with anatomy, physiology, misconceptions, study_tips.
179
- """
180
- t = (topic or "").lower()
181
-
182
- def pack(anat, phys, misc, tips):
183
- return {"anatomy": anat, "physiology": phys, "misconceptions": misc, "study_tips": tips}
 
 
 
 
184
 
185
- if any(k in t for k in ["cardiac conduction", "sa node", "av node", "bundle", "purkinje", "ecg"]):
 
 
 
 
 
 
 
 
 
186
  return pack(
187
- anat=[
188
- "SA node (RA) AV node → His bundle → R/L bundle branches → Purkinje fibers.",
189
- "Fibrous skeleton insulates atria from ventricles; AV node is the gateway."
190
- ],
191
- phys=[
192
- "Pacemaker automaticity (If current) sets heart rate.",
193
- "AV nodal delay allows ventricular filling; His–Purkinje enables synchronized contraction."
194
- ],
195
- misc=[
196
- "Misconception: all tissues pace equally — SA node dominates.",
197
- "PR interval ≈ AV nodal delay."
198
- ],
199
- tips=[
200
- "Map ECG waves to mechanics (P/QRS/T).",
201
- "Relate ion channels to nodal vs myocyte AP phases."
202
- ],
203
  )
204
-
205
- if any(k in t for k in ["nephron", "kidney", "gfr", "raas", "countercurrent"]):
206
  return pack(
207
- anat=[
208
- "Segments: Bowman’s PCT → DLH → ALH (thin/thick) DCT → collecting duct.",
209
- "Cortex (glomeruli/PCT/DCT) vs medulla (loops/collecting ducts)."
210
- ],
211
- phys=[
212
- "GFR via Starling forces; PCT bulk reabsorption; ALH generates gradient; DCT/CD fine-tune (ADH/aldosterone).",
213
- "Countercurrent multiplication + vasa recta maintain medullary gradient."
214
- ],
215
- misc=[
216
- "Misconception: water reabsorption is equal everywhere — hormones control CD concentration.",
217
- "Urea also supports medullary osmolality."
218
- ],
219
- tips=[
220
- "Sketch transporters per segment.",
221
- "Practice ‘what if’ with ↑ADH/↑Aldosterone/���GFR."
222
- ],
223
  )
224
-
225
- if any(k in t for k in ["alveolar", "gas exchange", "v/q", "ventilation perfusion", "oxygen dissociation"]):
226
  return pack(
227
- anat=[
228
- "Conducting vs respiratory zones; Type I (exchange) vs Type II (surfactant) pneumocytes."
229
- ],
230
- phys=[
231
- "V/Q matching optimizes exchange; extremes: dead space (high V/Q) vs shunt (low V/Q).",
232
- "O2–Hb curve shifts with pH, CO2, temp, 2,3-BPG (Bohr effect)."
233
- ],
234
- misc=[
235
- "Misconception: uniform V/Q across lung — gravity/disease alter distribution.",
236
- "Assuming PaO2–SaO2 linearity (it’s sigmoidal)."
237
- ],
238
- tips=[
239
- "Draw V/Q along lung height.",
240
- "Link spirometry patterns to mechanics."
241
- ],
242
  )
243
-
244
- if any(k in t for k in ["neuron", "synapse", "action potential", "neurotransmitter"]):
245
  return pack(
246
- anat=[
247
- "Neuron: dendrites, soma, axon hillock, axon; myelin & nodes."
248
- ],
249
- phys=[
250
- "AP: Na+ depolarization K+ repolarization; refractory periods.",
251
- "Chemical synapse: Ca2+-dependent vesicle release; EPSPs/IPSPs summate."
252
- ],
253
- misc=[
254
- "Misconception: any EPSP fires AP — threshold & summation matter."
255
- ],
256
- tips=[
257
- "Relate channel states to AP phases.",
258
- "Compare ionotropic vs metabotropic effects."
259
- ],
260
  )
261
-
262
- if any(k in t for k in ["muscle contraction", "excitation contraction", "sarcomere"]):
263
  return pack(
264
- anat=[
265
- "Sarcomere: Z to Z; thin (actin/troponin/tropomyosin) vs thick (myosin).",
266
- "T-tubules & SR triads (skeletal)."
267
- ],
268
- phys=[
269
- "AP → DHPR → RyR → Ca2+ release → troponin C → cross-bridge cycling.",
270
- "Force–length & force–velocity relationships; motor unit recruitment."
271
- ],
272
- misc=[
273
- "ATP also needed for detachment & Ca2+ resequestration."
274
- ],
275
- tips=[
276
- "Diagram cross-bridge cycle.",
277
- "Predict effects of length on force."
278
- ],
279
  )
280
-
281
  return pack(
282
- anat=["Identify major structures & relationships."],
283
- phys=["Describe inputs key steps → outputs; control loops."],
284
- misc=["Clarify commonly conflated terms; distinguish variation from pathology (education-only)."],
285
- tips=["Make a labeled sketch; use ‘if-this-then-that’ scenarios to test understanding."],
 
286
  )
287
 
288
  @function_tool
289
- def study_outline(topic: str) -> dict:
290
- """Return a suggested outline and practice prompts for the topic."""
291
- return {
292
- "topic": topic,
293
- "subtopics": [
294
- "Key structures & relationships",
295
- "Mechanism (step-by-step)",
296
- "Control & feedback",
297
- "Quantitative intuition (flows, pressures, potentials)",
298
- "Common misconceptions"
299
- ],
300
- "practice_prompts": [
301
- f"Explain {topic} in 5 steps to a first-year student.",
302
- f"Draw a block diagram of {topic} with inputs/outputs.",
303
- f"What changes if a key parameter increases/decreases in {topic}?",
304
- ],
305
- }
 
 
 
 
 
 
 
 
 
306
 
307
  # =============================
308
- # Agents
309
  # =============================
310
- tutor_instructions = (
311
- "You are an Anatomy & Physiology Tutor. TEACH, do not diagnose.\n"
312
- "Given a topic (e.g., 'cardiac conduction', 'nephron physiology'), produce concise bullet points:\n"
313
- "1) Anatomy (structures/relationships)\n"
314
- "2) Physiology (inputs steps → outputs)\n"
315
- "3) Common misconceptions\n"
316
- "4) Study tips\n"
317
- "5) Caution: education only, no diagnosis\n"
318
- "Use tools (topic_reference_guide, study_outline) to ground the response.\n"
319
- "Avoid clinical diagnosis or treatment advice."
320
  )
321
- tutor_agent = Agent(
322
- name="A&P Tutor",
323
- instructions=tutor_instructions,
324
  model=llm_model,
325
- tools=[topic_reference_guide, study_outline],
326
  )
327
 
328
- guardrail_agent = Agent(
329
- name="Safety Classifier",
330
  instructions=(
331
- "Classify if the user's message requests medical diagnosis or unsafe medical advice, "
332
- "and if it includes personal identifiers. Respond as JSON with fields: "
333
- "{unsafe_medical_advice: bool, requests_diagnosis: bool, pii_included: bool, reasoning: string}."
 
 
 
 
 
 
 
 
334
  ),
335
  model=llm_model,
336
  )
337
 
338
  # =============================
339
- # SD-XL helper (HF Inference API) -> PNG path
340
  # =============================
341
  def sdxl_png(prompt: str, negative: str = "") -> str:
342
  token = os.getenv("HF_TOKEN")
343
  if not token:
344
  raise RuntimeError("Missing HF_TOKEN. Add it in Space secrets.")
345
  client = InferenceClient("stabilityai/stable-diffusion-xl-base-1.0", token=token)
346
-
347
  img: Image.Image = client.text_to_image(
348
- prompt = prompt + " | educational, clean vector style, white background, no labels, safe-for-work",
349
  negative_prompt = negative or "text, watermark, logo, gore, photorealistic patient, clutter",
350
  width = 1024, height = 768,
351
  guidance_scale = 7.5,
352
- num_inference_steps = 30,
353
  seed = 42
354
  )
355
  out_dir = os.environ.get("CHAINLIT_FILES_DIR") or os.path.join(os.getcwd(), ".files")
356
  os.makedirs(out_dir, exist_ok=True)
357
- path = os.path.join(out_dir, f"sdxl-{int(time.time())}.png")
358
  img.save(path)
359
  return path
360
 
361
  # =============================
362
- # Chainlit flows
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  # =============================
364
  WELCOME = (
365
- "🧠 **Anatomy & Physiology Tutor** + 🎨 **/gen Diagram (SD-XL)**\n\n"
366
- " Ask any A&P topic (e.g., *cardiac conduction*, *nephron physiology*, *gas exchange*).\n"
367
- " Generate a clean educational diagram (PNG) with:\n"
368
- " `/gen isometric nephron diagram, flat vector, white background, no labels`\n\n"
369
- "⚠️ Education only — no diagnosis or clinical advice."
370
  )
371
 
372
  @cl.on_chat_start
373
  async def on_chat_start():
 
374
  await cl.Message(content=WELCOME).send()
 
375
 
376
  @cl.on_message
377
  async def on_message(message: cl.Message):
378
  text = (message.content or "").strip()
379
 
380
- # Quick command: /gen <desc> → SD-XL PNG
381
  if text.lower().startswith("/gen "):
382
  desc = text[5:].strip()
383
  if not desc:
384
- await cl.Message(content="Please provide a description after `/gen`.\nExample: `/gen isometric nephron diagram, flat vector`").send()
385
  return
386
  try:
387
  path = sdxl_png(desc)
@@ -389,58 +455,250 @@ async def on_message(message: cl.Message):
389
  await cl.Message(content=f"Generation failed: {e}").send()
390
  return
391
  await cl.Message(
392
- content="🎨 **Generated diagram** (education only):",
393
  elements=[cl.Image(path=path, name=os.path.basename(path), display="inline")],
394
  ).send()
395
  return
396
 
397
- # Light safety check (blocks diagnosis/treatment requests)
398
  try:
399
- safety = await Runner.run(guardrail_agent, text)
400
- parsed = safety.final_output
401
- try:
402
- data = json.loads(parsed) if isinstance(parsed, str) else parsed
403
- except Exception:
404
- data = {}
405
- if isinstance(data, dict) and (data.get("unsafe_medical_advice") or data.get("requests_diagnosis")):
 
 
 
406
  await cl.Message(
407
- content="🚫 I can’t provide diagnosis or treatment advice. I can teach A&P concepts and generate **educational** diagrams."
 
408
  ).send()
 
409
  return
410
  except Exception:
411
  pass
412
 
413
- # Tutor mode
414
- topic = text or "anatomy and physiology overview"
415
- result = await Runner.run(tutor_agent, topic)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
 
417
- # Tool-based quick reference + outline
418
- try:
419
- guide = topic_reference_guide(topic)
420
- except Exception:
421
- guide = {}
422
- try:
423
- outline = study_outline(topic)
424
- except Exception:
425
- outline = {}
 
 
 
 
426
 
427
- def bullets(arr):
428
- return "\n".join([f"- {b}" for b in arr]) if isinstance(arr, list) else "- (n/a)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
 
430
- quick_ref = (
431
- f"### 📚 Quick Reference: {topic}\n"
432
- f"**Anatomy**\n{bullets(guide.get('anatomy', []))}\n\n"
433
- f"**Physiology**\n{bullets(guide.get('physiology', []))}\n\n"
434
- f"**Common Misconceptions**\n{bullets(guide.get('misconceptions', []))}\n\n"
435
- f"**Study Tips**\n{bullets(guide.get('study_tips', []))}\n\n"
436
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
 
438
- outline_md = ""
439
- if outline:
440
- subs = bullets(outline.get("subtopics", []))
441
- qs = bullets(outline.get("practice_prompts", []))
442
- outline_md = f"### 🗂️ Suggested Outline\n{subs}\n\n### 📝 Practice Prompts\n{qs}\n\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
 
444
- caution = "> ⚠️ Education only no diagnosis or treatment advice."
445
- answer = result.final_output or "I couldn’t generate an explanation."
446
- await cl.Message(content=f"{quick_ref}{outline_md}---\n{answer}\n\n{caution}").send()
 
 
 
1
  # ---------------------------------------------------------
2
+ # Ayla Biomedical Device Troubleshooting Assistant
3
+ # Now with: Upload Device Manual (PDF/TXT) search relevant pages
4
+ # Refined, on-task, “real-person” tone. Education-only.
5
+ # Flow: device -> symptom -> context/manual -> triage plan (+ optional /gen PNG)
6
  # ---------------------------------------------------------
7
+ import os, json, time, re, io
8
  from dataclasses import dataclass, field
9
  from typing import Any, Callable, Dict, List, Optional
10
 
 
14
  from openai import AsyncOpenAI as _SDKAsyncOpenAI
15
  from huggingface_hub import InferenceClient
16
  from PIL import Image
17
+ from pypdf import PdfReader # ← new: PDF text extraction
18
 
19
  # =============================
20
+ # Inline lightweight "agents" shim
21
  # =============================
22
  def set_tracing_disabled(disabled: bool = True):
23
  return disabled
 
26
  func._is_tool = True
27
  return func
28
 
 
 
 
 
 
 
 
29
  @dataclass
30
  class GuardrailFunctionOutput:
31
  output_info: Any
32
  tripwire_triggered: bool = False
33
  tripwire_message: str = ""
34
 
 
 
 
35
  class AsyncOpenAI:
36
  def __init__(self, api_key: str, base_url: Optional[str] = None):
37
  kwargs = {"api_key": api_key}
38
  if base_url:
39
  kwargs["base_url"] = base_url
40
  self._client = _SDKAsyncOpenAI(**kwargs)
 
41
  @property
42
  def client(self):
43
  return self._client
 
53
  instructions: str
54
  model: OpenAIChatCompletionsModel
55
  tools: Optional[List[Callable]] = field(default_factory=list)
 
 
 
 
56
  def tool_specs(self) -> List[Dict[str, Any]]:
57
  specs = []
58
  for t in (self.tools or []):
 
83
  ]
84
  tools = agent.tool_specs()
85
  tool_map = {t.__name__: t for t in (agent.tools or []) if getattr(t, "_is_tool", False)}
 
 
86
  for _ in range(4):
87
  resp = await agent.model.client.chat.completions.create(
88
  model=agent.model.model,
 
93
  choice = resp.choices[0]
94
  msg = choice.message
95
  msgs.append({"role": "assistant", "content": msg.content or "", "tool_calls": msg.tool_calls})
 
96
  if msg.tool_calls:
97
  for call in msg.tool_calls:
98
  fn_name = call.function.name
99
  args = json.loads(call.function.arguments or "{}")
100
+ result = {"error": f"Unknown tool: {fn_name}"}
101
  if fn_name in tool_map:
102
  try:
103
  result = tool_map[fn_name](**args)
104
  except Exception as e:
105
  result = {"error": str(e)}
 
 
106
  msgs.append({
107
  "role": "tool",
108
  "tool_call_id": call.id,
109
  "name": fn_name,
110
  "content": json.dumps(result),
111
  })
112
+ continue
 
113
  final_text = msg.content or ""
114
  final_obj = type("Result", (), {})()
115
  final_obj.final_output = final_text
116
  final_obj.context = context or {}
117
+ final_obj.final_output_as = lambda t: final_text
 
 
 
 
 
 
 
 
118
  return final_obj
 
119
  final_obj = type("Result", (), {})()
120
  final_obj.final_output = "Sorry, I couldn't complete the request."
121
  final_obj.context = context or {}
 
123
  return final_obj
124
 
125
  # =============================
126
+ # Config & model clients
127
  # =============================
128
  load_dotenv()
129
  API_KEY = os.environ.get("GEMINI_API_KEY") or os.environ.get("OPENAI_API_KEY")
 
132
  HF_TOKEN = os.environ.get("HF_TOKEN") # for SD-XL generation
133
 
134
  set_tracing_disabled(True)
 
135
  external_client: AsyncOpenAI = AsyncOpenAI(
136
  api_key=API_KEY,
137
  base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
138
  )
139
+ llm_model = OpenAIChatCompletionsModel("gemini-2.5-flash", external_client)
 
 
 
140
 
141
  # =============================
142
+ # Catalog & options
143
  # =============================
144
+ DEVICE_CATALOG: Dict[str, Dict[str, List[str]]] = {
145
+ "ECG": {"symptoms": ["No signal", "Noisy baseline", "Leads off", "Paper/feed issue", "Inaccurate HR", "Other"]},
146
+ "Pulse oximeter": {"symptoms": ["Erratic SpO2", "Low signal", "No reading", "Probe damage", "Other"]},
147
+ "Infusion pump": {"symptoms": ["Occlusion alarm", "Air-in-line", "Inaccurate volume", "Battery issue", "Other"]},
148
+ "Ventilator": {"symptoms": ["Leak alarm", "High pressure alarm", "Sensor fault", "Other"]},
149
+ "Defibrillator": {"symptoms": ["Self-test fail", "Charging issue", "Pads/leads issue", "Other"]},
150
+ "Patient monitor": {"symptoms": ["NIBP error", "ECG noise", "SpO2 dropout", "Temp probe fault", "Other"]},
151
+ "Spirometer": {"symptoms": ["Fails 3L syringe", "No flow", "Inaccurate volumes", "Other"]},
152
+ "Glucometer": {"symptoms": ["Inconsistent readings", "Strip error", "No power", "Other"]},
153
+ "Suction machine": {"symptoms": ["Low suction", "Motor noise", "Overheating", "Other"]},
154
+ "Thermometer": {"symptoms": ["No reading", "Inaccurate", "Battery issue", "Other"]},
155
+ "Other device": {"symptoms": ["Other"]},
156
+ }
157
+ LIFE_CRITICAL = {"Ventilator", "Defibrillator", "Infusion pump"}
158
 
159
+ # =============================
160
+ # Tools (education-only; non-invasive)
161
+ # =============================
162
+ @function_tool
163
+ def device_reference_guide(device: str) -> dict:
164
+ """Safety-first notes, common faults, non-invasive checks, QC reminders, and escalation cues (education-only)."""
165
+ d = (device or "").lower()
166
+ def pack(safety, faults, checks, qc, escalate):
167
+ return {"safety": safety, "common_faults": faults, "quick_checks": checks, "qc_calibration": qc, "escalate_if": escalate}
168
+ if "ecg" in d:
169
  return pack(
170
+ ["Disconnect from mains before handling patient cables. No invasive service steps."],
171
+ ["Leads off/dry electrodes","Motion/EMI","Incorrect lead placement","Broken wires/high impedance"],
172
+ ["Verify lead map & skin prep","Replace electrodes","Route away from mains","Known-good lead set"],
173
+ ["Simulator: 1 mV @ 10 mm/mV","Check paper speed/gain","Electrical safety tests per schedule"],
174
+ ["Abnormal baseline with simulator","Failed safety tests","Burning smell/liquid ingress"]
 
 
 
 
 
 
 
 
 
 
 
175
  )
176
+ if "pulse" in d or "oxi" in d:
 
177
  return pack(
178
+ ["If patient-connected, assess clinically first; avoid opening casing."],
179
+ ["Poor perfusion/cold extremities","Motion","Nail polish/ambient light","Probe cable damage"],
180
+ ["Warm extremity / try another digit","Remove polish / shield light","Inspect/ reseat probe / swap known-good"],
181
+ ["Daily function check/simulator if available","Document vs reference"],
182
+ ["Erratic with known-good probe","Cracked housing/liquid ingress"]
 
 
 
 
 
 
 
 
 
 
 
183
  )
184
+ if "infusion" in d:
 
185
  return pack(
186
+ ["If connected: place patient on backup before checks. Never bypass alarms."],
187
+ ["Wrong IV set type","Occlusion up/downstream","Door not latched","Air in line"],
188
+ ["Verify set type","Reload tubing per OEM","Check clamps/kinks","Prime to remove air"],
189
+ ["Volumetric accuracy & occlusion tests per schedule"],
190
+ ["Inaccurate on gravimetric test","Alarm malfunction","Physical damage"]
 
 
 
 
 
 
 
 
 
 
191
  )
192
+ if "vent" in d:
 
193
  return pack(
194
+ ["Life-support. Do NOT troubleshoot while attached to a patient."],
195
+ ["Circuit leaks","Blocked filters","Sensor faults","Config mismatch"],
196
+ ["Self-test","Inspect filters/circuit","Swap known-good sensors","Confirm modes/settings"],
197
+ ["Full analyzer checks per schedule","Electrical safety"],
198
+ ["Any failed self-test/analyzer","Damage or unknown error code"]
 
 
 
 
 
 
 
 
 
199
  )
200
+ if "defib" in d:
 
201
  return pack(
202
+ ["Life-support. Only qualified personnel; no invasive steps."],
203
+ ["Pads/leads/power issues","Self-test failure"],
204
+ ["Run self-test","Verify batteries/pads/leads","Inspect connections"],
205
+ ["Output energy verification with analyzer","Safety tests per schedule"],
206
+ ["Failed analyzer/self-test","Damage/liquid ingress"]
 
 
 
 
 
 
 
 
 
 
207
  )
 
208
  return pack(
209
+ ["If attached to patient, use backup first. Do not open device. Follow OEM policy."],
210
+ ["Power/battery issues","Loose/ damaged cables","Config mismatch","Environmental interference"],
211
+ ["Power cycle","Verify mains/battery","Reseat all connectors","Swap accessories","Restore defaults if safe"],
212
+ ["Scheduled QC/ES tests; verify with simulators if available"],
213
+ ["Persistent faults","Safety test failures","Burning smell/liquid ingress/cracks"]
214
  )
215
 
216
  @function_tool
217
+ def symptom_checklist(device: str, symptom: str) -> dict:
218
+ """Targeted, non-invasive checklist for a device & symptom (education-only)."""
219
+ s = (symptom or "").lower()
220
+ steps = [
221
+ "Record model/serial/firmware; note exact error codes/messages.",
222
+ "Power/battery indicators OK; try another outlet; reseat battery if user-removable.",
223
+ "Inspect/replace accessories; reseat connectors; swap to known-good if available.",
224
+ "Confirm configuration/profile matches clinical setup.",
225
+ ]
226
+ if any(k in s for k in ["noise", "artifact", "interference"]):
227
+ steps += ["Minimize motion/EMI; route patient cables away from mains; proper skin prep if applicable."]
228
+ if any(k in s for k in ["no signal", "no reading"]):
229
+ steps += ["Try alternate sensor/lead; test with simulator/reference if available."]
230
+ if any(k in s for k in ["inaccurate", "wrong", "drift"]):
231
+ steps += ["Verify against simulator/reference; perform user-level zeroing if in OEM user manual."]
232
+ if any(k in s for k in ["alarm", "error"]):
233
+ steps += ["Do NOT bypass alarms; log alarm details; check OEM quick-ref."]
234
+ return {"device": device, "symptom": symptom, "steps": steps}
235
+
236
+ @function_tool
237
+ def escalation_policy(device: str) -> dict:
238
+ """Escalation line by device criticality."""
239
+ name = (device or "")
240
+ if any(x.lower() in name.lower() for x in LIFE_CRITICAL):
241
+ return {"policy": "Life-critical pathway: remove from service immediately, tag DO NOT USE, escalate to Biomedical Engineering/OEM."}
242
+ return {"policy": "If issues persist after basic checks or any safety/QC test fails, remove from service and escalate to Biomedical Engineering."}
243
 
244
  # =============================
245
+ # Persona & Guardrails
246
  # =============================
247
+ ayla_instructions = (
248
+ "You are Ayla, a calm, concise biomedical device troubleshooting assistant for clinical engineers. "
249
+ "Stay strictly on task: only biomedical devices troubleshooting. "
250
+ "Prohibit diagnosis, treatment, invasive repairs, alarm bypass, firmware hacks, or collecting PHI. "
251
+ "Speak naturally, tight and actionable. "
252
+ "Output sections in this order: 1) Safety First 2) Likely Causes 3) Step-by-Step Checks 4) QC/Calibration 5) Escalate When. "
253
+ "If manual excerpts are provided in the prompt, use them but defer to OEM manual if any conflict. "
254
+ "End with a one-line summary. Education-only; refer to OEM manuals and policy."
 
 
255
  )
256
+ ayla_agent = Agent(
257
+ name="Ayla",
258
+ instructions=ayla_instructions,
259
  model=llm_model,
260
+ tools=[device_reference_guide, symptom_checklist, escalation_policy],
261
  )
262
 
263
+ offtopic_guard = Agent(
264
+ name="Scope Guard",
265
  instructions=(
266
+ "Classify if the message is within scope of biomedical device troubleshooting. "
267
+ "Return JSON: {in_scope: true/false, reason: '...'}"
268
+ ),
269
+ model=llm_model,
270
+ )
271
+
272
+ safety_guard = Agent(
273
+ name="Safety Guard",
274
+ instructions=(
275
+ "If the message asks for diagnosis, treatment, invasive repair, bypassing safety/alarms, hacking firmware, or PHI collection, "
276
+ "return JSON {unsafe: true, reason: '...'} else {unsafe: false}."
277
  ),
278
  model=llm_model,
279
  )
280
 
281
  # =============================
282
+ # SD-XL helper -> PNG path
283
  # =============================
284
  def sdxl_png(prompt: str, negative: str = "") -> str:
285
  token = os.getenv("HF_TOKEN")
286
  if not token:
287
  raise RuntimeError("Missing HF_TOKEN. Add it in Space secrets.")
288
  client = InferenceClient("stabilityai/stable-diffusion-xl-base-1.0", token=token)
 
289
  img: Image.Image = client.text_to_image(
290
+ prompt = prompt + " | clean vector style, white background, no text labels, educational, safe-for-work",
291
  negative_prompt = negative or "text, watermark, logo, gore, photorealistic patient, clutter",
292
  width = 1024, height = 768,
293
  guidance_scale = 7.5,
294
+ num_inference_steps = 24,
295
  seed = 42
296
  )
297
  out_dir = os.environ.get("CHAINLIT_FILES_DIR") or os.path.join(os.getcwd(), ".files")
298
  os.makedirs(out_dir, exist_ok=True)
299
+ path = os.path.join(out_dir, f"sdxl-troubleshoot-{int(time.time())}.png")
300
  img.save(path)
301
  return path
302
 
303
  # =============================
304
+ # Manual upload, indexing, and search
305
+ # =============================
306
+ def sanitize_short(text: str, maxlen: int = 80) -> str:
307
+ t = re.sub(r"\s+", " ", (text or "")).strip()
308
+ return t[:maxlen]
309
+
310
+ def _extract_pdf_pages(data: bytes) -> List[Dict[str, Any]]:
311
+ reader = PdfReader(io.BytesIO(data))
312
+ pages = []
313
+ for i, pg in enumerate(reader.pages, start=1):
314
+ try:
315
+ txt = pg.extract_text() or ""
316
+ except Exception:
317
+ txt = ""
318
+ pages.append({"page": i, "text": txt})
319
+ return pages
320
+
321
+ def _extract_txt_pages(data: bytes, chunk_chars: int = 1400) -> List[Dict[str, Any]]:
322
+ try:
323
+ txt = data.decode("utf-8", errors="ignore")
324
+ except Exception:
325
+ txt = ""
326
+ chunks = []
327
+ i = 0
328
+ while i < len(txt):
329
+ chunk = txt[i:i+chunk_chars]
330
+ chunks.append({"page": len(chunks)+1, "text": chunk})
331
+ i += chunk_chars
332
+ return chunks
333
+
334
+ def _manual_search(pages: List[Dict[str, Any]], query: str, topk: int = 3) -> List[Dict[str, Any]]:
335
+ if not pages:
336
+ return []
337
+ terms = [w for w in re.findall(r"\w+", (query or "").lower()) if len(w) > 2]
338
+ scored = []
339
+ for p in pages:
340
+ low = p.get("text","").lower()
341
+ score = sum(low.count(t) for t in terms)
342
+ if score > 0:
343
+ scored.append((score, p))
344
+ scored.sort(key=lambda x: x[0], reverse=True)
345
+ return [p for _, p in scored[:topk]] or (pages[:1]) # fallback first page
346
+
347
+ def _make_excerpt(text: str, terms: List[str], window: int = 380) -> str:
348
+ t = (text or "")
349
+ low = t.lower()
350
+ idxs = [low.find(term) for term in terms if term in low]
351
+ start = max(0, min([i for i in idxs if i >= 0], default=0) - window)
352
+ end = min(len(t), start + 2*window)
353
+ return re.sub(r"\s+", " ", t[start:end]).strip()
354
+
355
+ # =============================
356
+ # UI helpers
357
+ # =============================
358
+ async def present_device_menu():
359
+ actions = [cl.Action(name=f"dev_{i}", value=name, label=name) for i, name in enumerate(DEVICE_CATALOG.keys(), 1)]
360
+ return await cl.AskActionMessage(
361
+ content="👋 I’m **Ayla**. Let’s keep this tight and practical.\n\n**Pick a device** to start:",
362
+ actions=actions,
363
+ timeout=180,
364
+ ).send()
365
+
366
+ async def present_symptom_menu(device_name: str):
367
+ options = DEVICE_CATALOG.get(device_name, {}).get("symptoms", ["Other"])
368
+ actions = [cl.Action(name=f"sym_{i}", value=s, label=s) for i, s in enumerate(options, 1)]
369
+ return await cl.AskActionMessage(
370
+ content=f"**Device:** {device_name}\n\nSelect the **symptom**:",
371
+ actions=actions,
372
+ timeout=180,
373
+ ).send()
374
+
375
+ async def present_context_menu(device_name: str, symptom: str, manual_loaded: bool):
376
+ actions = [
377
+ cl.Action(name="ctx_patient_connected", value="patient_connected", label="Patient currently connected?"),
378
+ cl.Action(name="ctx_tried_accessories", value="tried_accessories", label="Already swapped accessories?"),
379
+ cl.Action(name="ctx_env_checked", value="env_checked", label="Checked power/EMI/filters?"),
380
+ cl.Action(name="ctx_upload_manual", value="upload_manual", label="Upload Manual (PDF/TXT)"),
381
+ ]
382
+ if manual_loaded:
383
+ actions += [
384
+ cl.Action(name="ctx_view_hits", value="view_hits", label="View Manual Matches"),
385
+ cl.Action(name="ctx_clear_manual", value="clear_manual", label="Remove Manual"),
386
+ ]
387
+ actions += [
388
+ cl.Action(name="ctx_start", value="start_triage", label="Start Triage ✅"),
389
+ cl.Action(name="ctx_restart", value="restart", label="Change Device")
390
+ ]
391
+ return await cl.AskActionMessage(
392
+ content=f"**Device:** {device_name}\n**Symptom:** {symptom}\n\nAdd quick context or **upload the device manual**, then **Start Triage**:",
393
+ actions=actions,
394
+ timeout=240,
395
+ ).send()
396
+
397
+ def session_reset():
398
+ cl.user_session.set("stage", "await_device")
399
+ cl.user_session.set("device", None)
400
+ cl.user_session.set("symptom", None)
401
+ cl.user_session.set("flags", set())
402
+ cl.user_session.set("manual", None) # {"name": str, "pages": List[dict]}
403
+
404
+ def _manual_info() -> str:
405
+ m = cl.user_session.get("manual")
406
+ if not m:
407
+ return "No manual attached."
408
+ return f"Manual: **{m.get('name','(unnamed)')}** — {len(m.get('pages',[]))} page-chunks indexed."
409
+
410
+ # =============================
411
+ # Guards
412
+ # =============================
413
+ def is_off_topic_label(json_str: str) -> bool:
414
+ try:
415
+ data = json.loads(json_str) if isinstance(json_str, str) else json_str
416
+ return not bool(data.get("in_scope", False))
417
+ except Exception:
418
+ return False
419
+
420
+ def is_unsafe(json_str: str) -> bool:
421
+ try:
422
+ data = json.loads(json_str) if isinstance(json_str, str) else json_str
423
+ return bool(data.get("unsafe", False))
424
+ except Exception:
425
+ return False
426
+
427
+ # =============================
428
+ # Chat flow
429
  # =============================
430
  WELCOME = (
431
+ "🛠️ **Ayla Biomedical Device Troubleshooting Assistant**\n"
432
+ "Education-only. No diagnosis, no invasive service, no alarm bypass. OEM manual & policy rule the day.\n\n"
433
+ "Tip: Generate a simple diagram anytime: `/gen flowchart for pulse oximeter troubleshooting`"
 
 
434
  )
435
 
436
  @cl.on_chat_start
437
  async def on_chat_start():
438
+ session_reset()
439
  await cl.Message(content=WELCOME).send()
440
+ await present_device_menu()
441
 
442
  @cl.on_message
443
  async def on_message(message: cl.Message):
444
  text = (message.content or "").strip()
445
 
446
+ # Slash command: /gen
447
  if text.lower().startswith("/gen "):
448
  desc = text[5:].strip()
449
  if not desc:
450
+ await cl.Message(content="Usage: `/gen flowchart for <device> troubleshooting`").send()
451
  return
452
  try:
453
  path = sdxl_png(desc)
 
455
  await cl.Message(content=f"Generation failed: {e}").send()
456
  return
457
  await cl.Message(
458
+ content="🎨 **Generated diagram** (education-only):",
459
  elements=[cl.Image(path=path, name=os.path.basename(path), display="inline")],
460
  ).send()
461
  return
462
 
463
+ # Scope & safety checks
464
  try:
465
+ scope = await Runner.run(offtopic_guard, text)
466
+ if is_off_topic_label(scope.final_output):
467
+ await cl.Message(content="Let’s stay focused on **biomedical device troubleshooting**. Please pick a device below.").send()
468
+ await present_device_menu()
469
+ return
470
+ except Exception:
471
+ pass
472
+ try:
473
+ unsafe = await Runner.run(safety_guard, text)
474
+ if is_unsafe(unsafe.final_output):
475
  await cl.Message(
476
+ content="🚫 I can’t help with diagnosis, treatment, invasive repairs, alarm bypass, firmware hacks, or collecting personal data. "
477
+ "Let’s stick to **safe, non-invasive troubleshooting**. Pick a device again:"
478
  ).send()
479
+ await present_device_menu()
480
  return
481
  except Exception:
482
  pass
483
 
484
+ # State machine guardrails
485
+ stage = cl.user_session.get("stage", "await_device")
486
+ device = cl.user_session.get("device")
487
+ if stage == "await_device":
488
+ await cl.Message(content="Please **choose a device** from the buttons so I can tailor the checks.").send()
489
+ await present_device_menu()
490
+ return
491
+ if stage == "await_symptom":
492
+ await cl.Message(content=f"**Device:** {device}\nPlease **select a symptom** from the options.").send()
493
+ await present_symptom_menu(device)
494
+ return
495
+ if stage == "await_context":
496
+ # Free text here just toggles quick context flags
497
+ low = text.lower()
498
+ flags: set = cl.user_session.get("flags") or set()
499
+ if "patient" in low and "connect" in low:
500
+ flags.add("patient_connected")
501
+ if "accessor" in low or "probe" in low or "lead" in low:
502
+ flags.add("tried_accessories")
503
+ if "emi" in low or "power" in low or "filter" in low:
504
+ flags.add("env_checked")
505
+ cl.user_session.set("flags", flags)
506
+ await cl.Message(content="Noted. You can also **Upload Manual** or tap **Start Triage**.").send()
507
+ return
508
 
509
+ # Capture custom symptom typed between steps
510
+ @cl.on_message
511
+ async def capture_custom_symptom(message: cl.Message):
512
+ if cl.user_session.get("stage") != "await_custom_symptom":
513
+ return
514
+ raw = sanitize_short(message.content, 80)
515
+ if not raw or len(raw) < 3:
516
+ await cl.Message(content="Please enter a short symptom (≥ 3 chars).").send()
517
+ return
518
+ cl.user_session.set("symptom", raw)
519
+ cl.user_session.set("stage", "await_context")
520
+ manual_loaded = cl.user_session.get("manual") is not None
521
+ await present_context_menu(cl.user_session.get("device"), raw, manual_loaded)
522
 
523
+ # =============================
524
+ # Action handlers
525
+ # =============================
526
+ @cl.action_callback("*")
527
+ async def on_action(action: cl.Action):
528
+ name = action.name or ""
529
+ stage = cl.user_session.get("stage", "await_device")
530
+ device = cl.user_session.get("device")
531
+ symptom = cl.user_session.get("symptom")
532
+ flags: set = cl.user_session.get("flags") or set()
533
+ manual = cl.user_session.get("manual")
534
+
535
+ # Device selection
536
+ if name.startswith("dev_"):
537
+ device_name = action.value
538
+ cl.user_session.set("device", device_name)
539
+ cl.user_session.set("stage", "await_symptom")
540
+ await present_symptom_menu(device_name)
541
+ return
542
 
543
+ # Symptom selection
544
+ if name.startswith("sym_"):
545
+ chosen = action.value
546
+ if chosen == "Other":
547
+ await cl.Message(content="Type a short symptom (≤ 80 chars).").send()
548
+ cl.user_session.set("stage", "await_custom_symptom")
549
+ return
550
+ cl.user_session.set("symptom", chosen)
551
+ cl.user_session.set("stage", "await_context")
552
+ manual_loaded = cl.user_session.get("manual") is not None
553
+ await present_context_menu(cl.user_session.get("device"), chosen, manual_loaded)
554
+ return
555
+
556
+ # Context toggles
557
+ if name == "ctx_patient_connected":
558
+ flags.add("patient_connected")
559
+ cl.user_session.set("flags", flags)
560
+ await cl.Message(content="Marked: patient currently connected.").send()
561
+ return
562
+ if name == "ctx_tried_accessories":
563
+ flags.add("tried_accessories")
564
+ cl.user_session.set("flags", flags)
565
+ await cl.Message(content="Marked: accessories already swapped/checked.").send()
566
+ return
567
+ if name == "ctx_env_checked":
568
+ flags.add("env_checked")
569
+ cl.user_session.set("flags", flags)
570
+ await cl.Message(content="Marked: power/EMI/filters checked.").send()
571
+ return
572
 
573
+ # Upload manual
574
+ if name == "ctx_upload_manual":
575
+ files = await cl.AskFileMessage(
576
+ content="Upload the **device manual** (PDF or TXT). Max ~20 MB.\n\n" + _manual_info(),
577
+ accept=["application/pdf", "text/plain"],
578
+ max_size_mb=20,
579
+ max_files=1,
580
+ timeout=240,
581
+ ).send()
582
+ if not files:
583
+ await cl.Message(content="No file received.").send()
584
+ return
585
+ f = files[0]
586
+ data = getattr(f, "content", None)
587
+ if data is None and getattr(f, "path", None):
588
+ with open(f.path, "rb") as fh:
589
+ data = fh.read()
590
+ pages = []
591
+ try:
592
+ if f.mime == "application/pdf" or f.name.lower().endswith(".pdf"):
593
+ pages = _extract_pdf_pages(data)
594
+ else:
595
+ pages = _extract_txt_pages(data)
596
+ except Exception as e:
597
+ await cl.Message(content=f"Couldn't read the manual: {e}").send()
598
+ return
599
+ cl.user_session.set("manual", {"name": f.name, "pages": pages})
600
+ await cl.Message(content=f"✅ Manual indexed: **{f.name}** — {len(pages)} page-chunks.\nYou can **View Manual Matches** or **Start Triage**.").send()
601
+ await present_context_menu(cl.user_session.get("device"), cl.user_session.get("symptom"), manual_loaded=True)
602
+ return
603
+
604
+ # View manual matches
605
+ if name == "ctx_view_hits":
606
+ manual = cl.user_session.get("manual")
607
+ if not manual:
608
+ await cl.Message(content="No manual attached yet.").send()
609
+ return
610
+ query = f"{cl.user_session.get('device')} {cl.user_session.get('symptom')}"
611
+ hits = _manual_search(manual.get("pages", []), query, topk=3)
612
+ terms = [w for w in re.findall(r"\w+", query.lower()) if len(w) > 2]
613
+ parts = []
614
+ for h in hits:
615
+ excerpt = _make_excerpt(h.get("text",""), terms, window=420)
616
+ parts.append(f"**p.{h.get('page')}** — {excerpt}")
617
+ if not parts:
618
+ parts = ["No obvious matches found; try different symptom phrasing."]
619
+ await cl.Message(content="### 🔎 Manual Matches\n" + "\n\n".join(parts)).send()
620
+ return
621
+
622
+ # Clear manual
623
+ if name == "ctx_clear_manual":
624
+ cl.user_session.set("manual", None)
625
+ await cl.Message(content="Manual removed.").send()
626
+ await present_context_menu(cl.user_session.get("device"), cl.user_session.get("symptom"), manual_loaded=False)
627
+ return
628
+
629
+ # Restart flow
630
+ if name == "ctx_restart":
631
+ session_reset()
632
+ await present_device_menu()
633
+ return
634
+
635
+ # Start triage
636
+ if name == "ctx_start":
637
+ device = cl.user_session.get("device") or "device"
638
+ symptom = cl.user_session.get("symptom") or "issue"
639
+ flags = cl.user_session.get("flags") or set()
640
+ manual = cl.user_session.get("manual")
641
+
642
+ context_lines = []
643
+ if "patient_connected" in flags:
644
+ context_lines.append("Patient currently connected.")
645
+ if "tried_accessories" in flags:
646
+ context_lines.append("Accessories already swapped/checked.")
647
+ if "env_checked" in flags:
648
+ context_lines.append("Power/EMI/filters checked.")
649
+
650
+ # Manual excerpts (if provided)
651
+ manual_section = ""
652
+ if manual and manual.get("pages"):
653
+ query = f"{device} {symptom}"
654
+ terms = [w for w in re.findall(r"\w+", query.lower()) if len(w) > 2]
655
+ hits = _manual_search(manual["pages"], query, topk=3)
656
+ blocks = []
657
+ for h in hits:
658
+ excerpt = _make_excerpt(h.get("text",""), terms, window=420)
659
+ blocks.append(f"[Manual p.{h.get('page')}] {excerpt}")
660
+ if blocks:
661
+ manual_section = "Manual excerpts (for reference; OEM manual prevails if any conflict):\n" + "\n".join(blocks)
662
+
663
+ triage_prompt = (
664
+ f"Device: {device}\n"
665
+ f"Symptom: {symptom}\n"
666
+ f"Context: {', '.join(context_lines) if context_lines else 'n/a'}\n\n"
667
+ f"{manual_section}\n\n"
668
+ "Produce structured sections exactly:\n"
669
+ "1) Safety First (specific to device & context; non-invasive)\n"
670
+ "2) Likely Causes (ranked)\n"
671
+ "3) Step-by-Step Checks (bullet list, do-not-open, do-not-bypass alarms)\n"
672
+ "4) QC/Calibration (what to verify and with what tool)\n"
673
+ "5) Escalate When (clear triggers)\n"
674
+ "End with a one-line summary.\n"
675
+ "If device is life-critical (ventilator/defibrillator/infusion pump) and patient_connected, "
676
+ "start with: REMOVE FROM SERVICE & USE BACKUP — then proceed with safe checks off-patient."
677
+ )
678
+
679
+ result = await Runner.run(ayla_agent, triage_prompt)
680
+
681
+ # Tool-sourced quick guide for reliability
682
+ guide = device_reference_guide(device)
683
+ checklist = symptom_checklist(device, symptom)
684
+ policy = escalation_policy(device)
685
+
686
+ def bullets(arr): return "\n".join([f"- {b}" for b in (arr or [])]) if arr else "-"
687
+
688
+ quick = (
689
+ f"### 📘 Quick Reference: {device}\n"
690
+ f"**Safety**\n{bullets(guide.get('safety'))}\n\n"
691
+ f"**Common Faults**\n{bullets(guide.get('common_faults'))}\n\n"
692
+ f"**Quick Checks**\n{bullets(guide.get('quick_checks'))}\n\n"
693
+ f"**QC / Calibration**\n{bullets(guide.get('qc_calibration'))}\n\n"
694
+ f"**Escalate If**\n{bullets(guide.get('escalate_if'))}\n\n"
695
+ f"### 📝 Targeted Checklist for Symptom\n{bullets(checklist.get('steps'))}\n\n"
696
+ f"**Policy**\n- {policy.get('policy')}\n\n"
697
+ f"> ⚠️ Education-only. Refer to OEM manuals/policy. No invasive service.\n"
698
+ )
699
 
700
+ answer = result.final_output or "I couldn’t generate a troubleshooting plan."
701
+ session_reset()
702
+ await cl.Message(content=f"{quick}\n---\n{answer}").send()
703
+ await present_device_menu()
704
+ return