Update app.py
Browse files
app.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
# ---------------------------------------------------------
|
| 2 |
-
#
|
| 3 |
-
#
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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 |
-
|
| 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 |
-
#
|
| 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
|
| 167 |
-
model="gemini-2.5-flash",
|
| 168 |
-
openai_client=external_client,
|
| 169 |
-
)
|
| 170 |
|
| 171 |
# =============================
|
| 172 |
-
#
|
| 173 |
# =============================
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
"""
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
"""
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
return pack(
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
],
|
| 191 |
-
|
| 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 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
],
|
| 211 |
-
|
| 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 |
-
|
| 228 |
-
|
| 229 |
-
],
|
| 230 |
-
|
| 231 |
-
|
| 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 |
-
|
| 247 |
-
|
| 248 |
-
],
|
| 249 |
-
|
| 250 |
-
|
| 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 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
],
|
| 268 |
-
|
| 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 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
|
|
|
| 286 |
)
|
| 287 |
|
| 288 |
@function_tool
|
| 289 |
-
def
|
| 290 |
-
"""
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
"
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
]
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
|
| 307 |
# =============================
|
| 308 |
-
#
|
| 309 |
# =============================
|
| 310 |
-
|
| 311 |
-
"You are
|
| 312 |
-
"
|
| 313 |
-
"
|
| 314 |
-
"
|
| 315 |
-
"3)
|
| 316 |
-
"
|
| 317 |
-
"
|
| 318 |
-
"Use tools (topic_reference_guide, study_outline) to ground the response.\n"
|
| 319 |
-
"Avoid clinical diagnosis or treatment advice."
|
| 320 |
)
|
| 321 |
-
|
| 322 |
-
name="
|
| 323 |
-
instructions=
|
| 324 |
model=llm_model,
|
| 325 |
-
tools=[
|
| 326 |
)
|
| 327 |
|
| 328 |
-
|
| 329 |
-
name="
|
| 330 |
instructions=(
|
| 331 |
-
"Classify if the
|
| 332 |
-
"
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
),
|
| 335 |
model=llm_model,
|
| 336 |
)
|
| 337 |
|
| 338 |
# =============================
|
| 339 |
-
# SD-XL helper
|
| 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 + " |
|
| 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 =
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
# =============================
|
| 364 |
WELCOME = (
|
| 365 |
-
"
|
| 366 |
-
"
|
| 367 |
-
"
|
| 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 |
-
#
|
| 381 |
if text.lower().startswith("/gen "):
|
| 382 |
desc = text[5:].strip()
|
| 383 |
if not desc:
|
| 384 |
-
await cl.Message(content="
|
| 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
|
| 393 |
elements=[cl.Image(path=path, name=os.path.basename(path), display="inline")],
|
| 394 |
).send()
|
| 395 |
return
|
| 396 |
|
| 397 |
-
#
|
| 398 |
try:
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
| 406 |
await cl.Message(
|
| 407 |
-
content="🚫 I can’t
|
|
|
|
| 408 |
).send()
|
|
|
|
| 409 |
return
|
| 410 |
except Exception:
|
| 411 |
pass
|
| 412 |
|
| 413 |
-
#
|
| 414 |
-
|
| 415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
|
| 427 |
-
|
| 428 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
|
| 438 |
-
|
| 439 |
-
if
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
|
|
|
|
|
|
|
|
| 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
|