Spaces:
Build error
Build error
File size: 9,028 Bytes
5363ac3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 | """
agent.py — the agentic core
----------------------------
Loads one instruct LLM with native tool-calling support and runs the
bounded tool-calling loop: model -> parse <tool_call> blocks -> execute
the matching sandboxed function from tools.py -> feed the result back as
a "tool" turn -> repeat until the model answers in plain text.
Works for both the text chat tab and the realtime voice tab (voice just
transcribes to text first, then calls `run_agent_turn`, then speaks the
returned text).
Model choice
------------
Any open instruct model whose chat template supports `tools=` and emits
Hermes/Qwen-style `<tool_call>{...}</tool_call>` blocks will work here
out of the box (Qwen2.5/Qwen3-Instruct, Mistral-Nemo-Instruct, etc.).
Default is Qwen2.5-7B-Instruct: solid multilingual coverage (including
Malayalam) and a well-tested tool-calling template. Swap MODEL_ID via
the MEDGUIDE_MODEL_ID env var without touching any other code.
"""
from __future__ import annotations
import json
import os
import re
import threading
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
from tools import TOOLS, TOOL_REGISTRY
try:
import spaces
GPU_DECORATOR = spaces.GPU
except ImportError: # local / CPU dev fallback
def GPU_DECORATOR(fn=None, **kwargs):
return fn if fn is not None else (lambda f: f)
MODEL_ID = os.environ.get("MEDGUIDE_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct")
MAX_TOOL_ROUNDS = 4
MAX_NEW_TOKENS = 512
SYSTEM_PROMPT = """You are MedGuide, a general health-information assistant \
available by text and by realtime voice, in English and in Malayalam \
(മലയാളം), for a "pre-visit" conversation before someone sees a clinician.
You are NOT a doctor, nurse, or pharmacist. You cannot diagnose conditions, \
prescribe medication, or replace professional medical care.
Language rule: always reply in the SAME language the user just used \
(English or Malayalam). If they mix languages, mirror the mix naturally. \
Keep sentences short and simple -- your text may be read aloud by a \
text-to-speech engine.
Rules you must always follow:
1. If the user describes symptoms that could indicate a medical emergency, \
call the symptom_urgency_triage tool (translate symptom phrases to English \
for the tool call itself). If it returns "emergency", clearly and \
immediately tell them, in their own language, to call emergency services \
(108 in India, 911 in the US, or their local equivalent) or go to the \
nearest emergency room -- before anything else.
2. Use your tools to produce concrete numbers (BMI, unit conversions, \
vital-sign reference ranges, interaction awareness notes, triage level) \
instead of guessing or calculating them yourself. Never state a number a \
tool could have computed without calling the tool.
3. Never provide specific dosing instructions for prescription or \
controlled substances. You may share general, widely-published OTC dosing \
information but always tell the user to confirm against the package label \
or a pharmacist.
4. Always make clear you provide general information only and encourage \
the user to consult a licensed healthcare professional for diagnosis, \
treatment, or prescriptions.
5. Be warm, clear, and plain-spoken. No alarming language unless the \
situation is genuinely urgent per rule 1.
6. This is a pre-visit intake conversation: where useful, ask brief \
follow-up questions (one at a time) to gather chief complaint, duration, \
severity, and relevant vitals, so a clinician can be handed a clean summary \
later.
"""
_TOOL_CALL_RE = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
_tokenizer = None
_model = None
_lock = threading.Lock()
def _load():
global _tokenizer, _model
if _model is not None:
return
with _lock:
if _model is not None:
return
_tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
_model = AutoModelForCausalLM.from_pretrained(
MODEL_ID,
dtype=torch.bfloat16,
device_map="auto",
)
_model.eval()
def _extract_tool_calls(text: str):
calls = []
for blob in _TOOL_CALL_RE.findall(text):
try:
obj = json.loads(blob)
except json.JSONDecodeError:
continue
name = obj.get("name")
args = obj.get("arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
args = {}
calls.append({"name": name, "arguments": args})
return calls
def _generate(messages, tools=True, stream_to: TextIteratorStreamer | None = None):
_load()
text = _tokenizer.apply_chat_template(
messages,
tools=TOOLS if tools else None,
tokenize=False,
add_generation_prompt=True,
)
inputs = _tokenizer(text, return_tensors="pt").to(_model.device)
gen_kwargs = dict(
**inputs,
max_new_tokens=MAX_NEW_TOKENS,
do_sample=True,
temperature=0.6,
top_p=0.9,
pad_token_id=_tokenizer.eos_token_id,
)
if stream_to is not None:
gen_kwargs["streamer"] = stream_to
thread = threading.Thread(target=_model.generate, kwargs=gen_kwargs)
thread.start()
return thread
with torch.no_grad():
out = _model.generate(**gen_kwargs)
gen_tokens = out[0][inputs["input_ids"].shape[-1]:]
return _tokenizer.decode(gen_tokens, skip_special_tokens=True)
def _history_to_messages(history: list) -> list:
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
for turn in history or []:
role, content = turn.get("role"), turn.get("content")
if role in ("user", "assistant") and content:
messages.append({"role": role, "content": content})
return messages
@GPU_DECORATOR(duration=60)
def run_agent_turn(user_message: str, history: list):
"""Runs the bounded tool-calling loop for one user turn.
Returns (answer_text, trace) where trace is a list of human-readable
"tool used" strings for display in the UI.
"""
messages = _history_to_messages(history)
messages.append({"role": "user", "content": user_message})
trace = []
answer = ""
for _round in range(MAX_TOOL_ROUNDS):
answer = _generate(messages, tools=True)
calls = _extract_tool_calls(answer)
if not calls:
# strip any stray tool-call tags the model didn't close properly
return re.sub(r"</?tool_call>.*", "", answer, flags=re.DOTALL).strip(), trace
messages.append({"role": "assistant", "content": answer})
for call in calls:
name, args = call["name"], call["arguments"]
if name not in TOOL_REGISTRY:
response = {"error": f"'{name}' is not an available tool."}
else:
try:
response = TOOL_REGISTRY[name](**args)
except TypeError as e:
response = {"error": f"Bad arguments for {name}: {e}"}
trace.append(f"🔧 {name}({json.dumps(args, ensure_ascii=False)}) → "
f"{json.dumps(response, ensure_ascii=False)}")
messages.append({
"role": "tool",
"name": name,
"content": json.dumps(response, ensure_ascii=False),
})
return (answer.strip() or
"I wasn't able to finish reasoning about that — could you "
"rephrase or simplify your question?"), trace
@GPU_DECORATOR(duration=90)
def generate_previsit_summary(history: list, language: str = "English") -> str:
"""Turns the conversation so far into a short, structured note a
clinician can skim before the visit. No new medical claims -- purely
a summarization of what was already said/computed in this chat."""
convo = "\n".join(
f"{t.get('role', '?').upper()}: {t.get('content', '')}"
for t in (history or []) if t.get("content")
)
lang_instruction = (
"Write the summary in Malayalam." if language == "Malayalam"
else "Write the summary in English."
)
prompt = (
"Summarize the pre-visit conversation below into a short clinical "
"handoff note with these headings: Chief Complaint, Duration/Onset, "
"Reported Symptoms, Vitals/Numbers Discussed (only ones explicitly "
"mentioned), Triage Level (if computed), Notes for Clinician. "
"Do not invent any information that wasn't in the conversation. "
f"{lang_instruction}\n\n--- CONVERSATION ---\n{convo}"
)
messages = [
{"role": "system", "content": "You write concise, accurate clinical "
"handoff summaries. You never invent "
"facts not present in the source text."},
{"role": "user", "content": prompt},
]
return _generate(messages, tools=False).strip() |