Spaces:
Sleeping
Sleeping
Download app.py from YVES28/ecg-chatbot: direct link, hf CLI and curl.
- Browser
- Download file 25.1 kB
-
https://huggingface.co/spaces/YVES28/ecg-chatbot/resolve/main/app.py
- Command line
-
hf download hf://spaces/YVES28/ecg-chatbot/app.py
-
curl -L -o app.py https://huggingface.co/spaces/YVES28/ecg-chatbot/resolve/main/app.py
25.1 kB
| """ECG卡使用者问答智能体 (方案 A2) — Gradio + HF Inference Router. | |
| 部署环境: Hugging Face Space (Gradio SDK). | |
| 所需 Secrets: HF_TOKEN (Read 权限). | |
| 可选 Variables: MODEL_ID (默认 Qwen/Qwen2.5-7B-Instruct). | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import re | |
| import traceback | |
| from difflib import SequenceMatcher | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Tuple | |
| # spaces 必须在 gradio 之前 import;在非 ZeroGPU 环境下 spaces 是 no-op,不影响本地测试。 | |
| import spaces # noqa: F401 | |
| import gradio as gr | |
| try: # 允许无 huggingface_hub 时也能启动 UI(离线只跑本地降级回复) | |
| from huggingface_hub import InferenceClient | |
| except Exception: # pragma: no cover | |
| InferenceClient = None # type: ignore | |
| # ZeroGPU 启动检查要求至少一个 @spaces.GPU 装饰器出现在 Gradio 绑定的 handler 上。 | |
| # 本 Space 的 LLM 走 HF Inference Router(远端算),本地不需要 GPU; | |
| # 加装饰器只是通过启动校验,duration=1 表示最多占用 1 秒 GPU 配额, | |
| # 而且函数体内根本不触发 GPU 操作,实际配额消耗为 0。 | |
| # ------------------------------------------------------------------ # | |
| # 常量 / 配置 # | |
| # ------------------------------------------------------------------ # | |
| APP_DIR = Path(__file__).parent | |
| FAQ_PATH = APP_DIR / "faq_ecg.json" | |
| SAMPLE_PATIENTS_PATH = APP_DIR / "sample_patients.json" | |
| MODEL_ID = os.environ.get("MODEL_ID", "Qwen/Qwen2.5-7B-Instruct") | |
| HF_TOKEN = os.environ.get("HF_TOKEN", "") | |
| # 三级风险 forced-override 关键词(方案A2 附录) | |
| LEVEL_3_KEYWORDS = [ | |
| "胸痛", "剧烈胸痛", "压榨性", | |
| "晕厥", "昏倒", "昏迷", "意识不清", "失去意识", | |
| "呼吸困难", "喘不上气", | |
| "持续室速", "持续性室速", | |
| "抽搐", "口角歪斜", "半身不遂", | |
| ] | |
| LEVEL_3_TAIL = "\n\n🚨 当前情况可能存在紧急风险,请立即前往急诊或联系急救服务(120)。" | |
| # 输出模板固定四段 | |
| OUTPUT_SECTIONS = ["问题分析", "原因说明", "建议", "是否需要医生"] | |
| DOCTOR_MARKS = { | |
| "ok": "✅ 当前无需就诊", | |
| "warn": "⚠ 建议尽快咨询医生", | |
| "urgent": "🚨 请立即前往医院急诊", | |
| } | |
| # System prompt(方案A2 Reasoner 说明) | |
| SYSTEM_PROMPT_ZH = """你是"ECG卡使用者问答智能体"。角色:面向ECG卡使用者(以中老年慢性心血管病患者及家属为主)的中文问答助手。 | |
| 【语气与人设】 | |
| - 专业、耐心、温和;避免使用"你必须""不要害怕"这类命令或恐吓; | |
| - 对老年用户友好:短句、简明、避免生僻缩写(必要时括号说明); | |
| - 不做医学诊断,不下结论,不替代医生;仅做知识解释、风险提示、正确使用引导。 | |
| 【能力范围】 | |
| 1) 设备使用指导(充电/贴电极/蓝牙/上传等); | |
| 2) 采集信号质量指导(接触、噪声、漂移等); | |
| 3) 心电基础知识与常见术语解释(HR/PR/QRS/QT/PVC/PAC/AF 等); | |
| 4) 报告结果的通俗解释; | |
| 5) 依据用户描述做三级风险初判(见下)。 | |
| 【三级风险分类】 | |
| - Level 1(常规问题):日常使用、基础术语 → 结尾用 "✅ 当前无需就诊"。 | |
| - Level 2(需就诊):症状明显、指标异常、需要专业评估 → 结尾用 "⚠ 建议尽快咨询医生"。 | |
| - Level 3(急诊):持续胸痛、晕厥、呼吸困难、意识障碍、持续室速等 → 结尾用 "🚨 请立即前往医院急诊"。 | |
| 【严格输出模板(硬性要求,不遵守视为错误输出)】 | |
| 你的每一次回答必须**严格**按以下 4 段结构,顺序**不能颠倒**,标题**不能省略**、**不能改写**、**不能翻译**。 | |
| 每段标题必须**逐字出现**且**独占一行**: | |
| - 第 1 段标题必须是: 问题分析 | |
| - 第 2 段标题必须是: 原因说明 | |
| - 第 3 段标题必须是: 建议 | |
| - 第 4 段标题必须是: 是否需要医生 | |
| 结构示例(严格照抄标题格式;省略号是每段内容占位,你自己填): | |
| 问题分析 | |
| ...(30-80字,针对用户问题的直接分析或档案相关个人化解读) | |
| 原因说明 | |
| ...(简明的机制/原因,面向普通人) | |
| 建议 | |
| 1) ... | |
| 2) ... | |
| 3) ... | |
| (2-5条可执行步骤,用 1) 2) 3) 分条) | |
| 是否需要医生 | |
| 根据风险等级恰好填写下列之一(不要增加多余说明): | |
| ✅ 当前无需就诊 | |
| ⚠ 建议尽快咨询医生 | |
| 🚨 请立即前往医院急诊 | |
| 【错误示例(禁止)】 | |
| - ❌ 直接以内容开头,漏掉"问题分析"标题; | |
| - ❌ 用"分析:"、"分析如下"、"Analysis"等替代"问题分析"; | |
| - ❌ 4 段顺序颠倒(如把"是否需要医生"放在中间); | |
| - ❌ 在"是否需要医生"这段之后追加任何额外总结或补充段落。 | |
| 【硬约束】 | |
| - 不下诊断结论,不推荐具体处方药剂量;涉及用药调整请建议咨询医生; | |
| - 未上传档案时不要编造历史数据; | |
| - 出现胸痛/晕厥/意识不清/呼吸困难等关键词一律按 Level 3 处理。 | |
| 【个人化回答规则(极其重要)】 | |
| - 如果 system prompt 中出现 "参考此患者档案" 部分,该部分是本用户真实档案。 | |
| - 当用户提问包含"我的""我最近""我目前""我的记录""帮我看看"等第一人称表达,或询问自身指标/心率/用药/病史/趋势时,你必须优先引用"参考此患者档案"中的具体数字与事实,而不是给出通用科普。 | |
| - 引用示例:"根据您的档案,近8次ECG心率范围 X-Y bpm"、"档案显示您正在服用美托洛尔缓释片 47.5mg 每日一次"、"您近期有 1 次疑似房颤记录(2026-07-11)"。 | |
| - 未在档案中出现的数据严禁编造。 | |
| - 若档案与用户描述冲突,以档案为客观基线,同时提示用户可能的差异。""" | |
| # ------------------------------------------------------------------ # | |
| # 数据加载 # | |
| # ------------------------------------------------------------------ # | |
| def _load_json(path: Path) -> Any: | |
| if not path.exists(): | |
| return None | |
| with path.open("r", encoding="utf-8") as f: | |
| return json.load(f) | |
| FAQ_DB = _load_json(FAQ_PATH) or {"faqs": []} | |
| FAQS: List[Dict[str, Any]] = FAQ_DB.get("faqs", []) | |
| SAMPLE_PATIENTS_DB = _load_json(SAMPLE_PATIENTS_PATH) or {"patients": []} | |
| SAMPLE_PATIENTS: List[Dict[str, Any]] = SAMPLE_PATIENTS_DB.get("patients", []) | |
| SAMPLE_MAP: Dict[str, Dict[str, Any]] = { | |
| p["profile_id"]: p for p in SAMPLE_PATIENTS | |
| } | |
| # ------------------------------------------------------------------ # | |
| # FAQ 检索:关键词 + 字符相似度 # | |
| # ------------------------------------------------------------------ # | |
| def _normalize(text: str) -> str: | |
| return re.sub(r"\s+", "", text or "").lower() | |
| def _score_faq(query: str, faq: Dict[str, Any]) -> float: | |
| q = _normalize(query) | |
| if not q: | |
| return 0.0 | |
| # 关键词命中权重 | |
| kw_hits = 0 | |
| for kw in faq.get("keywords", []): | |
| if _normalize(kw) and _normalize(kw) in q: | |
| kw_hits += 1 | |
| kw_score = min(1.0, kw_hits * 0.4) | |
| # 问题相似度 | |
| sim = SequenceMatcher(None, q, _normalize(faq.get("question", ""))).ratio() | |
| # 关键词与查询串的最大相似度(处理短问题) | |
| kw_sim = 0.0 | |
| for kw in faq.get("keywords", []): | |
| kw_sim = max(kw_sim, SequenceMatcher(None, q, _normalize(kw)).ratio()) | |
| return 0.55 * kw_score + 0.30 * sim + 0.15 * kw_sim | |
| def retrieve_faqs(query: str, top_k: int = 3, min_score: float = 0.15) -> List[Dict[str, Any]]: | |
| if not query or not FAQS: | |
| return [] | |
| scored = [(f, _score_faq(query, f)) for f in FAQS] | |
| scored.sort(key=lambda x: x[1], reverse=True) | |
| picked = [f for f, s in scored if s >= min_score][:top_k] | |
| return picked | |
| def build_faq_context(faqs: List[Dict[str, Any]]) -> str: | |
| if not faqs: | |
| return "参考知识\n(未匹配到高相关度FAQ,请基于系统角色与通用心电常识回答。)" | |
| lines = ["参考知识(检索自内置FAQ,可参考其中的原因、建议、风险分级思路)"] | |
| for i, f in enumerate(faqs, 1): | |
| lines.append(f"\n[{i}] 问题:{f['question']}") | |
| lines.append(f" 类别:{f.get('category','?')} | 风险等级:L{f.get('risk_level','?')}") | |
| lines.append(f" 参考答复片段:\n{f.get('answer','')}") | |
| lines.append(f" 来源:{f.get('source','')}") | |
| return "\n".join(lines) | |
| # ------------------------------------------------------------------ # | |
| # 患者档案 → 压缩上下文 # | |
| # ------------------------------------------------------------------ # | |
| def _tail(seq, n): | |
| if not seq: | |
| return [] | |
| return list(seq)[-n:] | |
| def compress_profile(profile: Dict[str, Any]) -> Tuple[str, Dict[str, int]]: | |
| """按方案A2压缩规则生成 patient_context 字符串。返回 (text, stats)。""" | |
| if not profile: | |
| return "", {} | |
| lines: List[str] = [] | |
| stats: Dict[str, int] = {} | |
| pt = profile.get("patient") or {} | |
| if pt: | |
| keys = ["name", "age", "gender", "diagnosis"] | |
| vals = [f"{k}={pt.get(k)}" for k in keys if pt.get(k) is not None] | |
| lines.append("【患者基本信息】" + ", ".join(vals) if vals else "【患者基本信息】(未提供)") | |
| mh = profile.get("medical_history") or {} | |
| if mh: | |
| cond = ", ".join(mh.get("conditions") or []) or "(无)" | |
| events = ", ".join(mh.get("past_events") or []) or "(无)" | |
| lines.append(f"【既往病史】{cond}") | |
| lines.append(f"【重要事件】{events}") | |
| meds = profile.get("medications") or [] | |
| if meds: | |
| med_str = "; ".join( | |
| f"{m.get('name','?')} {m.get('dose','')} {m.get('freq','')}".strip() | |
| for m in meds | |
| ) | |
| lines.append(f"【当前用药】{med_str}") | |
| stats["medications"] = len(meds) | |
| ecg = profile.get("ecg_history") or [] | |
| if ecg: | |
| recent = _tail(ecg, 8) | |
| stats["ecg_total"] = len(ecg) | |
| stats["ecg_used"] = len(recent) | |
| lines.append(f"【近{len(recent)}次ECG(共{len(ecg)}次)】") | |
| for e in recent: | |
| lines.append( | |
| " - {t} HR={hr} PR={pr} QRS={qrs} QTc={qtc} " | |
| "节律={rhy} AI={ai} 信号={sq}".format( | |
| t=e.get("time", "?"), | |
| hr=e.get("HR", "?"), | |
| pr=e.get("PR", "?"), | |
| qrs=e.get("QRS", "?"), | |
| qtc=e.get("QTc", "?"), | |
| rhy=e.get("rhythm", ""), | |
| ai=e.get("ai_finding", ""), | |
| sq=e.get("signal_quality", ""), | |
| ) | |
| ) | |
| reports = profile.get("reports") or [] | |
| if reports: | |
| latest = reports[-1] | |
| stats["reports_total"] = len(reports) | |
| km = latest.get("key_metrics") or {} | |
| km_str = ", ".join(f"{k}={v}" for k, v in km.items()) or "(无)" | |
| content = (latest.get("content") or "")[:800] | |
| lines.append( | |
| "【最近报告】{t} 类型={typ} 关键指标: {km}\n内容摘要:{c}".format( | |
| t=latest.get("time", "?"), | |
| typ=latest.get("type", "?"), | |
| km=km_str, | |
| c=content, | |
| ) | |
| ) | |
| vitals = profile.get("vitals_log") or [] | |
| if vitals: | |
| recent_v = _tail(vitals, 5) | |
| v_str = "; ".join( | |
| f"{v.get('time','?')} 体重={v.get('weight_kg','?')}kg 血压={v.get('bp','?')}" | |
| for v in recent_v | |
| ) | |
| lines.append(f"【近{len(recent_v)}天生命体征】{v_str}") | |
| alerts = profile.get("alerts") or [] | |
| if alerts: | |
| recent_a = _tail(alerts, 3) | |
| stats["alerts_total"] = len(alerts) | |
| for a in recent_a: | |
| lines.append( | |
| "【预警】{t} L={lv} 内容:{c} 处置:{h}".format( | |
| t=a.get("time", "?"), | |
| lv=a.get("level", "?"), | |
| c=a.get("content", ""), | |
| h=a.get("handled", ""), | |
| ) | |
| ) | |
| else: | |
| lines.append("【预警】暂无历史预警记录") | |
| header = "参考此患者档案(仅供AI理解上下文;所有个人化建议仍需医生确认)" | |
| return header + "\n" + "\n".join(lines), stats | |
| def parse_profile_input(text: str) -> Tuple[Dict[str, Any] | None, str]: | |
| """解析用户粘贴的JSON。返回 (profile_or_None, feedback)。""" | |
| if not text or not text.strip(): | |
| return None, "已清空档案(未使用历史数据)。" | |
| try: | |
| obj = json.loads(text) | |
| except Exception as e: | |
| return None, f"❌ JSON解析失败:{e}" | |
| # 允许直接 profile 或 {"patients":[...]} 挑第一个 | |
| if isinstance(obj, dict) and "patients" in obj and isinstance(obj["patients"], list): | |
| if not obj["patients"]: | |
| return None, "❌ patients 数组为空。" | |
| return obj["patients"][0], "✅ 已加载 patients[0]。" | |
| if isinstance(obj, dict): | |
| return obj, "✅ 已加载档案。" | |
| return None, "❌ 无法识别的档案结构。" | |
| # ------------------------------------------------------------------ # | |
| # 输出后处理:补齐模板 / 强制 Level 3 # | |
| # ------------------------------------------------------------------ # | |
| def _has_urgent_keyword(text: str) -> bool: | |
| if not text: | |
| return False | |
| return any(k in text for k in LEVEL_3_KEYWORDS) | |
| def ensure_template(answer: str, forced_level: int | None = None) -> str: | |
| text = (answer or "").strip() | |
| # 补齐缺失段落 | |
| for section in OUTPUT_SECTIONS: | |
| if section not in text: | |
| text += f"\n\n{section}\n(本次回复未提供该段落,请以医生意见为准。)" | |
| # 强制 Level 3:必须包含 🚨 急诊结论,且末尾必须出现 "120" 字样(生命安全兜底) | |
| if forced_level == 3: | |
| # 移除已有的其它 doctor 结论 | |
| text = text.replace(DOCTOR_MARKS["ok"], "") | |
| text = text.replace(DOCTOR_MARKS["warn"], "") | |
| # 如果 LLM 没输出急诊标记,追加(带 120) | |
| if DOCTOR_MARKS["urgent"] not in text: | |
| text += LEVEL_3_TAIL | |
| # 若 LLM 已给出急诊标记但缺 120,单独补一条 120 提示(方案A2 硬要求) | |
| elif "120" not in text: | |
| text += LEVEL_3_TAIL | |
| return text.strip() | |
| # ------------------------------------------------------------------ # | |
| # HF Router 调用 # | |
| # ------------------------------------------------------------------ # | |
| def _call_llm(system_prompt: str, | |
| history: List[Tuple[str, str]], | |
| user_msg: str, | |
| max_tokens: int = 1024, | |
| temperature: float = 0.5, | |
| top_p: float = 0.9) -> str: | |
| if InferenceClient is None: | |
| return _fallback_answer(user_msg, reason="huggingface_hub 未安装。") | |
| if not HF_TOKEN: | |
| return _fallback_answer(user_msg, reason="未配置 HF_TOKEN(Space Settings → Secrets → HF_TOKEN)。") | |
| client = InferenceClient(token=HF_TOKEN, timeout=60) | |
| messages = [{"role": "system", "content": system_prompt}] | |
| for u, a in history or []: | |
| if u: | |
| messages.append({"role": "user", "content": u}) | |
| if a: | |
| messages.append({"role": "assistant", "content": a}) | |
| messages.append({"role": "user", "content": user_msg}) | |
| try: | |
| resp = client.chat_completion( | |
| model=MODEL_ID, | |
| messages=messages, | |
| max_tokens=max_tokens, | |
| temperature=temperature, | |
| top_p=top_p, | |
| ) | |
| # openai-style | |
| try: | |
| return resp.choices[0].message.content or "" | |
| except Exception: | |
| return str(resp) | |
| except Exception as e: | |
| return _fallback_answer(user_msg, reason=f"模型调用失败:{e}") | |
| def _fallback_answer(user_msg: str, reason: str = "") -> str: | |
| """离线/无 token 时的降级回复:如果匹配到 FAQ 就直接返回,否则给结构化占位。""" | |
| hits = retrieve_faqs(user_msg, top_k=1, min_score=0.3) | |
| if hits: | |
| base = hits[0].get("answer", "") | |
| note = f"\n\n(⚠ 本次因 {reason} 未能调用大模型,已直接使用最相关FAQ返回。)" if reason else "" | |
| return base + note | |
| return ( | |
| "问题分析\n暂时无法调用大模型进行个性化解释。\n\n" | |
| "原因说明\n" + (reason or "未知") + "\n\n" | |
| "建议\n1) 稍后再试;2) 如问题紧急请直接联系管理师或医生;" | |
| "3) 部署侧请检查 Space Secrets 中 HF_TOKEN 是否配置。\n\n" | |
| "是否需要医生\n" + DOCTOR_MARKS["warn"] | |
| ) | |
| # ------------------------------------------------------------------ # | |
| # Gradio 回调 # | |
| # ------------------------------------------------------------------ # | |
| def _chatbot_to_history(chatbot_list) -> List[Tuple[str, str]]: | |
| """gr.Chatbot list → [(user, assistant), ...]""" | |
| hist: List[Tuple[str, str]] = [] | |
| for item in chatbot_list or []: | |
| if isinstance(item, (list, tuple)) and len(item) == 2: | |
| hist.append((item[0] or "", item[1] or "")) | |
| return hist | |
| def on_load_sample(profile_id: str) -> Tuple[str, str]: | |
| """加载样本档案 → (json_text, preview)""" | |
| if not profile_id or profile_id not in SAMPLE_MAP: | |
| return "", "(未选择样本档案)" | |
| obj = SAMPLE_MAP[profile_id] | |
| return json.dumps(obj, ensure_ascii=False, indent=2), _profile_preview(obj) | |
| def on_clear_profile() -> Tuple[str, str]: | |
| return "", "已清空档案。" | |
| def on_apply_profile(text: str) -> Tuple[str, str]: | |
| profile, msg = parse_profile_input(text) | |
| if profile is None: | |
| return "", msg | |
| return _profile_preview(profile), msg | |
| def _profile_preview(profile: Dict[str, Any]) -> str: | |
| ctx, stats = compress_profile(profile) | |
| stat_line = " ".join(f"{k}={v}" for k, v in stats.items()) if stats else "(无压缩统计)" | |
| return f"档案已就绪。压缩统计:{stat_line}\n\n{ctx}" | |
| def respond(user_msg: str, | |
| chat_history: List[Any], | |
| profile_json_text: str) -> Tuple[List[Any], str]: | |
| """核心回复函数。返回 (new_chat_history, cleared_input)。 | |
| 注:此函数被 @spaces.GPU 装饰仅为满足 ZeroGPU 启动检查, | |
| 实际计算不使用本地 GPU(全部远端 API)。 | |
| """ | |
| if not user_msg or not user_msg.strip(): | |
| return chat_history or [], "" | |
| try: | |
| history = _chatbot_to_history(chat_history) | |
| # 1) FAQ 检索 | |
| top_faqs = retrieve_faqs(user_msg, top_k=3) | |
| faq_ctx = build_faq_context(top_faqs) | |
| # 2) 患者档案压缩 | |
| profile, _ = parse_profile_input(profile_json_text or "") | |
| patient_ctx, _ = compress_profile(profile) if profile else ("", {}) | |
| # 3) 三级风险预判 | |
| forced_level = 3 if _has_urgent_keyword(user_msg) else None | |
| # 4) 组装 system prompt | |
| parts = [SYSTEM_PROMPT_ZH, faq_ctx] | |
| if patient_ctx: | |
| parts.append(patient_ctx) | |
| else: | |
| parts.append("参考此患者档案\n(用户当前未上传历史档案。请勿虚构历史数据,若被问及具体历史请友善提醒用户先在左侧上传档案。)") | |
| if forced_level == 3: | |
| parts.append("紧急信号提示\n(检测到用户描述中包含高危关键词,必须以 Level 3 处理,最后必须给出 🚨 前往急诊 结论。)") | |
| system_prompt = "\n\n".join(parts) | |
| # 5) 调用 LLM | |
| raw = _call_llm(system_prompt, history, user_msg) | |
| # 6) 后处理 | |
| final = ensure_template(raw, forced_level=forced_level) | |
| chat_history = (chat_history or []) + [[user_msg, final]] | |
| return chat_history, "" | |
| except Exception: | |
| err = "内部错误:\n```\n" + traceback.format_exc() + "\n```" | |
| chat_history = (chat_history or []) + [[user_msg, err]] | |
| return chat_history, "" | |
| def on_example_click(example: str) -> str: | |
| return example | |
| # ------------------------------------------------------------------ # | |
| # UI # | |
| # ------------------------------------------------------------------ # | |
| EXAMPLE_QUESTIONS = [ | |
| "ECG卡提示接触不好怎么办?", | |
| "报告显示PVC多是什么意思?", | |
| "心率120需要马上就医吗?", | |
| "电极贴多久换一次?", | |
| "房颤危险吗?", | |
| ] | |
| SAMPLE_CHOICES = [(p["display_name"], p["profile_id"]) for p in SAMPLE_PATIENTS] | |
| CSS = """ | |
| .gradio-container {max-width: 1200px !important;} | |
| #patient_json textarea {font-family: ui-monospace, monospace; font-size: 12px;} | |
| #preview {background: #F5F7FA; border-radius: 12px; padding: 12px;} | |
| """ | |
| def build_ui() -> gr.Blocks: | |
| with gr.Blocks(title="ECG卡使用者问答智能体", css=CSS, theme=gr.themes.Soft()) as demo: | |
| gr.Markdown( | |
| "# 🫀 ECG卡使用者问答智能体\n" | |
| "面向ECG卡使用者的中文问答助手(方案 A2)。回复严格遵循 **问题分析 / 原因说明 / 建议 / 是否需要医生** 四段模板;涉及胸痛、晕厥、呼吸困难等关键词会自动升级为急诊提示。\n\n" | |
| "> 本 Demo 内置约 160 条 FAQ 为示例条目,标注 `需临床团队审核`;所有回复不构成医学诊断,以医生意见为准。" | |
| ) | |
| with gr.Row(): | |
| # 左侧:患者档案 | |
| with gr.Column(scale=1): | |
| gr.Markdown("### 🗂 我的历史数据(可选)") | |
| gr.Markdown( | |
| "上传或粘贴 JSON 档案,机器人将结合您的 ECG 历史、报告、用药、预警给出更贴合的回复。\n" | |
| "留空时机器人不会引用任何患者数据。" | |
| ) | |
| sample_dropdown = gr.Dropdown( | |
| choices=SAMPLE_CHOICES, | |
| value=None, | |
| label="载入示例档案", | |
| interactive=True, | |
| ) | |
| with gr.Row(): | |
| load_btn = gr.Button("载入示例", variant="secondary", size="sm") | |
| clear_btn = gr.Button("清空档案", size="sm") | |
| profile_json = gr.Textbox( | |
| label="档案 JSON", | |
| placeholder='{"patient": {"name": "张**", "age": 68, ...}, "ecg_history": [...], ...}', | |
| lines=12, | |
| elem_id="patient_json", | |
| ) | |
| apply_btn = gr.Button("解析并预览", variant="primary") | |
| preview = gr.Markdown("(暂无档案预览)", elem_id="preview") | |
| # 右侧:对话 | |
| with gr.Column(scale=2): | |
| gr.Markdown("### 💬 咨询对话") | |
| chatbot = gr.Chatbot( | |
| label="ECG助手", | |
| height=520, | |
| show_copy_button=True, | |
| type="tuples", # 保持与现有 [user, assistant] 元组格式兼容 | |
| ) | |
| with gr.Row(): | |
| user_box = gr.Textbox( | |
| placeholder="请输入问题,例如:『我最近心率怎么样?』", | |
| lines=2, | |
| scale=8, | |
| show_label=False, | |
| ) | |
| send_btn = gr.Button("发送", variant="primary", scale=1) | |
| with gr.Row(): | |
| clear_chat_btn = gr.Button("🗑 清空对话", size="sm") | |
| gr.Markdown("**示例问题(点击自动填入输入框)**") | |
| with gr.Row(): | |
| example_btns = [gr.Button(q, size="sm") for q in EXAMPLE_QUESTIONS] | |
| # ---- 绑定回调 ---- | |
| load_btn.click(on_load_sample, inputs=[sample_dropdown], outputs=[profile_json, preview]) | |
| clear_btn.click(on_clear_profile, outputs=[profile_json, preview]) | |
| apply_btn.click(on_apply_profile, inputs=[profile_json], outputs=[preview, preview]) | |
| send_btn.click(respond, inputs=[user_box, chatbot, profile_json], outputs=[chatbot, user_box]) | |
| user_box.submit(respond, inputs=[user_box, chatbot, profile_json], outputs=[chatbot, user_box]) | |
| clear_chat_btn.click(lambda: [], outputs=[chatbot]) | |
| for btn, q in zip(example_btns, EXAMPLE_QUESTIONS): | |
| btn.click(on_example_click, inputs=[gr.State(q)], outputs=[user_box]) | |
| # 底部信息 | |
| info = ( | |
| f"**模型:** `{MODEL_ID}` (可通过 Space Variables `MODEL_ID` 覆盖) · " | |
| f"**HF_TOKEN:** {'✅ 已配置' if HF_TOKEN else '❌ 未配置(Space Settings → Secrets 中添加)'} · " | |
| f"**FAQ:** {len(FAQS)} 条 · " | |
| f"**示例患者:** {len(SAMPLE_PATIENTS)} 位" | |
| ) | |
| gr.Markdown(info) | |
| gr.Markdown( | |
| "> ⚠ 本工具仅为科普/使用引导,不能替代医生;紧急情况请拨打 **120**。" | |
| ) | |
| return demo | |
| demo = build_ui() | |
| if __name__ == "__main__": | |
| demo.launch() | |