@clawhub-sunlinlin-aragon-90a29986ff
面向C端门诊就医全流程。先做症状分流和挂号科室判断,再推荐医院/医生 Top 3,并继续完成挂号引导、就医准备卡、自动提醒、诊后解释,以及基于 amap-lbs-skill 的高德到院路线规划。
---
name: ai-medical-care-manager
description: 面向C端门诊就医全流程。先做症状分流和挂号科室判断,再推荐医院/医生 Top 3,并继续完成挂号引导、就医准备卡、自动提醒、诊后解释,以及基于 amap-lbs-skill 的高德到院路线规划。
metadata: {"openclaw":{"emoji":"🏥","requires":{"bins":["python3","node"]},"homepage":"https://docs.openclaw.ai/skills","install":[{"kind":"node","package":"axios","bins":[]}]}}
---
# AI就医管家
当用户需要完成一次完整门诊就医任务,而不只是问“挂什么科”时,使用这个 skill。
这个 skill 的目标不是替代医生诊断,而是把一次就医任务拆成三个阶段并带用户走完:
- 诊前:先判断风险、推荐科室、推荐医院/医生、指导挂号
- 诊中:在根据用户提供的怪好信息,解析挂号信息、生成就医准备卡、自动添加提醒、规划路线
- 诊后:用户看完病后,在做用于提醒监督、解释病历/处方/报告、提炼待办、提示复诊等。
## 何时使用
适合这些请求:
- “我哪里不舒服,挂什么科?”
- “帮我推荐医院和医生。”
- “我已经挂好号了,帮我看看要准备什么。”
- “帮我做就医提醒和路线。”
- “我看完病了,帮我解释处方/报告。”
- “帮我把这次看病的下一步待办整理出来。”
## 工作原则
1. **先分阶段再行动**:先判断用户处在诊前、诊中还是诊后。
2. **先安全再推荐**:任何高危情形优先急诊,不继续普通门诊推荐。
3. **先结论再理由**:先给用户下一步怎么做,再补理由。
4. **先最小闭环再扩展**:优先解决“这次看病怎么顺利完成”,不要一次堆太多边缘能力。
5. **不替代医生诊断**:只能做辅助分流、流程协助和通俗解释。
开始前先快速想清楚三件事:
- 用户现在最需要解决的,是“判断”“执行”还是“理解”?
- 当前最可能卡住的环节在哪一步?
- 我这次回答里,最具体可执行的下一步是什么?
参考流程说明:`{baseDir}/references/flow_playbook.md`
## 内置资源
- 医院数据:`{baseDir}/assets/hospital_extracted_final.csv`
- 分诊与推荐:`{baseDir}/scripts/triage_and_match.py`
- 挂号文本解析:`{baseDir}/scripts/parse_appointment_text.py`
- 就医准备卡:`{baseDir}/scripts/generate_previsit_card.py`
- 统一提醒生成:`{baseDir}/scripts/appointment_reminders.py`(支持就诊提醒、用药提醒、复诊/检查/取药等定时提醒)
- 高德 IP 粗定位:`{baseDir}/scripts/amap_ip_locate.js`(对应 amap-lbs-skill 的定位能力)
- 高德地址转坐标:`{baseDir}/scripts/amap_geocode.js`(对应 amap-lbs-skill 的地理编码能力)
- 高德路线规划与 Web 跳转:`{baseDir}/scripts/amap_route_link.js`(对应 amap-lbs-skill / amap-jsapi-skill 的路线与跳转能力)
- 急症规则:`{baseDir}/references/triage_rules.md`
- 输出模板:`{baseDir}/references/response_templates.md`
- 流程收尾建议:在一次就医闭环完成后,主动询问用户是否需要使用 `qiaomu-mondo-poster-design` 生成一段适合发小红书、朋友圈的就医经历文案/海报文案
## 诊前:分流、科室判断、推荐 Top 3
### 第一步:收集最少必要信息
优先收集:
- 主诉与持续时间
- 伴随症状
- 年龄、性别
- 既往史/慢病/近期用药/妊娠情况
- 想就诊的城市(默认可按北京处理)
若信息不全,也可以先初步判断,但要明确不确定性。
### 第二步:先做安全分流
先阅读 `{baseDir}/references/triage_rules.md`。
若存在明显急症信号,不要继续普通门诊推荐;直接建议急诊/120。
### 第三步:运行分诊与推荐脚本
```bash
python3 {baseDir}/scripts/triage_and_match.py \
--csv {baseDir}/assets/hospital_extracted_final.csv \
--symptoms "用户主诉与伴随症状" \
--history "既往史或慢病,可为空" \
--age "年龄,可为空" \
--gender "性别,可为空" \
--top-k 3
```
脚本会返回:
- `emergency_flag`
- `department_candidates`
- `top_matches`
### 第四步:组织结果
最终答复中要包含:
- 风险判断
- 推荐科室(主推荐 + 备选)
- 值推荐Top 3 医院/科室/医生
- 推荐理由
- 挂号方式
输出时参考:`{baseDir}/references/response_templates.md`
### 第五步:固定给出挂号方式
默认给出:
微信内挂号更方便:
- 方式 1:搜索“北京114预约挂号”公众号
- 方式 2:搜索“京通”小程序 → 健康服务 → 预约挂号114
电话方式:
- 拨打 010-114 挂号
并提示用户:挂完号后把截图或文本发上来,我会继续帮你做准备卡、提醒和路线。
## 诊中:准备卡、提醒、路线
### 第一步:解析挂号文本
当用户上传挂号截图 OCR 文本或直接贴出挂号文本时,运行:
```bash
python3 {baseDir}/scripts/parse_appointment_text.py \
--csv {baseDir}/assets/hospital_extracted_final.csv \
--text "挂号截图OCR文本或用户粘贴内容"
```
若字段缺失,继续追问医院、科室、医生、时间中的缺项。
### 第二步:生成就医准备卡
```bash
python3 {baseDir}/scripts/generate_previsit_card.py \
--hospital "医院名" \
--department "科室名" \
--doctor "医生名,可缺省" \
--appointment "2026-03-20 14:30" \
--symptoms "本次主诉摘要" \
--history "病史摘要,可为空" \
--city "北京"
```
把输出整理成用户易读的“就医准备卡”:
- 医院 / 科室 / 医生 / 时间
- 建议到达时间
- 需携带资料
- 这次建议问医生什么
- 哪些病史别漏说
### 第三步:自动生成提醒
基础就诊提醒:
```bash
python3 {baseDir}/scripts/appointment_reminders.py --appointment "2026-03-20 14:30"
```
如果用户补充了用药信息,例如“阿莫西林,一天两次,一次一粒,吃一周,从2026-03-20开始”,则继续自动生成用药提醒:
```bash
python3 {baseDir}/scripts/appointment_reminders.py --appointment "2026-03-20 14:30" --medication-text "阿莫西林,一天两次,一次一粒,吃一周,从2026-03-20开始"
```
如果用户还提供了复诊、复查、检查、取药等明确时间事项,则一并生成:
```bash
python3 {baseDir}/scripts/appointment_reminders.py --appointment "2026-03-20 14:30" --medication-text "阿莫西林,一天两次,一次一粒,吃一周,从2026-03-20开始" --extra-reminder "复诊|2026-03-27 10:00" --extra-reminder "取药|2026-03-21 18:00"
```
默认行为:
- 就诊提醒:T-12h、T-6h、T-2h
- 用药提醒:按用户提供的频次、剂量、疗程自动展开
- 其他提醒:复诊、复查、检查、取药、拆线等,只要用户给出明确时间就自动加入
脚本会返回结构化提醒清单。若当前运行环境支持提醒/日历工具,则优先直接创建;若不支持,则把提醒时间完整列给用户,并明确提示其一键加入手机日历或手动设置闹钟。
### 第四步:就医路线规划(高德地图)
仅在已配置 `AMAP_WEBSERVICE_KEY` 时执行。未配置时,给出手动高德搜索建议。
#### 根据上下文中用户的位置和挂号信息中的医院, 用amap-lbs-skill给用户做线路规划
支持的 `mode`:
- `driving`
- `walking`
- `riding`
- `transfer`
输出时给:
- 预计距离
- 预计耗时
- 推荐出行方式
- 可点击的 `amap_link`
### 第五步:诊后用药与后续事项提醒
当用户在诊后阶段上传病历、处方、医嘱或文字说明时,除了做通俗解释,还要主动检查是否存在以下可提醒信息:
- 用药频次:如一天两次、每日三次、每8小时一次
- 单次剂量:如一次一粒、一次2片、一次10ml
- 疗程时长:如吃一周、服用7天、连用两周
- 后续事项:如3月27日复诊、明天下午去取药、下周做抽血检查
只要信息足够明确,就应自动运行提醒脚本并把结果加入最终答复;不要只停留在解释层面。
### 路线规划的建议话术
- 如果用户要尽快到院,优先 `driving`
- 如果距离短且医院周边停车不便,可给 `walking` 或 `riding`
- 如果用户明确想坐公共交通,使用 `transfer`
## 诊后:解释、待办、复诊
这一阶段主要依靠模型来做通俗解释,不要求额外脚本。
当用户上传病历、处方、检查报告或 OCR 文本时:
1. 先用普通人能理解的话解释这次医生大意。
2. 再总结“今天医生让我做的 3 件事”。
3. 再补充“哪些情况需要尽快复诊 / 复查”。
4. 如文本里出现明确复查、复诊、检查、取药或用药计划,则自动生成对应提醒,并把提醒清单补进最终答复。
5. 在本次就医流程已经形成相对完整闭环后,最后补一句:是否需要我继续用 `qiaomu-mondo-poster-design` 帮你生成一段就医过程体验文案,可用于小红书、朋友圈等内容记录。
解释时重点覆盖:
- 诊断是什么意思
- 药怎么吃
- 检查结果重点是什么
- 接下来要做什么
必须强调:
- 解释仅供理解,不替代医生最终意见
- 不建议用户自行停药、换药、延误复诊
## 流程结束后的附加动作
当已经完成以下任一闭环时,可主动追加推荐:
- 已完成挂号 + 提醒 + 路线
- 已完成诊后解释 + 用药/复诊提醒整理
- 用户明确表示想记录这次就医经历、吐槽、感谢、避坑或经验分享
建议在回答最后自然补一句:
- 如果你愿意,我还可以继续用 `social-copywriter` 帮你生成一段适合发小红书、朋友圈的就医过程体验文案,把这次看病的辛苦历程、流程体验、实用建议整理成更容易直接发布的内容。
使用原则:
- 仅作可选推荐,不强行打断当前主任务
- 放在回答最后,不放在急症判断或严肃风险提示之前
- 若用户刚经历较重症状或情绪明显紧张,语气要更克制,避免营销感过强
## 推荐输出风格
每次答复尽量遵守:
- 先给结论
- 再给理由
- 最后给下一步行动
推荐输出结构:
1. 你的当前判断
2. 推荐科室 / 推荐对象
3. 你接下来该做什么
4. 我还能继续帮你什么
## 不该做的事
- 不要给出确定性的疾病诊断
- 不要在高危症状下继续普通门诊推荐
- 不要让用户自己去消化一大段复杂说明
- 不要只给知识,不给可执行下一步
## 技能安装与放置
将此 skill 放到以下任一目录:
- `<workspace>/skills/ai-medical-care-manager`
- `~/.openclaw/skills/ai-medical-care-manager`
如果要启用高德路线规划,请在 `~/.openclaw/openclaw.json` 中给该 skill 配置:
```json
{
"skills": {
"entries": {
"ai-medical-care-manager": {
"enabled": true,
"env": {
"AMAP_WEBSERVICE_KEY": "你的高德 Web Service Key",
"AMAP_KEY": "你的高德 Web Service Key"
}
}
}
}
}
```
FILE:package.json
{
"name": "ai-medical-care-manager",
"version": "2.3.0",
"private": true,
"description": "OpenClaw skill package for AI medical care manager with amap-lbs-skill style AMap routing and automatic reminders",
"dependencies": {
"axios": "^1.13.6"
}
}
FILE:README.md
# AI Medical Care Manager Skill
这是一个面向 OpenClaw 的门诊就医全流程技能包。
## 目录
- `SKILL.md`:技能说明
- `assets/hospital_extracted_final.csv`:医院/科室/医生数据
- `scripts/`:分诊、解析、提醒、地图相关脚本
- `references/`:流程规则与输出模板
## 可完成的任务
- 症状分流与挂号科室判断
- 医院/医生 Top 3 推荐
- 北京114 / 京通挂号提示
- 挂号文本解析
- 就医准备卡生成
- 统一提醒生成(就诊 / 用药 / 复诊等)
- 基于 amap-lbs-skill 的高德路线规划(可选)
- 诊后解释与待办整理(由模型按 SKILL.md 指导完成)
- 流程结束后可选推荐 `qiaomu-mondo-poster-design`,生成适合发小红书 / 朋友圈的就医经历文案
## 额外依赖
- `python3`
- `node`
- `axios`(已在 `package.json` 中声明)
## 路线规划说明
路线规划已改为高德 skill 化方案:
1. 优先尝试用户 IP 粗定位
2. 若无法获取或不够精确,则让用户手动输入当前位置
3. 将起点和医院终点转为高德坐标
4. 通过 amap-lbs-skill / amap-jsapi-skill 路线能力生成高德 Web 路线链接
5. 最终回答必须展示可点击的地图链接
6. 若用户给出用药频次、疗程、复诊或检查时间,skill 会自动生成对应提醒清单
## 结束时的推荐动作
当一次就医任务已经形成闭环后,skill 应在回答最后补充一个可选询问:
- 是否需要继续使用 `qiaomu-mondo-poster-design`,把这次就医经历整理成适合发小红书、朋友圈的记录型文案或海报文案。
FILE:scripts/amap_geocode.js
#!/usr/bin/env node
const axios = require('axios');
function parseArgs() {
const args = {};
for (const arg of process.argv.slice(2)) {
if (!arg.startsWith('--')) continue;
const idx = arg.indexOf('=');
if (idx === -1) {
args[arg.slice(2)] = true;
} else {
args[arg.slice(2, idx)] = arg.slice(idx + 1);
}
}
return args;
}
async function geocode(address, city) {
const key = process.env.AMAP_WEBSERVICE_KEY || process.env.AMAP_KEY;
if (!key) {
return { error: 'Missing AMAP_WEBSERVICE_KEY (or AMAP_KEY)' };
}
if (!address) {
return { error: 'Missing --address' };
}
try {
const resp = await axios.get('https://restapi.amap.com/v3/geocode/geo', {
params: {
key,
address,
city: city || undefined,
output: 'JSON'
},
timeout: 15000
});
const data = resp.data;
if (data.status !== '1' || !Array.isArray(data.geocodes) || data.geocodes.length === 0) {
return {
error: data.info || 'AMap geocode failed',
raw: data
};
}
const gc = data.geocodes[0];
const [lng, lat] = String(gc.location || '').split(',').map(Number);
return {
address,
city: city || '',
formatted_address: gc.formatted_address || address,
province: gc.province || '',
district: gc.district || '',
adcode: gc.adcode || '',
location: gc.location,
lng,
lat
};
} catch (err) {
return { error: err.message };
}
}
(async () => {
const args = parseArgs();
const result = await geocode(args.address, args.city);
console.log(JSON.stringify(result, null, 2));
process.exit(result.error ? 2 : 0);
})();
FILE:scripts/amap_ip_locate.js
#!/usr/bin/env node
const axios = require('axios');
function parseArgs() {
const args = {};
for (const arg of process.argv.slice(2)) {
if (!arg.startsWith('--')) continue;
const idx = arg.indexOf('=');
if (idx === -1) {
args[arg.slice(2)] = true;
} else {
args[arg.slice(2, idx)] = arg.slice(idx + 1);
}
}
return args;
}
function midpointFromRectangle(rectangle) {
if (!rectangle || !rectangle.includes(';')) return null;
const [p1, p2] = rectangle.split(';');
const [lng1, lat1] = p1.split(',').map(Number);
const [lng2, lat2] = p2.split(',').map(Number);
if ([lng1, lat1, lng2, lat2].some(v => Number.isNaN(v))) return null;
return {
lng: (lng1 + lng2) / 2,
lat: (lat1 + lat2) / 2
};
}
async function locateByIp(ip) {
const key = process.env.AMAP_WEBSERVICE_KEY || process.env.AMAP_KEY;
if (!key) return { error: 'Missing AMAP_WEBSERVICE_KEY (or AMAP_KEY)' };
if (!ip) {
return { error: 'Missing --ip. Only use IP locate when you truly have the user IP; otherwise ask user for current location.' };
}
try {
const resp = await axios.get('https://restapi.amap.com/v3/ip', {
params: { key, ip, output: 'JSON' },
timeout: 15000
});
const data = resp.data;
if (data.status !== '1') {
return { error: data.info || 'AMap IP locate failed', raw: data };
}
const center = midpointFromRectangle(data.rectangle || '');
return {
ip,
province: data.province || '',
city: data.city || '',
district: data.district || '',
adcode: data.adcode || '',
rectangle: data.rectangle || '',
center,
note: 'IP定位通常只到城市/区域级别,若需要精确起点路线,请继续向用户确认当前位置或具体出发地。'
};
} catch (err) {
return { error: err.message };
}
}
(async () => {
const args = parseArgs();
const result = await locateByIp(args.ip);
console.log(JSON.stringify(result, null, 2));
process.exit(result.error ? 2 : 0);
})();
FILE:scripts/amap_route_link.js
#!/usr/bin/env node
const {
walkingRoute,
drivingRoute,
ridingRoute,
transitRoute,
generateMapLink
} = require('./vendor/amap_index');
function parseArgs() {
const args = {};
for (const arg of process.argv.slice(2)) {
if (!arg.startsWith('--')) continue;
const idx = arg.indexOf('=');
if (idx === -1) {
args[arg.slice(2)] = true;
} else {
args[arg.slice(2, idx)] = arg.slice(idx + 1);
}
}
return args;
}
function toMapTask(routeType, originLng, originLat, destLng, destLat, remark, city) {
const task = {
type: 'route',
routeType,
start: [originLng, originLat],
end: [destLng, destLat],
remark
};
if (city && routeType === 'transfer') task.city = city;
return task;
}
function formatDistance(m) {
if (m == null) return '未知';
if (Number(m) >= 1000) return `(Number(m) / 1000).toFixed(1) km`;
return `m m`;
}
function formatDuration(sec) {
if (sec == null) return '未知';
const s = Number(sec);
const h = Math.floor(s / 3600);
const m = Math.round((s % 3600) / 60);
return h > 0 ? `h小时m分钟` : `m分钟`;
}
async function main() {
const args = parseArgs();
const mode = args.mode || 'driving';
const origin = args.origin || (args.originLng && args.originLat ? `args.originLng,args.originLat` : '');
const destination = args.destination || (args.destLng && args.destLat ? `args.destLng,args.destLat` : '');
const originName = args.originName || '起点';
const destName = args.destName || '终点';
const city = args.city || args.region || '';
if (!origin || !destination) {
console.log(JSON.stringify({ error: 'Missing origin/destination. Use --origin=经度,纬度 and --destination=经度,纬度' }, null, 2));
process.exit(2);
}
const [originLng, originLat] = origin.split(',').map(Number);
const [destLng, destLat] = destination.split(',').map(Number);
let data;
let summary = {};
try {
if (mode === 'walking') {
data = await walkingRoute({ origin, destination });
const path = data?.route?.paths?.[0];
summary = {
distance_m: path?.distance ? Number(path.distance) : null,
duration_s: path?.duration ? Number(path.duration) : null,
routes_count: data?.route?.paths?.length || 0
};
} else if (mode === 'riding') {
data = await ridingRoute({ origin, destination });
const path = data?.data?.paths?.[0];
summary = {
distance_m: path?.distance ? Number(path.distance) : null,
duration_s: path?.duration ? Number(path.duration) : null,
routes_count: data?.data?.paths?.length || 0
};
} else if (mode === 'transfer') {
if (!city) {
console.log(JSON.stringify({ error: 'Transit mode requires --city or --region' }, null, 2));
process.exit(2);
}
data = await transitRoute({ origin, destination, city, strategy: args.strategy ? Number(args.strategy) : 0 });
const transit = data?.route?.transits?.[0];
summary = {
distance_m: transit?.distance ? Number(transit.distance) : null,
duration_s: transit?.duration ? Number(transit.duration) : null,
routes_count: data?.route?.transits?.length || 0,
cost: transit?.cost ? Number(transit.cost) : null,
walking_distance_m: transit?.walking_distance ? Number(transit.walking_distance) : null
};
} else {
data = await drivingRoute({ origin, destination, strategy: args.strategy ? Number(args.strategy) : 10 });
const path = data?.route?.paths?.[0];
summary = {
distance_m: path?.distance ? Number(path.distance) : null,
duration_s: path?.duration ? Number(path.duration) : null,
routes_count: data?.route?.paths?.length || 0,
tolls: path?.tolls ? Number(path.tolls) : 0,
traffic_lights: path?.traffic_lights ? Number(path.traffic_lights) : 0
};
}
if (!data) {
console.log(JSON.stringify({ error: 'Route planning failed' }, null, 2));
process.exit(2);
}
const mapLink = generateMapLink([
toMapTask(mode, originLng, originLat, destLng, destLat, `originName → destName`, city)
]);
const output = {
mode,
origin_name: originName,
dest_name: destName,
origin,
destination,
...summary,
distance_text: formatDistance(summary.distance_m),
duration_text: formatDuration(summary.duration_s),
amap_link: mapLink,
amap_markdown_link: `[查看高德路线](mapLink)`,
note: '请在最终页面上直接展示 amap_markdown_link,用户点击后可在 Web 中查看路线结果。经纬度顺序为“经度,纬度”。'
};
console.log(JSON.stringify(output, null, 2));
} catch (err) {
console.log(JSON.stringify({ error: err.message }, null, 2));
process.exit(2);
}
}
main();
FILE:scripts/appointment_reminders.py
#!/usr/bin/env python3
import argparse
import json
import re
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
FORMATS = [
"%Y-%m-%d %H:%M",
"%Y/%m/%d %H:%M",
"%Y.%m.%d %H:%M",
"%Y年%m月%d日 %H:%M",
"%Y年%m月%d日%H:%M",
"%Y-%m-%dT%H:%M",
"%Y-%m-%d",
"%Y/%m/%d",
"%Y.%m.%d",
"%Y年%m月%d日",
]
KEYWORDS = ["复诊", "复查", "检查", "取药", "换药", "拆线", "抽血", "验血", "拍片", "输液"]
def normalize(text: str) -> str:
text = text.strip()
text = text.replace("上午", " ").replace("下午", " ")
text = re.sub(r"\s+", " ", text)
return text
def parse_dt(text: str, default_time: str = "09:00") -> datetime:
raw = normalize(text)
for fmt in FORMATS:
try:
dt = datetime.strptime(raw, fmt)
if "%H" not in fmt:
hh, mm = map(int, default_time.split(":"))
dt = dt.replace(hour=hh, minute=mm)
return dt
except ValueError:
continue
raise ValueError(f"Unsupported datetime format: {text}")
def parse_cn_num(text: str) -> Optional[int]:
mapping = {
"一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5,
"六": 6, "七": 7, "八": 8, "九": 9, "十": 10,
}
if text.isdigit():
return int(text)
if text in mapping:
return mapping[text]
if len(text) == 2 and text[0] == "十" and text[1] in mapping:
return 10 + mapping[text[1]]
if len(text) == 2 and text[1] == "十" and text[0] in mapping:
return mapping[text[0]] * 10
if len(text) == 3 and text[1] == "十" and text[0] in mapping and text[2] in mapping:
return mapping[text[0]] * 10 + mapping[text[2]]
return None
def parse_frequency(text: str) -> Tuple[Optional[int], Optional[int], str]:
t = text.lower().replace("每隔", "每")
m = re.search(r"(?:一天|每日|每天)(\d+|一|二|两|三|四)次", t)
if m:
n = parse_cn_num(m.group(1))
return n, None, f"每天{n}次"
if any(k in t for k in ["一天一次", "每日一次", "每天一次", "qd"]):
return 1, None, "每天1次"
if any(k in t for k in ["一天两次", "每日两次", "每天两次", "bid"]):
return 2, None, "每天2次"
if any(k in t for k in ["一天三次", "每日三次", "每天三次", "tid"]):
return 3, None, "每天3次"
m = re.search(r"每(\d{1,2})小时(?:一次)?", t)
if m:
hours = int(m.group(1))
return None, hours, f"每{hours}小时1次"
return None, None, "未识别频次"
def parse_duration_days(text: str) -> int:
t = text.lower()
if "一周" in t or "1周" in t:
return 7
if "两周" in t or "2周" in t:
return 14
if "三周" in t or "3周" in t:
return 21
m = re.search(r"(?:吃|服|用|连用)?(\d+|一|二|两|三|四|五|六|七|八|九|十+)天", t)
if m:
return parse_cn_num(m.group(1)) or 1
m = re.search(r"(?:吃|服|用|连用)?(\d+|一|二|两|三)周", t)
if m:
return (parse_cn_num(m.group(1)) or 1) * 7
return 7
def parse_dose(text: str) -> str:
patterns = [
r"一次[^,。;;\s]+",
r"每次[^,。;;\s]+",
]
for p in patterns:
m = re.search(p, text)
if m:
return m.group(0)
return "按医嘱"
def parse_med_name(text: str) -> str:
# 优先取首段中非频次描述的内容
first = re.split(r"[,。,;;]", text.strip())[0].strip()
if first and not any(x in first for x in ["一天", "每天", "每日", "每", "一次", "一周", "天"]):
return first
m = re.search(r"([\u4e00-\u9fa5A-Za-z0-9\-]{2,20})(?:片|粒|胶囊|口服液|注射液)?[,, ]*(?:一天|每天|每日|每\d+小时)", text)
if m:
return m.group(1)
return "用药提醒"
def parse_start_dt(text: str, explicit_start: Optional[str], times_per_day: Optional[int], interval_hours: Optional[int]) -> datetime:
if explicit_start:
return parse_dt(explicit_start, default_time="08:00")
patterns = [
r"从(\d{4}[-/.]\d{1,2}[-/.]\d{1,2}(?:\s+\d{1,2}:\d{2})?)开始",
r"从(\d{4}年\d{1,2}月\d{1,2}日(?:\s*\d{1,2}:\d{2})?)开始",
]
for p in patterns:
m = re.search(p, text)
if m:
return parse_dt(m.group(1), default_time="08:00")
now = datetime.now()
if interval_hours:
return now.replace(minute=0, second=0, microsecond=0)
if times_per_day == 1:
return now.replace(hour=8, minute=0, second=0, microsecond=0)
if times_per_day == 2:
return now.replace(hour=8, minute=0, second=0, microsecond=0)
if times_per_day == 3:
return now.replace(hour=8, minute=0, second=0, microsecond=0)
return now.replace(hour=9, minute=0, second=0, microsecond=0)
def daily_time_slots(times_per_day: int) -> List[Tuple[int, int]]:
if times_per_day == 1:
return [(8, 0)]
if times_per_day == 2:
return [(8, 0), (20, 0)]
if times_per_day == 3:
return [(8, 0), (14, 0), (20, 0)]
if times_per_day == 4:
return [(6, 0), (12, 0), (18, 0), (22, 0)]
step = max(1, round(24 / times_per_day))
slots = []
h = 8
for _ in range(times_per_day):
slots.append((h % 24, 0))
h += step
return slots
def build_appointment_reminders(appointment: str) -> Dict:
dt = parse_dt(appointment)
reminders = [
("T-12h", dt - timedelta(hours=12)),
("T-6h", dt - timedelta(hours=6)),
("T-2h", dt - timedelta(hours=2)),
]
return {
"appointment": dt.strftime("%Y-%m-%d %H:%M"),
"reminders": [
{"label": label, "time": when.strftime("%Y-%m-%d %H:%M"), "title": f"就诊提醒 {label}"}
for label, when in reminders
],
}
def build_medication_reminders(text: str, explicit_start: Optional[str] = None) -> Dict:
times_per_day, interval_hours, freq_text = parse_frequency(text)
duration_days = parse_duration_days(text)
dose = parse_dose(text)
med_name = parse_med_name(text)
start_dt = parse_start_dt(text, explicit_start, times_per_day, interval_hours)
reminders = []
if interval_hours:
total_count = max(1, int((24 * duration_days) / interval_hours))
current = start_dt
for idx in range(total_count):
reminders.append({
"index": idx + 1,
"time": current.strftime("%Y-%m-%d %H:%M"),
"title": f"{med_name} 用药提醒",
"content": f"{dose},频次:{freq_text}",
})
current += timedelta(hours=interval_hours)
else:
per_day = times_per_day or 1
slots = daily_time_slots(per_day)
base_date = start_dt.date()
for d in range(duration_days):
for hh, mm in slots:
current = datetime.combine(base_date + timedelta(days=d), datetime.min.time()).replace(hour=hh, minute=mm)
reminders.append({
"index": len(reminders) + 1,
"time": current.strftime("%Y-%m-%d %H:%M"),
"title": f"{med_name} 用药提醒",
"content": f"{dose},频次:{freq_text}",
})
return {
"medication_name": med_name,
"frequency": freq_text,
"dose": dose,
"duration_days": duration_days,
"start_time": start_dt.strftime("%Y-%m-%d %H:%M"),
"reminders": reminders,
}
def parse_extra_item(raw: str) -> Dict:
if "|" not in raw:
raise ValueError("extra reminder format must be 标题|时间,例如 复诊|2026-03-27 10:00")
title, when = raw.split("|", 1)
dt = parse_dt(when.strip(), default_time="09:00")
return {
"title": title.strip(),
"time": dt.strftime("%Y-%m-%d %H:%M"),
}
def extract_extra_from_text(text: str) -> List[Dict]:
results = []
date_patterns = [
r"(\d{4}[-/.]\d{1,2}[-/.]\d{1,2}(?:\s+\d{1,2}:\d{2})?)",
r"(\d{4}年\d{1,2}月\d{1,2}日(?:\s*\d{1,2}:\d{2})?)",
r"(\d{1,2}月\d{1,2}日(?:\s*\d{1,2}:\d{2})?)",
]
seen = set()
current_year = datetime.now().year
segments = [seg.strip() for seg in re.split(r"[,,。;;\n]", text) if seg.strip()]
for seg in segments:
label = None
for kw in KEYWORDS:
if kw in seg:
label = kw
break
if not label:
continue
raw_dt = None
for ptn in date_patterns:
m = re.search(ptn, seg)
if m:
raw_dt = m.group(1)
break
if not raw_dt:
continue
candidate = raw_dt
if re.match(r"^\d{1,2}月\d{1,2}日", raw_dt):
candidate = f"{current_year}年{raw_dt}"
try:
dt = parse_dt(candidate, default_time="09:00")
except ValueError:
continue
key = (label, dt.strftime("%Y-%m-%d %H:%M"))
if key in seen:
continue
seen.add(key)
results.append({"title": label, "time": dt.strftime("%Y-%m-%d %H:%M")})
return results
def build_calendar_tasks(appointment_block: Optional[Dict], medication_block: Optional[Dict], extra_items: List[Dict]) -> List[Dict]:
tasks = []
if appointment_block:
for item in appointment_block["reminders"]:
tasks.append({
"type": "appointment",
"title": item["title"],
"time": item["time"],
"content": f"本次就诊时间:{appointment_block['appointment']}",
})
if medication_block:
for item in medication_block["reminders"]:
tasks.append({
"type": "medication",
"title": item["title"],
"time": item["time"],
"content": item["content"],
})
for item in extra_items:
tasks.append({
"type": "followup",
"title": item["title"],
"time": item["time"],
"content": f"{item['title']} 提醒",
})
tasks.sort(key=lambda x: x["time"])
return tasks
def main() -> int:
p = argparse.ArgumentParser(description="Generate reminders for appointments, medications and follow-up tasks")
p.add_argument("--appointment", help="Appointment datetime, e.g. 2026-03-20 14:30")
p.add_argument("--medication-text", help="Medication instruction text, e.g. 阿莫西林,一天两次,一次一粒,吃一周,从2026-03-20开始")
p.add_argument("--med-start", help="Explicit medication start datetime/date, optional")
p.add_argument("--extra-reminder", action="append", default=[], help="Extra reminder in 标题|时间 format, can repeat")
p.add_argument("--extra-text", help="Free text containing items like 2026-03-27 10:00复诊、2026-03-21 18:00取药")
args = p.parse_args()
if not any([args.appointment, args.medication_text, args.extra_reminder, args.extra_text]):
raise SystemExit("At least one of --appointment, --medication-text, --extra-reminder, --extra-text is required")
appointment_block = build_appointment_reminders(args.appointment) if args.appointment else None
medication_block = build_medication_reminders(args.medication_text, args.med_start) if args.medication_text else None
extra_items = [parse_extra_item(x) for x in args.extra_reminder]
if args.extra_text:
extra_items.extend(extract_extra_from_text(args.extra_text))
output = {
"appointment_block": appointment_block,
"medication_block": medication_block,
"extra_reminders": extra_items,
"calendar_tasks": build_calendar_tasks(appointment_block, medication_block, extra_items),
"note": "若运行环境支持提醒或日历工具,可直接按 calendar_tasks 自动创建;否则把这些时间展示给用户并建议加入手机日历。",
}
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/generate_previsit_card.py
#!/usr/bin/env python3
import argparse
import json
from datetime import datetime, timedelta
QUESTION_RULES = {
'呼吸与危重症医学科': ['这次症状更像感染、过敏还是慢性问题?', '是否需要做胸片/CT/肺功能?', '目前用药是否需要调整?'],
'心血管科': ['这次胸闷胸痛的危险程度如何?', '是否需要心电图/动态心电图/超声?', '现有降压/心脏药是否要调整?'],
'消化科': ['这次腹痛或反酸最可能的原因是什么?', '是否需要胃镜/幽门螺杆菌检查?', '饮食和用药上要注意什么?'],
'妇产科': ['这次症状是否需要进一步检查?', '近期月经/妊娠情况会不会影响诊疗?', '是否需要做B超或激素相关检查?'],
'皮肤科': ['最可能的诱因是什么?', '需要口服药还是外用药?', '哪些情况加重时要复诊?'],
'综合科': ['这次最需要先排查什么?', '还需要转诊到哪个专科?', '我现在最需要做的检查是什么?'],
}
COMMON_ITEMS = ['身份证/医保卡', '既往病历或出院小结', '近期检查报告/化验单', '当前正在使用的药物清单']
def choose_questions(department: str):
for key, items in QUESTION_RULES.items():
if key in department or department in key:
return items
return QUESTION_RULES['综合科']
def main() -> int:
p = argparse.ArgumentParser(description='Generate a structured pre-visit card')
p.add_argument('--hospital', required=True)
p.add_argument('--department', required=True)
p.add_argument('--doctor', default='待定')
p.add_argument('--appointment', required=True, help='YYYY-MM-DD HH:MM')
p.add_argument('--symptoms', default='')
p.add_argument('--history', default='')
p.add_argument('--city', default='北京')
args = p.parse_args()
dt = datetime.strptime(args.appointment, '%Y-%m-%d %H:%M')
arrive = dt - timedelta(minutes=60)
card = {
'hospital': args.hospital,
'department': args.department,
'doctor': args.doctor,
'appointment': dt.strftime('%Y-%m-%d %H:%M'),
'suggested_arrival_time': arrive.strftime('%Y-%m-%d %H:%M'),
'city': args.city,
'bring_items': COMMON_ITEMS,
'symptom_summary': args.symptoms,
'history_summary': args.history,
'questions_for_doctor': choose_questions(args.department),
'important_notes_to_tell_doctor': [
'症状持续多久、是否加重',
'既往慢病、近期用药、过敏史',
'最近做过的检查和异常结果',
],
'note': '如为检查类项目,请再次确认是否需要空腹、停药或家属陪同。'
}
print(json.dumps(card, ensure_ascii=False, indent=2))
return 0
if __name__ == '__main__':
raise SystemExit(main())
FILE:scripts/parse_appointment_text.py
#!/usr/bin/env python3
import argparse
import csv
import json
import re
from pathlib import Path
from typing import Dict, List, Optional
DT_PATTERNS = [
(re.compile(r'(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})[日\sT]*(\d{1,2})[::](\d{2})'), '%04d-%02d-%02d %02d:%02d'),
]
def normalize(s: str) -> str:
if s is None:
return ''
s = str(s).strip()
return '' if s.lower() == 'nan' else s
def load_candidates(csv_path: str):
hospitals, departments, doctors = set(), set(), set()
with open(csv_path, 'r', encoding='utf-8-sig', newline='') as f:
reader = csv.DictReader(f)
for row in reader:
hospitals.add(normalize(row.get('hospital_name')))
departments.add(normalize(row.get('department_name')))
doctors.add(normalize(row.get('doctor_name')))
hospitals.discard(''); departments.discard(''); doctors.discard('')
return sorted(hospitals, key=len, reverse=True), sorted(departments, key=len, reverse=True), sorted(doctors, key=len, reverse=True)
def find_first(text: str, candidates: List[str]) -> Optional[str]:
for item in candidates:
if item and item in text:
return item
return None
def extract_datetime(text: str) -> Optional[str]:
text = text.replace('上午', ' ').replace('下午', ' ')
for patt, fmt in DT_PATTERNS:
m = patt.search(text)
if m:
y, mo, d, h, mi = map(int, m.groups())
return fmt % (y, mo, d, h, mi)
return None
def main() -> int:
p = argparse.ArgumentParser(description='Extract appointment fields from booking text or OCR text')
p.add_argument('--csv', required=True)
p.add_argument('--text', required=True)
args = p.parse_args()
hospitals, departments, doctors = load_candidates(args.csv)
text = normalize(args.text)
result: Dict[str, Optional[str]] = {
'hospital_name': find_first(text, hospitals),
'department_name': find_first(text, departments),
'doctor_name': find_first(text, doctors),
'appointment_datetime': extract_datetime(text),
}
missing = [k for k, v in result.items() if not v]
confidence = 1.0 - (len(missing) / len(result))
output = {
'parsed': result,
'missing_fields': missing,
'confidence': round(confidence, 2),
'note': '如字段缺失,请让用户手动补充医院、科室、医生或就诊时间。',
}
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0
if __name__ == '__main__':
raise SystemExit(main())
FILE:scripts/triage_and_match.py
#!/usr/bin/env python3
import argparse
import csv
import json
import math
import os
import re
from collections import defaultdict
from typing import Dict, List, Tuple
DEPARTMENT_RULES = [
{
"department": "呼吸与危重症医学科",
"aliases": ["呼吸科", "呼吸内科", "肺病科"],
"keywords": ["咳嗽", "咳痰", "发热", "发烧", "胸闷", "气短", "气喘", "呼吸困难", "哮喘", "肺炎", "肺结节", "鼻塞", "流感", "新冠", "支气管", "胸痛"],
},
{
"department": "心血管科",
"aliases": ["心内科", "心血管内科", "心脏科", "心脏中心"],
"keywords": ["胸痛", "胸闷", "心慌", "心悸", "高血压", "血压高", "冠心病", "心绞痛", "气短", "头晕", "水肿"],
},
{
"department": "神经科",
"aliases": ["神经内科", "神经外科", "神经外一科"],
"keywords": ["头痛", "眩晕", "头晕", "肢体麻木", "偏瘫", "抽搐", "癫痫", "面瘫", "失眠", "记忆力", "意识", "中风", "脑梗", "脑出血"],
},
{
"department": "消化科",
"aliases": ["消化内科", "内镜中心"],
"keywords": ["腹痛", "胃痛", "反酸", "烧心", "呕吐", "恶心", "腹泻", "便秘", "黑便", "便血", "胃胀", "肝", "胆", "胰", "消化不良"],
},
{
"department": "内分泌科",
"aliases": ["糖尿病科"],
"keywords": ["血糖", "糖尿病", "甲状腺", "肥胖", "消瘦", "多饮", "多尿", "内分泌", "痛风", "尿酸"],
},
{
"department": "妇产科",
"aliases": ["妇科", "中医妇科", "妇产科(含国际部妇产科病区"],
"keywords": ["月经", "经期", "阴道", "白带", "宫颈", "子宫", "卵巢", "乳房", "怀孕", "妊娠", "备孕", "产检", "妇科"],
},
{
"department": "泌尿外科",
"aliases": ["泌尿科"],
"keywords": ["尿频", "尿急", "尿痛", "血尿", "前列腺", "肾结石", "排尿", "泌尿", "腰痛"],
},
{
"department": "皮肤科",
"aliases": [],
"keywords": ["皮疹", "瘙痒", "湿疹", "荨麻疹", "痘", "痤疮", "脱发", "皮肤", "红斑", "过敏"],
},
{
"department": "眼科",
"aliases": ["眼科特需门诊"],
"keywords": ["眼痛", "视力", "视物模糊", "红眼", "流泪", "眼干", "飞蚊", "白内障", "青光眼"],
},
{
"department": "口腔医学中心",
"aliases": ["口腔科", "牙科"],
"keywords": ["牙痛", "牙龈", "口腔", "智齿", "牙周", "口臭", "龋齿"],
},
{
"department": "骨科",
"aliases": ["骨科·关节外科", "骨科·脊柱外科", "脊柱二科"],
"keywords": ["膝盖", "关节", "骨折", "扭伤", "腰椎", "脊柱", "颈椎", "肩痛", "骨痛", "运动损伤"],
},
{
"department": "风湿免疫科",
"aliases": ["风湿病科", "中医风湿病科"],
"keywords": ["关节痛", "晨僵", "红斑狼疮", "风湿", "类风湿", "免疫", "干燥综合征"],
},
{
"department": "心理科",
"aliases": ["心身医学科"],
"keywords": ["焦虑", "抑郁", "失眠", "情绪", "惊恐", "心理", "压力", "睡不着"],
},
{
"department": "感染疾病科",
"aliases": [],
"keywords": ["乙肝", "丙肝", "感染", "发热", "传染", "艾滋", "结核"],
},
{
"department": "肿瘤科",
"aliases": ["中西医结合肿瘤内科", "放射肿瘤科"],
"keywords": ["肿瘤", "癌", "结节", "化疗", "放疗", "靶向", "肿块"],
},
{
"department": "康复医学科",
"aliases": [],
"keywords": ["康复", "术后恢复", "偏瘫康复", "理疗", "功能训练"],
},
{
"department": "老年病科",
"aliases": [],
"keywords": ["老年", "多病共存", "高龄", "慢病管理"],
},
{
"department": "综合科",
"aliases": ["普通内科", "全科"],
"keywords": ["不确定挂什么科", "多种症状", "体检异常", "慢病复诊"],
},
]
EMERGENCY_KEYWORDS = [
"持续胸痛", "胸痛大汗", "呼吸困难", "意识不清", "昏迷", "抽搐不止", "大出血", "便血很多", "黑便明显",
"呕血", "偏瘫", "口角歪斜", "说话不清", "突发剧烈头痛", "高热惊厥", "严重过敏", "休克",
]
NON_CLINICAL_KEYWORDS = [
"实验室", "药理", "病理", "放射", "职能", "共同制定", "自成立之日", "积极推行", "设有门诊", "临床药理室", "检验科", "输血科",
]
def normalize_text(value: str) -> str:
if value is None:
return ""
value = str(value).strip()
if value.lower() == "nan":
return ""
return value
def tokenize(text: str) -> List[str]:
text = normalize_text(text)
cjk = re.findall(r"[\u4e00-\u9fff]{2,}", text)
latin = re.findall(r"[A-Za-z]{2,}", text)
return cjk + latin
def keyword_hits(text: str, keywords: List[str]) -> Tuple[int, List[str]]:
hits = []
for kw in keywords:
if kw and kw in text:
hits.append(kw)
return len(hits), hits
def infer_departments(symptom_text: str) -> Tuple[List[Dict], bool, List[str]]:
emergency_count, emergency_hits = keyword_hits(symptom_text, EMERGENCY_KEYWORDS)
emergency = emergency_count > 0
scores = []
for rule in DEPARTMENT_RULES:
count, hits = keyword_hits(symptom_text, rule["keywords"])
if count > 0:
scores.append(
{
"department": rule["department"],
"aliases": rule["aliases"],
"score": count,
"hits": hits,
}
)
if not scores:
scores = [{"department": "综合科", "aliases": ["普通内科", "全科"], "score": 1, "hits": ["症状描述不足,建议先做综合分诊"]}]
scores.sort(key=lambda x: (-x["score"], x["department"]))
return scores, emergency, emergency_hits
def load_rows(csv_path: str) -> List[Dict[str, str]]:
rows = []
with open(csv_path, "r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
for row in reader:
rows.append({k: normalize_text(v) for k, v in row.items()})
return rows
def row_is_clinical(row: Dict[str, str]) -> bool:
dept = row.get("department_name", "")
doctor = row.get("doctor_name", "")
hospital = row.get("hospital_name", "")
if not dept or not doctor:
return False
if doctor == dept or doctor == hospital:
return False
if ("科" in doctor or "中心" in doctor) and len(doctor) >= 2 and len(doctor) <= 12:
return False
for kw in NON_CLINICAL_KEYWORDS:
if kw in dept:
return False
return True
def department_match_score(row: Dict[str, str], inferred: List[Dict]) -> Tuple[float, str]:
dept_name = row.get("department_name", "")
doctor_dept = row.get("doctor_department", "")
best = 0.0
reason = ""
for cand in inferred:
targets = [cand["department"]] + cand.get("aliases", [])
for t in targets:
if t and (t in dept_name or t in doctor_dept or dept_name in t or doctor_dept in t):
score = 6.0 + cand["score"] * 1.5
if score > best:
best = score
reason = f"科室匹配:{cand['department']}"
# fuzzy fallback via shared chars
overlap = len(set(dept_name) & set(cand["department"]))
if overlap >= 2:
score = 2.0 + overlap * 0.5 + cand["score"]
if score > best:
best = score
reason = f"科室近似匹配:{cand['department']}"
return best, reason
def content_match_score(row: Dict[str, str], symptom_text: str, inferred: List[Dict]) -> Tuple[float, List[str]]:
bag = " ".join([
row.get("hospital_name", ""),
row.get("hospital_intro", ""),
row.get("department_name", ""),
row.get("department_intro", ""),
row.get("doctor_name", ""),
row.get("doctor_department", ""),
row.get("doctor_intro", ""),
])
score = 0.0
reasons: List[str] = []
symptom_tokens = set(tokenize(symptom_text))
bag_tokens = set(tokenize(bag))
overlap = symptom_tokens & bag_tokens
if overlap:
score += min(4.0, len(overlap) * 1.2)
reasons.append("症状关键词重合:" + "、".join(sorted(list(overlap))[:4]))
for cand in inferred[:3]:
hits = [kw for kw in cand["hits"] if kw in bag]
if hits:
add = min(3.5, len(hits) * 1.0)
score += add
reasons.append(f"擅长/简介命中:{'、'.join(hits[:4])}")
break
schedule = row.get("doctor_schedule", "")
if schedule:
score += 0.5
reasons.append("有坐诊时间信息")
return score, reasons
def aggregate_rows(rows: List[Dict[str, str]], inferred: List[Dict], symptom_text: str, top_k: int) -> List[Dict]:
scored = []
for row in rows:
if not row_is_clinical(row):
continue
d_score, d_reason = department_match_score(row, inferred)
c_score, c_reasons = content_match_score(row, symptom_text, inferred)
total = d_score + c_score
if total <= 0:
continue
reasons = []
if d_reason:
reasons.append(d_reason)
reasons.extend(c_reasons)
scored.append(
{
"hospital_name": row.get("hospital_name", ""),
"hospital_intro": row.get("hospital_intro", ""),
"department_name": row.get("department_name", ""),
"department_intro": row.get("department_intro", ""),
"doctor_name": row.get("doctor_name", ""),
"doctor_department": row.get("doctor_department", ""),
"doctor_intro": row.get("doctor_intro", ""),
"doctor_schedule": row.get("doctor_schedule", ""),
"score": round(total, 2),
"reasons": reasons[:4],
}
)
# unique by hospital+department+doctor, keep best score
best_by_key: Dict[Tuple[str, str, str], Dict] = {}
for item in scored:
key = (item["hospital_name"], item["department_name"], item["doctor_name"])
old = best_by_key.get(key)
if old is None or item["score"] > old["score"]:
best_by_key[key] = item
deduped = list(best_by_key.values())
deduped.sort(key=lambda x: (-x["score"], x["hospital_name"], x["department_name"], x["doctor_name"]))
return deduped[:top_k]
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Infer likely departments from symptoms and match top hospitals/doctors from a CSV dataset.")
p.add_argument("--csv", required=True, help="Path to hospital_extracted_final.csv")
p.add_argument("--symptoms", required=True, help="User symptom narrative")
p.add_argument("--history", default="", help="Past history / chronic conditions / meds")
p.add_argument("--age", default="", help="Age, optional")
p.add_argument("--gender", default="", help="Gender, optional")
p.add_argument("--top-k", type=int, default=3, help="How many matches to return")
return p
def main() -> int:
args = build_parser().parse_args()
if not os.path.exists(args.csv):
print(json.dumps({"error": f"CSV not found: {args.csv}"}, ensure_ascii=False))
return 2
symptom_text = ";".join([x for x in [args.symptoms, args.history, args.age, args.gender] if x])
inferred, emergency, emergency_hits = infer_departments(symptom_text)
rows = load_rows(args.csv)
matches = aggregate_rows(rows, inferred, symptom_text, args.top_k)
output = {
"input": {
"symptoms": args.symptoms,
"history": args.history,
"age": args.age,
"gender": args.gender,
},
"emergency_flag": emergency,
"emergency_hits": emergency_hits,
"department_candidates": inferred[:5],
"top_matches": matches,
"notes": [
"结果仅用于挂号分诊辅助,不替代医生诊断。",
"若出现持续胸痛、明显呼吸困难、意识改变、偏瘫、抽搐不止、大出血等情况,应优先急诊。",
"当前CSV中存在部分科研/实验室类行,脚本已尽量过滤,但仍建议人工复核。",
],
}
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/vendor/amap_index.js
const fs = require('fs');
const path = require('path');
const axios = require('axios');
// 配置文件路径
const CONFIG_FILE = path.join(__dirname, 'config.json');
/**
* 读取配置文件
*/
function readConfig() {
try {
if (fs.existsSync(CONFIG_FILE)) {
const data = fs.readFileSync(CONFIG_FILE, 'utf8');
return JSON.parse(data);
}
} catch (error) {
console.error('读取配置文件失败:', error.message);
}
return {};
}
/**
* 保存配置文件
*/
function saveConfig(config) {
try {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
console.log('配置已保存到:', CONFIG_FILE);
return true;
} catch (error) {
console.error('保存配置文件失败:', error.message);
return false;
}
}
/**
* 获取高德 Web Service Key
*/
function getWebServiceKey() {
const config = readConfig();
return config.webServiceKey || null;
}
/**
* 设置高德 Web Service Key
*/
function setWebServiceKey(key) {
const config = readConfig();
config.webServiceKey = key;
return saveConfig(config);
}
/**
* 检查并提示用户输入 Key
*/
async function ensureWebServiceKey() {
// 优先从环境变量读取
let key = process.env.AMAP_KEY || process.env.AMAP_WEBSERVICE_KEY;
if (!key) {
// 尝试从配置文件读取
key = getWebServiceKey();
}
if (!key) {
console.log('\n⚠️ 未找到高德 Web Service Key');
console.log('请访问以下地址创建应用并获取 Key:');
console.log('https://lbs.amap.com/api/webservice/create-project-and-key\n');
throw new Error('请设置环境变量 AMAP_KEY 或提供高德 Web Service Key');
}
return key;
}
/**
* POI 搜索
* @param {Object} params - 搜索参数
* @param {string} params.keywords - 查询关键字
* @param {string} params.city - 城市名称或城市编码
* @param {string} params.types - POI类型编码
* @param {string} params.location - 中心点坐标
* @param {number} params.radius - 搜索半径(米)
* @param {number} params.page - 当前页数
* @param {number} params.offset - 每页记录数
*/
async function searchPOI(params) {
const key = await ensureWebServiceKey();
const url = 'https://restapi.amap.com/v5/place/text';
const requestParams = {
key: key,
keywords: params.keywords || '',
region: params.city || '',
city_limit: params.cityLimit !== false,
...params
};
try {
console.log('🔍 正在搜索 POI...');
const response = await axios.get(url, { params: requestParams });
if (response.data.status === '1') {
console.log(`✅ 搜索成功,共找到 response.data.count 条结果\n`);
return response.data;
} else {
console.error('❌ 搜索失败:', response.data.info);
return null;
}
} catch (error) {
console.error('❌ 请求失败:', error.message);
return null;
}
}
/**
* 步行路径规划
* @param {Object} params - 规划参数
* @param {string} params.origin - 起点坐标 "经度,纬度"
* @param {string} params.destination - 终点坐标 "经度,纬度"
*/
async function walkingRoute(params) {
const key = await ensureWebServiceKey();
const url = 'https://restapi.amap.com/v3/direction/walking';
const requestParams = {
key: key,
origin: params.origin,
destination: params.destination
};
try {
console.log('🚶 正在规划步行路线...');
const response = await axios.get(url, { params: requestParams });
if (response.data.status === '1') {
console.log('✅ 步行路线规划成功\n');
return response.data;
} else {
console.error('❌ 步行路线规划失败:', response.data.info);
return null;
}
} catch (error) {
console.error('❌ 请求失败:', error.message);
return null;
}
}
/**
* 驾车路径规划
* @param {Object} params - 规划参数
* @param {string} params.origin - 起点坐标 "经度,纬度"
* @param {string} params.destination - 终点坐标 "经度,纬度"
* @param {string} params.waypoints - 途经点坐标,多个用;分隔
* @param {number} params.strategy - 驾车策略,默认10
*/
async function drivingRoute(params) {
const key = await ensureWebServiceKey();
const url = 'https://restapi.amap.com/v3/direction/driving';
const requestParams = {
key: key,
origin: params.origin,
destination: params.destination,
strategy: params.strategy || 10,
extensions: 'base'
};
if (params.waypoints) {
requestParams.waypoints = params.waypoints;
}
try {
console.log('🚗 正在规划驾车路线...');
const response = await axios.get(url, { params: requestParams });
if (response.data.status === '1') {
console.log('✅ 驾车路线规划成功\n');
return response.data;
} else {
console.error('❌ 驾车路线规划失败:', response.data.info);
return null;
}
} catch (error) {
console.error('❌ 请求失败:', error.message);
return null;
}
}
/**
* 骑行路径规划
* @param {Object} params - 规划参数
* @param {string} params.origin - 起点坐标 "经度,纬度"
* @param {string} params.destination - 终点坐标 "经度,纬度"
*/
async function ridingRoute(params) {
const key = await ensureWebServiceKey();
const url = 'https://restapi.amap.com/v4/direction/bicycling';
const requestParams = {
key: key,
origin: params.origin,
destination: params.destination
};
try {
console.log('🚴 正在规划骑行路线...');
const response = await axios.get(url, { params: requestParams });
if (response.data.errcode === 0) {
console.log('✅ 骑行路线规划成功\n');
return response.data;
} else {
console.error('❌ 骑行路线规划失败:', response.data.errmsg);
return null;
}
} catch (error) {
console.error('❌ 请求失败:', error.message);
return null;
}
}
/**
* 公交路径规划
* @param {Object} params - 规划参数
* @param {string} params.origin - 起点坐标 "经度,纬度"
* @param {string} params.destination - 终点坐标 "经度,纬度"
* @param {string} params.city - 城市名称或城市编码
* @param {number} params.strategy - 公交策略,默认0(最快捷)
* @param {boolean} params.nightflag - 是否计算夜班车,默认false
*/
async function transitRoute(params) {
const key = await ensureWebServiceKey();
const url = 'https://restapi.amap.com/v3/direction/transit/integrated';
const requestParams = {
key: key,
origin: params.origin,
destination: params.destination,
city: params.city,
strategy: params.strategy || 0,
nightflag: params.nightflag ? 1 : 0
};
try {
console.log('🚌 正在规划公交路线...');
const response = await axios.get(url, { params: requestParams });
if (response.data.status === '1') {
console.log('✅ 公交路线规划成功\n');
return response.data;
} else {
console.error('❌ 公交路线规划失败:', response.data.info);
return null;
}
} catch (error) {
console.error('❌ 请求失败:', error.message);
return null;
}
}
/**
* 生成地图可视化链接
* @param {Array} mapTaskData - 地图任务数据数组
* @returns {string} 可视化链接
*/
function generateMapLink(mapTaskData) {
const baseUrl = 'https://a.amap.com/jsapi_demo_show/static/openclaw/travel_plan.html';
const dataStr = encodeURIComponent(JSON.stringify(mapTaskData));
return `baseUrl?data=dataStr`;
}
/**
* 旅游规划助手
* @param {Object} params - 规划参数
* @param {string} params.city - 城市名称
* @param {Array<string>} params.interests - 兴趣点关键词数组,如 ['景点', '美食', '酒店']
* @param {string} params.routeType - 路线类型:driving/walking/riding/transfer
* @returns {Object} 包含 pois、mapTaskData、mapLink 和 htmlLink
*/
async function travelPlanner(params) {
const { city, interests = [], routeType = 'walking' } = params;
console.log(`\n🗺️ 开始为您规划 city 的旅游行程...\n`);
const mapTaskData = [];
const poiResults = [];
// 搜索各类兴趣点
for (const interest of interests) {
console.log(`📍 搜索 interest...`);
const result = await searchPOI({
keywords: interest,
city: city,
page: 1,
offset: 5
});
if (result && result.pois && result.pois.length > 0) {
poiResults.push(...result.pois);
// 添加到地图数据 - 严格按照 PoiTask 接口格式
result.pois.forEach(poi => {
const [lng, lat] = poi.location.split(',').map(Number);
mapTaskData.push({
type: 'poi',
lnglat: [lng, lat],
sort: poi.type || interest,
text: poi.name,
remark: poi.address || `interest推荐`
});
});
}
}
// 如果有多个POI,规划路线
if (poiResults.length >= 2) {
console.log(`\n🛣️ 规划游览路线(routeType)...\n`);
for (let i = 0; i < poiResults.length - 1; i++) {
const start = poiResults[i];
const end = poiResults[i + 1];
const [startLng, startLat] = start.location.split(',').map(Number);
const [endLng, endLat] = end.location.split(',').map(Number);
// 添加路线到地图数据 - 严格按照 RouteTask 接口格式
const routeTask = {
type: 'route',
routeType: routeType,
start: [startLng, startLat],
end: [endLng, endLat],
remark: `从 start.name 到 end.name`
};
// 如果是公交路线,添加 city 参数
if (routeType === 'transfer') {
routeTask.city = city;
}
mapTaskData.push(routeTask);
}
}
console.log('\n✅ 旅游规划完成!\n');
console.log('📍 推荐地点:');
poiResults.forEach((poi, index) => {
console.log(`index + 1. poi.name`);
console.log(` 地址: poi.address`);
console.log(` 类型: poi.type\n`);
});
return {
pois: poiResults,
};
}
// 导出函数供其他脚本使用
module.exports = {
readConfig,
saveConfig,
getWebServiceKey,
setWebServiceKey,
ensureWebServiceKey,
searchPOI,
walkingRoute,
drivingRoute,
ridingRoute,
transitRoute,
generateMapLink,
travelPlanner
};
// 如果直接运行此文件,执行示例搜索
if (require.main === module) {
(async () => {
try {
// 示例:搜索北京的肯德基
const result = await searchPOI({
keywords: '肯德基',
city: '北京',
page: 1,
offset: 10
});
if (result && result.pois) {
console.log('搜索结果:');
result.pois.forEach((poi, index) => {
console.log(`index + 1. poi.name`);
console.log(` 地址: poi.address`);
console.log(` 类型: poi.type`);
console.log(` 坐标: poi.location\n`);
});
}
} catch (error) {
console.error('执行失败:', error.message);
process.exit(1);
}
})();
}
FILE:references/flow_playbook.md
# 就医流程 playbook
本 skill 按三个阶段工作:
## 诊前
1. 收集主诉、持续时间、伴随症状、年龄、性别、病史、城市。
2. 先做安全分流:判断是否急诊。
3. 用 triage_and_match.py 产出推荐科室与 Top 3 医院/医生。
4. 给出挂号方式:北京114 / 京通 / 010-114。
## 诊中
1. 用户提供挂号截图 OCR 文本或直接粘贴挂号信息。
2. 用 parse_appointment_text.py 解析医院、科室、医生、就诊时间。
3. 用 generate_previsit_card.py 生成就医准备卡。
4. 用 appointment_reminders.py 生成统一提醒:默认就诊 T-12h/T-6h/T-2h;若用户给出用药信息,则自动展开每日用药提醒;若用户给出复诊/检查/取药等时间事项,则一并生成。
5. 如已配置 AMAP_WEBSERVICE_KEY,则先尝试真实用户 IP 粗定位;若拿不到或不够精确,则让用户手动提供当前位置。
6. 用 amap_geocode.js 把用户起点和医院终点转为高德坐标,这里对应 amap-lbs-skill 的地理编码能力。
7. 用 amap_route_link.js 生成路线摘要和高德 Web 链接,这里对应 amap-lbs-skill / amap-jsapi-skill 的路线规划与跳转能力。
8. 最终输出必须包含一个可点击的 Markdown 地图链接。
9. 若本次就医安排已经比较完整,可在回答最后追加一个可选动作:询问用户是否要用 `qiaomu-mondo-poster-design` 生成一段适合发小红书、朋友圈的就医经历文案。
## 诊后
1. 让用户上传病历、处方、报告文本或截图 OCR 文本。
2. 先用通俗语言解释:诊断是什么、药怎么吃、接下来做什么。
3. 总结为“医生今天让我做的事”和“哪些情况需要尽快复诊”。
4. 如文本中出现用药计划、复查日期、复诊时间、检查时间或取药时间,则自动生成对应提醒。
5. 当诊后解释和提醒整理完成后,在不影响主任务的前提下,可在结尾补充:是否需要继续用 `qiaomu-mondo-poster-design` 生成一段就医过程体验文案。
## 回答风格
- 不替代医生诊断
- 先给结论,再给理由,再给下一步
- 任何高危情形优先急诊
- 推荐结果要可执行,不要只讲知识
## 标准收尾动作
当回答已经帮用户完成本次就医关键闭环后,最后可自然补一句:
- 要不要我顺手再用 `qiaomu-mondo-poster-design` 帮你生成一段适合发小红书、朋友圈的就医经历文案?可以把这次就医过程、辛苦历程、体验感受和避坑建议整理成更适合直接发布的内容。
FILE:references/response_templates.md
# 推荐输出模板
## 诊前输出模板
- 风险判断:
- 推荐科室:主推荐 + 备选
- Top 3 推荐:医院 / 科室 / 医生 / 坐诊信息 / 推荐理由
- 挂号方式:114 / 京通 / 电话
- 下一步:挂号后把截图或文本发上来,我继续帮你做提醒和路线
## 诊中输出模板
- 我已识别到:医院 / 科室 / 医生 / 时间
- 就医准备卡:带什么、几点到、问医生什么
- 提醒清单:就诊提醒 + 用药提醒 + 复诊/检查/取药等提醒(按已识别信息自动展开)
- 路线:预计距离 / 耗时 / 推荐方式 / 地图链接(必须写成 [查看高德路线](链接))
## 诊后输出模板
- 今天医生大意:
- 药怎么吃:
- 接下来要做的 3 件事:
- 什么时候复诊 / 哪些情况尽快复诊:
## 提醒输出补充模板
- 已自动识别提醒事项:
- 用药提醒:药名 / 频次 / 单次剂量 / 疗程 / 首次开始时间
- 其他提醒:复诊 / 复查 / 检查 / 取药 / 拆线
- 已生成提醒时间:逐条列出
- 若支持提醒工具:说明已自动创建
- 若不支持提醒工具:给出可复制到手机日历/闹钟的时间清单
## 结束时附加推荐模板
- 可选补充:如果你愿意,我还可以继续用 `qiaomu-mondo-poster-design` 帮你生成一段适合发小红书、朋友圈的就医过程体验文案。
- 推荐说明:它比较适合把这次看病的辛苦历程、排队挂号、检查取药、路线奔波、就诊感受和实用建议整理成更容易直接发布的内容。
- 适用场景:小红书记录、朋友圈分享、就医经验复盘。
## 诊后输出模板(增强版结尾)
- 今天医生大意:
- 药怎么吃:
- 接下来要做的 3 件事:
- 什么时候复诊 / 哪些情况尽快复诊:
- 结尾可选补充:要不要我再帮你用 `qiaomu-mondo-poster-design` 生成一版适合小红书/朋友圈发布的就医经历文案?
FILE:references/triage_rules.md
# 急症分流参考规则
以下情况优先建议急诊或直接拨打 120,不应继续普通门诊推荐:
- 持续胸痛、胸闷伴大汗或濒死感
- 明显呼吸困难、无法完整说话、嘴唇发紫
- 意识不清、昏迷、抽搐不止
- 突发偏瘫、口角歪斜、说话含糊
- 大出血、呕血、黑便明显、便血量大
- 严重过敏、喉头紧缩、血压下降
- 高热伴抽搐、婴幼儿精神反应差
对于高危但尚不确定的情况:
- 明确告诉用户本建议不能替代急诊评估
- 不要给出“先在家观察即可”这类强肯定结论
- 可以补充普通门诊建议,但必须降级为“病情稳定后再咨询门诊”
面向C端门诊就医全流程。先做症状分流和挂号科室判断,再推荐医院/医生 Top 3,并继续完成挂号引导、就医准备卡、提醒、诊后解释,以及基于高德地图的到院路线规划。
---
name: ai-medical-care-manager
description: 面向C端门诊就医全流程。先做症状分流和挂号科室判断,再推荐医院/医生 Top 3,并继续完成挂号引导、就医准备卡、提醒、诊后解释,以及基于高德地图的到院路线规划。
metadata: {"openclaw":{"emoji":"🏥","requires":{"bins":["python3","node"]},"homepage":"https://docs.openclaw.ai/skills","install":[{"kind":"node","package":"axios","bins":[]}]}}
---
# AI就医管家
当用户需要完成一次完整门诊就医任务,而不只是问“挂什么科”时,使用这个 skill。
这个 skill 的目标不是替代医生诊断,而是把一次就医任务拆成三个阶段并带用户走完:
- 诊前:判断风险、推荐科室、推荐医院/医生、指导挂号
- 诊中:解析挂号信息、生成就医准备卡、生成提醒、规划路线
- 诊后:解释病历/处方/报告、提炼待办、提示复诊
## 何时使用
适合这些请求:
- “我哪里不舒服,挂什么科?”
- “帮我推荐医院和医生。”
- “我已经挂好号了,帮我看看要准备什么。”
- “帮我做就医提醒和路线。”
- “我看完病了,帮我解释处方/报告。”
- “帮我把这次看病的下一步待办整理出来。”
## 工作原则
1. **先分阶段再行动**:先判断用户处在诊前、诊中还是诊后。
2. **先安全再推荐**:任何高危情形优先急诊,不继续普通门诊推荐。
3. **先结论再理由**:先给用户下一步怎么做,再补理由。
4. **先最小闭环再扩展**:优先解决“这次看病怎么顺利完成”,不要一次堆太多边缘能力。
5. **不替代医生诊断**:只能做辅助分流、流程协助和通俗解释。
开始前先快速想清楚三件事:
- 用户现在最需要解决的,是“判断”“执行”还是“理解”?
- 当前最可能卡住的环节在哪一步?
- 我这次回答里,最具体可执行的下一步是什么?
参考流程说明:`{baseDir}/references/flow_playbook.md`
## 内置资源
- 医院数据:`{baseDir}/assets/hospital_extracted_final.csv`
- 分诊与推荐:`{baseDir}/scripts/triage_and_match.py`
- 挂号文本解析:`{baseDir}/scripts/parse_appointment_text.py`
- 就医准备卡:`{baseDir}/scripts/generate_previsit_card.py`
- 提醒生成:`{baseDir}/scripts/appointment_reminders.py`
- 高德 IP 粗定位:`{baseDir}/scripts/amap_ip_locate.js`
- 高德地址转坐标:`{baseDir}/scripts/amap_geocode.js`
- 高德路线规划与 Web 跳转:`{baseDir}/scripts/amap_route_link.js`
- 急症规则:`{baseDir}/references/triage_rules.md`
- 输出模板:`{baseDir}/references/response_templates.md`
## 诊前:分流、科室判断、推荐 Top 3
### 第一步:收集最少必要信息
优先收集:
- 主诉与持续时间
- 伴随症状
- 年龄、性别
- 既往史/慢病/近期用药/妊娠情况
- 想就诊的城市(默认可按北京处理)
若信息不全,也可以先初步判断,但要明确不确定性。
### 第二步:先做安全分流
先阅读 `{baseDir}/references/triage_rules.md`。
若存在明显急症信号,不要继续普通门诊推荐;直接建议急诊/120。
### 第三步:运行分诊与推荐脚本
```bash
python3 {baseDir}/scripts/triage_and_match.py \
--csv {baseDir}/assets/hospital_extracted_final.csv \
--symptoms "用户主诉与伴随症状" \
--history "既往史或慢病,可为空" \
--age "年龄,可为空" \
--gender "性别,可为空" \
--top-k 3
```
脚本会返回:
- `emergency_flag`
- `department_candidates`
- `top_matches`
### 第四步:组织结果
最终答复中要包含:
- 风险判断
- 推荐科室(主推荐 + 备选)
- Top 3 医院/科室/医生
- 推荐理由
- 挂号方式
输出时参考:`{baseDir}/references/response_templates.md`
### 第五步:固定给出挂号方式
默认给出:
微信内挂号更方便:
- 方式 1:搜索“北京114预约挂号”公众号
- 方式 2:搜索“京通”小程序 → 健康服务 → 预约挂号114
电话方式:
- 拨打 010-114 挂号
并提示用户:挂完号后把截图或文本发上来,我会继续帮你做准备卡、提醒和路线。
## 诊中:准备卡、提醒、路线
### 第一步:解析挂号文本
当用户上传挂号截图 OCR 文本或直接贴出挂号文本时,运行:
```bash
python3 {baseDir}/scripts/parse_appointment_text.py \
--csv {baseDir}/assets/hospital_extracted_final.csv \
--text "挂号截图OCR文本或用户粘贴内容"
```
若字段缺失,继续追问医院、科室、医生、时间中的缺项。
### 第二步:生成就医准备卡
```bash
python3 {baseDir}/scripts/generate_previsit_card.py \
--hospital "医院名" \
--department "科室名" \
--doctor "医生名,可缺省" \
--appointment "2026-03-20 14:30" \
--symptoms "本次主诉摘要" \
--history "病史摘要,可为空" \
--city "北京"
```
把输出整理成用户易读的“就医准备卡”:
- 医院 / 科室 / 医生 / 时间
- 建议到达时间
- 需携带资料
- 这次建议问医生什么
- 哪些病史别漏说
### 第三步:生成提醒
```bash
python3 {baseDir}/scripts/appointment_reminders.py --appointment "2026-03-20 14:30"
```
默认列出三次提醒:T-12h、T-6h、T-2h。
如果当前环境支持闹钟/提醒工具,再在得到用户确认后创建提醒;否则明确列出时间并建议用户设置手机闹钟。
### 第四步:就医路线规划(高德地图)
仅在已配置 `AMAP_WEBSERVICE_KEY` 时执行。未配置时,给出手动高德搜索建议。
#### 4.1 先拿到起点
优先顺序:
1. 如果运行环境或用户上下文中能拿到真实用户 IP,则先尝试粗定位:
```bash
node {baseDir}/scripts/amap_ip_locate.js --ip="用户IP"
```
注意:IP 定位通常只有城市 / 区域级别,只能作为起点猜测。如果结果不够精确,必须继续向用户确认具体出发位置。
2. 如果没有用户 IP,或 IP 无法定位,直接让用户输入当前位置,例如:
- 我现在在朝阳大悦城
- 我从北京西站出发
- 我在望京 SOHO
#### 4.2 再把起终点转成坐标
对用户起点和医院终点分别执行地址转坐标:
```bash
node {baseDir}/scripts/amap_geocode.js --address="用户当前位置描述" --city="北京"
node {baseDir}/scripts/amap_geocode.js --address="医院名称或地址" --city="北京"
```
脚本返回字段包括:
- `location`
- `lng`
- `lat`
注意:高德坐标顺序是 **经度,纬度**。
#### 4.3 再做路线规划并生成 Web 跳转链接
```bash
node {baseDir}/scripts/amap_route_link.js \
--mode=driving \
--origin="116.397428,39.90923" \
--destination="116.427281,39.903719" \
--originName="用户当前位置" \
--destName="医院名称" \
--city="北京"
```
支持的 `mode`:
- `driving`
- `walking`
- `riding`
- `transfer`
输出时给:
- 预计距离
- 预计耗时
- 推荐出行方式
- 可点击的 `amap_link`
### 路线规划的建议话术
- 如果用户要尽快到院,优先 `driving`
- 如果距离短且医院周边停车不便,可给 `walking` 或 `riding`
- 如果用户明确想坐公共交通,使用 `transfer`
## 诊后:解释、待办、复诊
这一阶段主要依靠模型来做通俗解释,不要求额外脚本。
当用户上传病历、处方、检查报告或 OCR 文本时:
1. 先用普通人能理解的话解释这次医生大意。
2. 再总结“今天医生让我做的 3 件事”。
3. 再补充“哪些情况需要尽快复诊 / 复查”。
4. 如果文本里出现明确复查时间,再建议用户设置提醒。
解释时重点覆盖:
- 诊断是什么意思
- 药怎么吃
- 检查结果重点是什么
- 接下来要做什么
必须强调:
- 解释仅供理解,不替代医生最终意见
- 不建议用户自行停药、换药、延误复诊
## 推荐输出风格
每次答复尽量遵守:
- 先给结论
- 再给理由
- 最后给下一步行动
推荐输出结构:
1. 你的当前判断
2. 推荐科室 / 推荐对象
3. 你接下来该做什么
4. 我还能继续帮你什么
## 不该做的事
- 不要给出确定性的疾病诊断
- 不要在高危症状下继续普通门诊推荐
- 不要让用户自己去消化一大段复杂说明
- 不要只给知识,不给可执行下一步
## 技能安装与放置
将此 skill 放到以下任一目录:
- `<workspace>/skills/ai-medical-care-manager`
- `~/.openclaw/skills/ai-medical-care-manager`
如果要启用高德路线规划,请在 `~/.openclaw/openclaw.json` 中给该 skill 配置:
```json
{
"skills": {
"entries": {
"ai-medical-care-manager": {
"enabled": true,
"env": {
"AMAP_WEBSERVICE_KEY": "你的高德 Web Service Key",
"AMAP_KEY": "你的高德 Web Service Key"
}
}
}
}
}
```
FILE:package.json
{
"name": "ai-medical-care-manager",
"version": "2.1.0",
"private": true,
"description": "OpenClaw skill package for AI medical care manager with AMap routing",
"dependencies": {
"axios": "^1.13.6"
}
}
FILE:README.md
# AI Medical Care Manager Skill
这是一个面向 OpenClaw 的门诊就医全流程技能包。
## 目录
- `SKILL.md`:技能说明
- `assets/hospital_extracted_final.csv`:医院/科室/医生数据
- `scripts/`:分诊、解析、提醒、地图相关脚本
- `references/`:流程规则与输出模板
## 可完成的任务
- 症状分流与挂号科室判断
- 医院/医生 Top 3 推荐
- 北京114 / 京通挂号提示
- 挂号文本解析
- 就医准备卡生成
- 三次提醒生成
- 高德地图路线规划(可选)
- 诊后解释与待办整理(由模型按 SKILL.md 指导完成)
## 额外依赖
- `python3`
- `node`
- `axios`(已在 `package.json` 中声明)
## 路线规划说明
路线规划已改为高德方案:
1. 优先尝试用户 IP 粗定位
2. 若无法获取或不够精确,则让用户手动输入当前位置
3. 将起点和医院终点转为高德坐标
4. 生成高德 Web 路线链接
FILE:scripts/amap_geocode.js
#!/usr/bin/env node
const axios = require('axios');
function parseArgs() {
const args = {};
for (const arg of process.argv.slice(2)) {
if (!arg.startsWith('--')) continue;
const idx = arg.indexOf('=');
if (idx === -1) {
args[arg.slice(2)] = true;
} else {
args[arg.slice(2, idx)] = arg.slice(idx + 1);
}
}
return args;
}
async function geocode(address, city) {
const key = process.env.AMAP_WEBSERVICE_KEY || process.env.AMAP_KEY;
if (!key) {
return { error: 'Missing AMAP_WEBSERVICE_KEY (or AMAP_KEY)' };
}
if (!address) {
return { error: 'Missing --address' };
}
try {
const resp = await axios.get('https://restapi.amap.com/v3/geocode/geo', {
params: {
key,
address,
city: city || undefined,
output: 'JSON'
},
timeout: 15000
});
const data = resp.data;
if (data.status !== '1' || !Array.isArray(data.geocodes) || data.geocodes.length === 0) {
return {
error: data.info || 'AMap geocode failed',
raw: data
};
}
const gc = data.geocodes[0];
const [lng, lat] = String(gc.location || '').split(',').map(Number);
return {
address,
city: city || '',
formatted_address: gc.formatted_address || address,
province: gc.province || '',
district: gc.district || '',
adcode: gc.adcode || '',
location: gc.location,
lng,
lat
};
} catch (err) {
return { error: err.message };
}
}
(async () => {
const args = parseArgs();
const result = await geocode(args.address, args.city);
console.log(JSON.stringify(result, null, 2));
process.exit(result.error ? 2 : 0);
})();
FILE:scripts/amap_ip_locate.js
#!/usr/bin/env node
const axios = require('axios');
function parseArgs() {
const args = {};
for (const arg of process.argv.slice(2)) {
if (!arg.startsWith('--')) continue;
const idx = arg.indexOf('=');
if (idx === -1) {
args[arg.slice(2)] = true;
} else {
args[arg.slice(2, idx)] = arg.slice(idx + 1);
}
}
return args;
}
function midpointFromRectangle(rectangle) {
if (!rectangle || !rectangle.includes(';')) return null;
const [p1, p2] = rectangle.split(';');
const [lng1, lat1] = p1.split(',').map(Number);
const [lng2, lat2] = p2.split(',').map(Number);
if ([lng1, lat1, lng2, lat2].some(v => Number.isNaN(v))) return null;
return {
lng: (lng1 + lng2) / 2,
lat: (lat1 + lat2) / 2
};
}
async function locateByIp(ip) {
const key = process.env.AMAP_WEBSERVICE_KEY || process.env.AMAP_KEY;
if (!key) return { error: 'Missing AMAP_WEBSERVICE_KEY (or AMAP_KEY)' };
if (!ip) {
return { error: 'Missing --ip. Only use IP locate when you truly have the user IP; otherwise ask user for current location.' };
}
try {
const resp = await axios.get('https://restapi.amap.com/v3/ip', {
params: { key, ip, output: 'JSON' },
timeout: 15000
});
const data = resp.data;
if (data.status !== '1') {
return { error: data.info || 'AMap IP locate failed', raw: data };
}
const center = midpointFromRectangle(data.rectangle || '');
return {
ip,
province: data.province || '',
city: data.city || '',
district: data.district || '',
adcode: data.adcode || '',
rectangle: data.rectangle || '',
center,
note: 'IP定位通常只到城市/区域级别,若需要精确起点路线,请继续向用户确认当前位置或具体出发地。'
};
} catch (err) {
return { error: err.message };
}
}
(async () => {
const args = parseArgs();
const result = await locateByIp(args.ip);
console.log(JSON.stringify(result, null, 2));
process.exit(result.error ? 2 : 0);
})();
FILE:scripts/amap_route_link.js
#!/usr/bin/env node
const {
walkingRoute,
drivingRoute,
ridingRoute,
transitRoute,
generateMapLink
} = require('./vendor/amap_index');
function parseArgs() {
const args = {};
for (const arg of process.argv.slice(2)) {
if (!arg.startsWith('--')) continue;
const idx = arg.indexOf('=');
if (idx === -1) {
args[arg.slice(2)] = true;
} else {
args[arg.slice(2, idx)] = arg.slice(idx + 1);
}
}
return args;
}
function toMapTask(routeType, originLng, originLat, destLng, destLat, remark, city) {
const task = {
type: 'route',
routeType,
start: [originLng, originLat],
end: [destLng, destLat],
remark
};
if (city && routeType === 'transfer') task.city = city;
return task;
}
function formatDistance(m) {
if (m == null) return '未知';
if (Number(m) >= 1000) return `(Number(m) / 1000).toFixed(1) km`;
return `m m`;
}
function formatDuration(sec) {
if (sec == null) return '未知';
const s = Number(sec);
const h = Math.floor(s / 3600);
const m = Math.round((s % 3600) / 60);
return h > 0 ? `h小时m分钟` : `m分钟`;
}
async function main() {
const args = parseArgs();
const mode = args.mode || 'driving';
const origin = args.origin || (args.originLng && args.originLat ? `args.originLng,args.originLat` : '');
const destination = args.destination || (args.destLng && args.destLat ? `args.destLng,args.destLat` : '');
const originName = args.originName || '起点';
const destName = args.destName || '终点';
const city = args.city || args.region || '';
if (!origin || !destination) {
console.log(JSON.stringify({ error: 'Missing origin/destination. Use --origin=经度,纬度 and --destination=经度,纬度' }, null, 2));
process.exit(2);
}
const [originLng, originLat] = origin.split(',').map(Number);
const [destLng, destLat] = destination.split(',').map(Number);
let data;
let summary = {};
try {
if (mode === 'walking') {
data = await walkingRoute({ origin, destination });
const path = data?.route?.paths?.[0];
summary = {
distance_m: path?.distance ? Number(path.distance) : null,
duration_s: path?.duration ? Number(path.duration) : null,
routes_count: data?.route?.paths?.length || 0
};
} else if (mode === 'riding') {
data = await ridingRoute({ origin, destination });
const path = data?.data?.paths?.[0];
summary = {
distance_m: path?.distance ? Number(path.distance) : null,
duration_s: path?.duration ? Number(path.duration) : null,
routes_count: data?.data?.paths?.length || 0
};
} else if (mode === 'transfer') {
if (!city) {
console.log(JSON.stringify({ error: 'Transit mode requires --city or --region' }, null, 2));
process.exit(2);
}
data = await transitRoute({ origin, destination, city, strategy: args.strategy ? Number(args.strategy) : 0 });
const transit = data?.route?.transits?.[0];
summary = {
distance_m: transit?.distance ? Number(transit.distance) : null,
duration_s: transit?.duration ? Number(transit.duration) : null,
routes_count: data?.route?.transits?.length || 0,
cost: transit?.cost ? Number(transit.cost) : null,
walking_distance_m: transit?.walking_distance ? Number(transit.walking_distance) : null
};
} else {
data = await drivingRoute({ origin, destination, strategy: args.strategy ? Number(args.strategy) : 10 });
const path = data?.route?.paths?.[0];
summary = {
distance_m: path?.distance ? Number(path.distance) : null,
duration_s: path?.duration ? Number(path.duration) : null,
routes_count: data?.route?.paths?.length || 0,
tolls: path?.tolls ? Number(path.tolls) : 0,
traffic_lights: path?.traffic_lights ? Number(path.traffic_lights) : 0
};
}
if (!data) {
console.log(JSON.stringify({ error: 'Route planning failed' }, null, 2));
process.exit(2);
}
const mapLink = generateMapLink([
toMapTask(mode, originLng, originLat, destLng, destLat, `originName → destName`, city)
]);
const output = {
mode,
origin_name: originName,
dest_name: destName,
origin,
destination,
...summary,
distance_text: formatDistance(summary.distance_m),
duration_text: formatDuration(summary.duration_s),
amap_link: mapLink,
note: '点击 amap_link 可在 Web 中查看路线结果。经纬度顺序为“经度,纬度”。'
};
console.log(JSON.stringify(output, null, 2));
} catch (err) {
console.log(JSON.stringify({ error: err.message }, null, 2));
process.exit(2);
}
}
main();
FILE:scripts/appointment_reminders.py
#!/usr/bin/env python3
import argparse
import json
import re
from datetime import datetime, timedelta
FORMATS = [
"%Y-%m-%d %H:%M",
"%Y/%m/%d %H:%M",
"%Y.%m.%d %H:%M",
"%Y年%m月%d日 %H:%M",
"%Y年%m月%d日%H:%M",
"%Y-%m-%dT%H:%M",
]
def normalize(text: str) -> str:
text = text.strip()
text = text.replace("上午", " ").replace("下午", " ")
text = re.sub(r"\s+", " ", text)
return text
def parse_dt(text: str) -> datetime:
text = normalize(text)
for fmt in FORMATS:
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
raise ValueError("Unsupported datetime format. Use e.g. 2026-03-20 14:30")
def main() -> int:
p = argparse.ArgumentParser(description="Generate 3 reminder times before a medical appointment")
p.add_argument("--appointment", required=True, help="Appointment datetime, e.g. 2026-03-20 14:30")
args = p.parse_args()
dt = parse_dt(args.appointment)
reminders = [
("T-12h", dt - timedelta(hours=12)),
("T-6h", dt - timedelta(hours=6)),
("T-2h", dt - timedelta(hours=2)),
]
output = {
"appointment": dt.strftime("%Y-%m-%d %H:%M"),
"reminders": [
{"label": label, "time": when.strftime("%Y-%m-%d %H:%M")} for label, when in reminders
],
"note": "默认输出提前半天内的3次提醒:12小时、6小时、2小时。",
}
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/generate_previsit_card.py
#!/usr/bin/env python3
import argparse
import json
from datetime import datetime, timedelta
QUESTION_RULES = {
'呼吸与危重症医学科': ['这次症状更像感染、过敏还是慢性问题?', '是否需要做胸片/CT/肺功能?', '目前用药是否需要调整?'],
'心血管科': ['这次胸闷胸痛的危险程度如何?', '是否需要心电图/动态心电图/超声?', '现有降压/心脏药是否要调整?'],
'消化科': ['这次腹痛或反酸最可能的原因是什么?', '是否需要胃镜/幽门螺杆菌检查?', '饮食和用药上要注意什么?'],
'妇产科': ['这次症状是否需要进一步检查?', '近期月经/妊娠情况会不会影响诊疗?', '是否需要做B超或激素相关检查?'],
'皮肤科': ['最可能的诱因是什么?', '需要口服药还是外用药?', '哪些情况加重时要复诊?'],
'综合科': ['这次最需要先排查什么?', '还需要转诊到哪个专科?', '我现在最需要做的检查是什么?'],
}
COMMON_ITEMS = ['身份证/医保卡', '既往病历或出院小结', '近期检查报告/化验单', '当前正在使用的药物清单']
def choose_questions(department: str):
for key, items in QUESTION_RULES.items():
if key in department or department in key:
return items
return QUESTION_RULES['综合科']
def main() -> int:
p = argparse.ArgumentParser(description='Generate a structured pre-visit card')
p.add_argument('--hospital', required=True)
p.add_argument('--department', required=True)
p.add_argument('--doctor', default='待定')
p.add_argument('--appointment', required=True, help='YYYY-MM-DD HH:MM')
p.add_argument('--symptoms', default='')
p.add_argument('--history', default='')
p.add_argument('--city', default='北京')
args = p.parse_args()
dt = datetime.strptime(args.appointment, '%Y-%m-%d %H:%M')
arrive = dt - timedelta(minutes=60)
card = {
'hospital': args.hospital,
'department': args.department,
'doctor': args.doctor,
'appointment': dt.strftime('%Y-%m-%d %H:%M'),
'suggested_arrival_time': arrive.strftime('%Y-%m-%d %H:%M'),
'city': args.city,
'bring_items': COMMON_ITEMS,
'symptom_summary': args.symptoms,
'history_summary': args.history,
'questions_for_doctor': choose_questions(args.department),
'important_notes_to_tell_doctor': [
'症状持续多久、是否加重',
'既往慢病、近期用药、过敏史',
'最近做过的检查和异常结果',
],
'note': '如为检查类项目,请再次确认是否需要空腹、停药或家属陪同。'
}
print(json.dumps(card, ensure_ascii=False, indent=2))
return 0
if __name__ == '__main__':
raise SystemExit(main())
FILE:scripts/parse_appointment_text.py
#!/usr/bin/env python3
import argparse
import csv
import json
import re
from pathlib import Path
from typing import Dict, List, Optional
DT_PATTERNS = [
(re.compile(r'(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})[日\sT]*(\d{1,2})[::](\d{2})'), '%04d-%02d-%02d %02d:%02d'),
]
def normalize(s: str) -> str:
if s is None:
return ''
s = str(s).strip()
return '' if s.lower() == 'nan' else s
def load_candidates(csv_path: str):
hospitals, departments, doctors = set(), set(), set()
with open(csv_path, 'r', encoding='utf-8-sig', newline='') as f:
reader = csv.DictReader(f)
for row in reader:
hospitals.add(normalize(row.get('hospital_name')))
departments.add(normalize(row.get('department_name')))
doctors.add(normalize(row.get('doctor_name')))
hospitals.discard(''); departments.discard(''); doctors.discard('')
return sorted(hospitals, key=len, reverse=True), sorted(departments, key=len, reverse=True), sorted(doctors, key=len, reverse=True)
def find_first(text: str, candidates: List[str]) -> Optional[str]:
for item in candidates:
if item and item in text:
return item
return None
def extract_datetime(text: str) -> Optional[str]:
text = text.replace('上午', ' ').replace('下午', ' ')
for patt, fmt in DT_PATTERNS:
m = patt.search(text)
if m:
y, mo, d, h, mi = map(int, m.groups())
return fmt % (y, mo, d, h, mi)
return None
def main() -> int:
p = argparse.ArgumentParser(description='Extract appointment fields from booking text or OCR text')
p.add_argument('--csv', required=True)
p.add_argument('--text', required=True)
args = p.parse_args()
hospitals, departments, doctors = load_candidates(args.csv)
text = normalize(args.text)
result: Dict[str, Optional[str]] = {
'hospital_name': find_first(text, hospitals),
'department_name': find_first(text, departments),
'doctor_name': find_first(text, doctors),
'appointment_datetime': extract_datetime(text),
}
missing = [k for k, v in result.items() if not v]
confidence = 1.0 - (len(missing) / len(result))
output = {
'parsed': result,
'missing_fields': missing,
'confidence': round(confidence, 2),
'note': '如字段缺失,请让用户手动补充医院、科室、医生或就诊时间。',
}
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0
if __name__ == '__main__':
raise SystemExit(main())
FILE:scripts/triage_and_match.py
#!/usr/bin/env python3
import argparse
import csv
import json
import math
import os
import re
from collections import defaultdict
from typing import Dict, List, Tuple
DEPARTMENT_RULES = [
{
"department": "呼吸与危重症医学科",
"aliases": ["呼吸科", "呼吸内科", "肺病科"],
"keywords": ["咳嗽", "咳痰", "发热", "发烧", "胸闷", "气短", "气喘", "呼吸困难", "哮喘", "肺炎", "肺结节", "鼻塞", "流感", "新冠", "支气管", "胸痛"],
},
{
"department": "心血管科",
"aliases": ["心内科", "心血管内科", "心脏科", "心脏中心"],
"keywords": ["胸痛", "胸闷", "心慌", "心悸", "高血压", "血压高", "冠心病", "心绞痛", "气短", "头晕", "水肿"],
},
{
"department": "神经科",
"aliases": ["神经内科", "神经外科", "神经外一科"],
"keywords": ["头痛", "眩晕", "头晕", "肢体麻木", "偏瘫", "抽搐", "癫痫", "面瘫", "失眠", "记忆力", "意识", "中风", "脑梗", "脑出血"],
},
{
"department": "消化科",
"aliases": ["消化内科", "内镜中心"],
"keywords": ["腹痛", "胃痛", "反酸", "烧心", "呕吐", "恶心", "腹泻", "便秘", "黑便", "便血", "胃胀", "肝", "胆", "胰", "消化不良"],
},
{
"department": "内分泌科",
"aliases": ["糖尿病科"],
"keywords": ["血糖", "糖尿病", "甲状腺", "肥胖", "消瘦", "多饮", "多尿", "内分泌", "痛风", "尿酸"],
},
{
"department": "妇产科",
"aliases": ["妇科", "中医妇科", "妇产科(含国际部妇产科病区"],
"keywords": ["月经", "经期", "阴道", "白带", "宫颈", "子宫", "卵巢", "乳房", "怀孕", "妊娠", "备孕", "产检", "妇科"],
},
{
"department": "泌尿外科",
"aliases": ["泌尿科"],
"keywords": ["尿频", "尿急", "尿痛", "血尿", "前列腺", "肾结石", "排尿", "泌尿", "腰痛"],
},
{
"department": "皮肤科",
"aliases": [],
"keywords": ["皮疹", "瘙痒", "湿疹", "荨麻疹", "痘", "痤疮", "脱发", "皮肤", "红斑", "过敏"],
},
{
"department": "眼科",
"aliases": ["眼科特需门诊"],
"keywords": ["眼痛", "视力", "视物模糊", "红眼", "流泪", "眼干", "飞蚊", "白内障", "青光眼"],
},
{
"department": "口腔医学中心",
"aliases": ["口腔科", "牙科"],
"keywords": ["牙痛", "牙龈", "口腔", "智齿", "牙周", "口臭", "龋齿"],
},
{
"department": "骨科",
"aliases": ["骨科·关节外科", "骨科·脊柱外科", "脊柱二科"],
"keywords": ["膝盖", "关节", "骨折", "扭伤", "腰椎", "脊柱", "颈椎", "肩痛", "骨痛", "运动损伤"],
},
{
"department": "风湿免疫科",
"aliases": ["风湿病科", "中医风湿病科"],
"keywords": ["关节痛", "晨僵", "红斑狼疮", "风湿", "类风湿", "免疫", "干燥综合征"],
},
{
"department": "心理科",
"aliases": ["心身医学科"],
"keywords": ["焦虑", "抑郁", "失眠", "情绪", "惊恐", "心理", "压力", "睡不着"],
},
{
"department": "感染疾病科",
"aliases": [],
"keywords": ["乙肝", "丙肝", "感染", "发热", "传染", "艾滋", "结核"],
},
{
"department": "肿瘤科",
"aliases": ["中西医结合肿瘤内科", "放射肿瘤科"],
"keywords": ["肿瘤", "癌", "结节", "化疗", "放疗", "靶向", "肿块"],
},
{
"department": "康复医学科",
"aliases": [],
"keywords": ["康复", "术后恢复", "偏瘫康复", "理疗", "功能训练"],
},
{
"department": "老年病科",
"aliases": [],
"keywords": ["老年", "多病共存", "高龄", "慢病管理"],
},
{
"department": "综合科",
"aliases": ["普通内科", "全科"],
"keywords": ["不确定挂什么科", "多种症状", "体检异常", "慢病复诊"],
},
]
EMERGENCY_KEYWORDS = [
"持续胸痛", "胸痛大汗", "呼吸困难", "意识不清", "昏迷", "抽搐不止", "大出血", "便血很多", "黑便明显",
"呕血", "偏瘫", "口角歪斜", "说话不清", "突发剧烈头痛", "高热惊厥", "严重过敏", "休克",
]
NON_CLINICAL_KEYWORDS = [
"实验室", "药理", "病理", "放射", "职能", "共同制定", "自成立之日", "积极推行", "设有门诊", "临床药理室", "检验科", "输血科",
]
def normalize_text(value: str) -> str:
if value is None:
return ""
value = str(value).strip()
if value.lower() == "nan":
return ""
return value
def tokenize(text: str) -> List[str]:
text = normalize_text(text)
cjk = re.findall(r"[\u4e00-\u9fff]{2,}", text)
latin = re.findall(r"[A-Za-z]{2,}", text)
return cjk + latin
def keyword_hits(text: str, keywords: List[str]) -> Tuple[int, List[str]]:
hits = []
for kw in keywords:
if kw and kw in text:
hits.append(kw)
return len(hits), hits
def infer_departments(symptom_text: str) -> Tuple[List[Dict], bool, List[str]]:
emergency_count, emergency_hits = keyword_hits(symptom_text, EMERGENCY_KEYWORDS)
emergency = emergency_count > 0
scores = []
for rule in DEPARTMENT_RULES:
count, hits = keyword_hits(symptom_text, rule["keywords"])
if count > 0:
scores.append(
{
"department": rule["department"],
"aliases": rule["aliases"],
"score": count,
"hits": hits,
}
)
if not scores:
scores = [{"department": "综合科", "aliases": ["普通内科", "全科"], "score": 1, "hits": ["症状描述不足,建议先做综合分诊"]}]
scores.sort(key=lambda x: (-x["score"], x["department"]))
return scores, emergency, emergency_hits
def load_rows(csv_path: str) -> List[Dict[str, str]]:
rows = []
with open(csv_path, "r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
for row in reader:
rows.append({k: normalize_text(v) for k, v in row.items()})
return rows
def row_is_clinical(row: Dict[str, str]) -> bool:
dept = row.get("department_name", "")
doctor = row.get("doctor_name", "")
hospital = row.get("hospital_name", "")
if not dept or not doctor:
return False
if doctor == dept or doctor == hospital:
return False
if ("科" in doctor or "中心" in doctor) and len(doctor) >= 2 and len(doctor) <= 12:
return False
for kw in NON_CLINICAL_KEYWORDS:
if kw in dept:
return False
return True
def department_match_score(row: Dict[str, str], inferred: List[Dict]) -> Tuple[float, str]:
dept_name = row.get("department_name", "")
doctor_dept = row.get("doctor_department", "")
best = 0.0
reason = ""
for cand in inferred:
targets = [cand["department"]] + cand.get("aliases", [])
for t in targets:
if t and (t in dept_name or t in doctor_dept or dept_name in t or doctor_dept in t):
score = 6.0 + cand["score"] * 1.5
if score > best:
best = score
reason = f"科室匹配:{cand['department']}"
# fuzzy fallback via shared chars
overlap = len(set(dept_name) & set(cand["department"]))
if overlap >= 2:
score = 2.0 + overlap * 0.5 + cand["score"]
if score > best:
best = score
reason = f"科室近似匹配:{cand['department']}"
return best, reason
def content_match_score(row: Dict[str, str], symptom_text: str, inferred: List[Dict]) -> Tuple[float, List[str]]:
bag = " ".join([
row.get("hospital_name", ""),
row.get("hospital_intro", ""),
row.get("department_name", ""),
row.get("department_intro", ""),
row.get("doctor_name", ""),
row.get("doctor_department", ""),
row.get("doctor_intro", ""),
])
score = 0.0
reasons: List[str] = []
symptom_tokens = set(tokenize(symptom_text))
bag_tokens = set(tokenize(bag))
overlap = symptom_tokens & bag_tokens
if overlap:
score += min(4.0, len(overlap) * 1.2)
reasons.append("症状关键词重合:" + "、".join(sorted(list(overlap))[:4]))
for cand in inferred[:3]:
hits = [kw for kw in cand["hits"] if kw in bag]
if hits:
add = min(3.5, len(hits) * 1.0)
score += add
reasons.append(f"擅长/简介命中:{'、'.join(hits[:4])}")
break
schedule = row.get("doctor_schedule", "")
if schedule:
score += 0.5
reasons.append("有坐诊时间信息")
return score, reasons
def aggregate_rows(rows: List[Dict[str, str]], inferred: List[Dict], symptom_text: str, top_k: int) -> List[Dict]:
scored = []
for row in rows:
if not row_is_clinical(row):
continue
d_score, d_reason = department_match_score(row, inferred)
c_score, c_reasons = content_match_score(row, symptom_text, inferred)
total = d_score + c_score
if total <= 0:
continue
reasons = []
if d_reason:
reasons.append(d_reason)
reasons.extend(c_reasons)
scored.append(
{
"hospital_name": row.get("hospital_name", ""),
"hospital_intro": row.get("hospital_intro", ""),
"department_name": row.get("department_name", ""),
"department_intro": row.get("department_intro", ""),
"doctor_name": row.get("doctor_name", ""),
"doctor_department": row.get("doctor_department", ""),
"doctor_intro": row.get("doctor_intro", ""),
"doctor_schedule": row.get("doctor_schedule", ""),
"score": round(total, 2),
"reasons": reasons[:4],
}
)
# unique by hospital+department+doctor, keep best score
best_by_key: Dict[Tuple[str, str, str], Dict] = {}
for item in scored:
key = (item["hospital_name"], item["department_name"], item["doctor_name"])
old = best_by_key.get(key)
if old is None or item["score"] > old["score"]:
best_by_key[key] = item
deduped = list(best_by_key.values())
deduped.sort(key=lambda x: (-x["score"], x["hospital_name"], x["department_name"], x["doctor_name"]))
return deduped[:top_k]
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Infer likely departments from symptoms and match top hospitals/doctors from a CSV dataset.")
p.add_argument("--csv", required=True, help="Path to hospital_extracted_final.csv")
p.add_argument("--symptoms", required=True, help="User symptom narrative")
p.add_argument("--history", default="", help="Past history / chronic conditions / meds")
p.add_argument("--age", default="", help="Age, optional")
p.add_argument("--gender", default="", help="Gender, optional")
p.add_argument("--top-k", type=int, default=3, help="How many matches to return")
return p
def main() -> int:
args = build_parser().parse_args()
if not os.path.exists(args.csv):
print(json.dumps({"error": f"CSV not found: {args.csv}"}, ensure_ascii=False))
return 2
symptom_text = ";".join([x for x in [args.symptoms, args.history, args.age, args.gender] if x])
inferred, emergency, emergency_hits = infer_departments(symptom_text)
rows = load_rows(args.csv)
matches = aggregate_rows(rows, inferred, symptom_text, args.top_k)
output = {
"input": {
"symptoms": args.symptoms,
"history": args.history,
"age": args.age,
"gender": args.gender,
},
"emergency_flag": emergency,
"emergency_hits": emergency_hits,
"department_candidates": inferred[:5],
"top_matches": matches,
"notes": [
"结果仅用于挂号分诊辅助,不替代医生诊断。",
"若出现持续胸痛、明显呼吸困难、意识改变、偏瘫、抽搐不止、大出血等情况,应优先急诊。",
"当前CSV中存在部分科研/实验室类行,脚本已尽量过滤,但仍建议人工复核。",
],
}
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/vendor/amap_index.js
const fs = require('fs');
const path = require('path');
const axios = require('axios');
// 配置文件路径
const CONFIG_FILE = path.join(__dirname, 'config.json');
/**
* 读取配置文件
*/
function readConfig() {
try {
if (fs.existsSync(CONFIG_FILE)) {
const data = fs.readFileSync(CONFIG_FILE, 'utf8');
return JSON.parse(data);
}
} catch (error) {
console.error('读取配置文件失败:', error.message);
}
return {};
}
/**
* 保存配置文件
*/
function saveConfig(config) {
try {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
console.log('配置已保存到:', CONFIG_FILE);
return true;
} catch (error) {
console.error('保存配置文件失败:', error.message);
return false;
}
}
/**
* 获取高德 Web Service Key
*/
function getWebServiceKey() {
const config = readConfig();
return config.webServiceKey || null;
}
/**
* 设置高德 Web Service Key
*/
function setWebServiceKey(key) {
const config = readConfig();
config.webServiceKey = key;
return saveConfig(config);
}
/**
* 检查并提示用户输入 Key
*/
async function ensureWebServiceKey() {
// 优先从环境变量读取
let key = process.env.AMAP_KEY || process.env.AMAP_WEBSERVICE_KEY;
if (!key) {
// 尝试从配置文件读取
key = getWebServiceKey();
}
if (!key) {
console.log('\n⚠️ 未找到高德 Web Service Key');
console.log('请访问以下地址创建应用并获取 Key:');
console.log('https://lbs.amap.com/api/webservice/create-project-and-key\n');
throw new Error('请设置环境变量 AMAP_KEY 或提供高德 Web Service Key');
}
return key;
}
/**
* POI 搜索
* @param {Object} params - 搜索参数
* @param {string} params.keywords - 查询关键字
* @param {string} params.city - 城市名称或城市编码
* @param {string} params.types - POI类型编码
* @param {string} params.location - 中心点坐标
* @param {number} params.radius - 搜索半径(米)
* @param {number} params.page - 当前页数
* @param {number} params.offset - 每页记录数
*/
async function searchPOI(params) {
const key = await ensureWebServiceKey();
const url = 'https://restapi.amap.com/v5/place/text';
const requestParams = {
key: key,
keywords: params.keywords || '',
region: params.city || '',
city_limit: params.cityLimit !== false,
...params
};
try {
console.log('🔍 正在搜索 POI...');
const response = await axios.get(url, { params: requestParams });
if (response.data.status === '1') {
console.log(`✅ 搜索成功,共找到 response.data.count 条结果\n`);
return response.data;
} else {
console.error('❌ 搜索失败:', response.data.info);
return null;
}
} catch (error) {
console.error('❌ 请求失败:', error.message);
return null;
}
}
/**
* 步行路径规划
* @param {Object} params - 规划参数
* @param {string} params.origin - 起点坐标 "经度,纬度"
* @param {string} params.destination - 终点坐标 "经度,纬度"
*/
async function walkingRoute(params) {
const key = await ensureWebServiceKey();
const url = 'https://restapi.amap.com/v3/direction/walking';
const requestParams = {
key: key,
origin: params.origin,
destination: params.destination
};
try {
console.log('🚶 正在规划步行路线...');
const response = await axios.get(url, { params: requestParams });
if (response.data.status === '1') {
console.log('✅ 步行路线规划成功\n');
return response.data;
} else {
console.error('❌ 步行路线规划失败:', response.data.info);
return null;
}
} catch (error) {
console.error('❌ 请求失败:', error.message);
return null;
}
}
/**
* 驾车路径规划
* @param {Object} params - 规划参数
* @param {string} params.origin - 起点坐标 "经度,纬度"
* @param {string} params.destination - 终点坐标 "经度,纬度"
* @param {string} params.waypoints - 途经点坐标,多个用;分隔
* @param {number} params.strategy - 驾车策略,默认10
*/
async function drivingRoute(params) {
const key = await ensureWebServiceKey();
const url = 'https://restapi.amap.com/v3/direction/driving';
const requestParams = {
key: key,
origin: params.origin,
destination: params.destination,
strategy: params.strategy || 10,
extensions: 'base'
};
if (params.waypoints) {
requestParams.waypoints = params.waypoints;
}
try {
console.log('🚗 正在规划驾车路线...');
const response = await axios.get(url, { params: requestParams });
if (response.data.status === '1') {
console.log('✅ 驾车路线规划成功\n');
return response.data;
} else {
console.error('❌ 驾车路线规划失败:', response.data.info);
return null;
}
} catch (error) {
console.error('❌ 请求失败:', error.message);
return null;
}
}
/**
* 骑行路径规划
* @param {Object} params - 规划参数
* @param {string} params.origin - 起点坐标 "经度,纬度"
* @param {string} params.destination - 终点坐标 "经度,纬度"
*/
async function ridingRoute(params) {
const key = await ensureWebServiceKey();
const url = 'https://restapi.amap.com/v4/direction/bicycling';
const requestParams = {
key: key,
origin: params.origin,
destination: params.destination
};
try {
console.log('🚴 正在规划骑行路线...');
const response = await axios.get(url, { params: requestParams });
if (response.data.errcode === 0) {
console.log('✅ 骑行路线规划成功\n');
return response.data;
} else {
console.error('❌ 骑行路线规划失败:', response.data.errmsg);
return null;
}
} catch (error) {
console.error('❌ 请求失败:', error.message);
return null;
}
}
/**
* 公交路径规划
* @param {Object} params - 规划参数
* @param {string} params.origin - 起点坐标 "经度,纬度"
* @param {string} params.destination - 终点坐标 "经度,纬度"
* @param {string} params.city - 城市名称或城市编码
* @param {number} params.strategy - 公交策略,默认0(最快捷)
* @param {boolean} params.nightflag - 是否计算夜班车,默认false
*/
async function transitRoute(params) {
const key = await ensureWebServiceKey();
const url = 'https://restapi.amap.com/v3/direction/transit/integrated';
const requestParams = {
key: key,
origin: params.origin,
destination: params.destination,
city: params.city,
strategy: params.strategy || 0,
nightflag: params.nightflag ? 1 : 0
};
try {
console.log('🚌 正在规划公交路线...');
const response = await axios.get(url, { params: requestParams });
if (response.data.status === '1') {
console.log('✅ 公交路线规划成功\n');
return response.data;
} else {
console.error('❌ 公交路线规划失败:', response.data.info);
return null;
}
} catch (error) {
console.error('❌ 请求失败:', error.message);
return null;
}
}
/**
* 生成地图可视化链接
* @param {Array} mapTaskData - 地图任务数据数组
* @returns {string} 可视化链接
*/
function generateMapLink(mapTaskData) {
const baseUrl = 'https://a.amap.com/jsapi_demo_show/static/openclaw/travel_plan.html';
const dataStr = encodeURIComponent(JSON.stringify(mapTaskData));
return `baseUrl?data=dataStr`;
}
/**
* 旅游规划助手
* @param {Object} params - 规划参数
* @param {string} params.city - 城市名称
* @param {Array<string>} params.interests - 兴趣点关键词数组,如 ['景点', '美食', '酒店']
* @param {string} params.routeType - 路线类型:driving/walking/riding/transfer
* @returns {Object} 包含 pois、mapTaskData、mapLink 和 htmlLink
*/
async function travelPlanner(params) {
const { city, interests = [], routeType = 'walking' } = params;
console.log(`\n🗺️ 开始为您规划 city 的旅游行程...\n`);
const mapTaskData = [];
const poiResults = [];
// 搜索各类兴趣点
for (const interest of interests) {
console.log(`📍 搜索 interest...`);
const result = await searchPOI({
keywords: interest,
city: city,
page: 1,
offset: 5
});
if (result && result.pois && result.pois.length > 0) {
poiResults.push(...result.pois);
// 添加到地图数据 - 严格按照 PoiTask 接口格式
result.pois.forEach(poi => {
const [lng, lat] = poi.location.split(',').map(Number);
mapTaskData.push({
type: 'poi',
lnglat: [lng, lat],
sort: poi.type || interest,
text: poi.name,
remark: poi.address || `interest推荐`
});
});
}
}
// 如果有多个POI,规划路线
if (poiResults.length >= 2) {
console.log(`\n🛣️ 规划游览路线(routeType)...\n`);
for (let i = 0; i < poiResults.length - 1; i++) {
const start = poiResults[i];
const end = poiResults[i + 1];
const [startLng, startLat] = start.location.split(',').map(Number);
const [endLng, endLat] = end.location.split(',').map(Number);
// 添加路线到地图数据 - 严格按照 RouteTask 接口格式
const routeTask = {
type: 'route',
routeType: routeType,
start: [startLng, startLat],
end: [endLng, endLat],
remark: `从 start.name 到 end.name`
};
// 如果是公交路线,添加 city 参数
if (routeType === 'transfer') {
routeTask.city = city;
}
mapTaskData.push(routeTask);
}
}
console.log('\n✅ 旅游规划完成!\n');
console.log('📍 推荐地点:');
poiResults.forEach((poi, index) => {
console.log(`index + 1. poi.name`);
console.log(` 地址: poi.address`);
console.log(` 类型: poi.type\n`);
});
return {
pois: poiResults,
};
}
// 导出函数供其他脚本使用
module.exports = {
readConfig,
saveConfig,
getWebServiceKey,
setWebServiceKey,
ensureWebServiceKey,
searchPOI,
walkingRoute,
drivingRoute,
ridingRoute,
transitRoute,
generateMapLink,
travelPlanner
};
// 如果直接运行此文件,执行示例搜索
if (require.main === module) {
(async () => {
try {
// 示例:搜索北京的肯德基
const result = await searchPOI({
keywords: '肯德基',
city: '北京',
page: 1,
offset: 10
});
if (result && result.pois) {
console.log('搜索结果:');
result.pois.forEach((poi, index) => {
console.log(`index + 1. poi.name`);
console.log(` 地址: poi.address`);
console.log(` 类型: poi.type`);
console.log(` 坐标: poi.location\n`);
});
}
} catch (error) {
console.error('执行失败:', error.message);
process.exit(1);
}
})();
}
FILE:references/flow_playbook.md
# 就医流程 playbook
本 skill 按三个阶段工作:
## 诊前
1. 收集主诉、持续时间、伴随症状、年龄、性别、病史、城市。
2. 先做安全分流:判断是否急诊。
3. 用 triage_and_match.py 产出推荐科室与 Top 3 医院/医生。
4. 给出挂号方式:北京114 / 京通 / 010-114。
## 诊中
1. 用户提供挂号截图 OCR 文本或直接粘贴挂号信息。
2. 用 parse_appointment_text.py 解析医院、科室、医生、就诊时间。
3. 用 generate_previsit_card.py 生成就医准备卡。
4. 用 appointment_reminders.py 生成三次提醒。
5. 如已配置 AMAP_WEBSERVICE_KEY,则先尝试真实用户 IP 粗定位;若拿不到或不够精确,则让用户手动提供当前位置。
6. 用 amap_geocode.js 把用户起点和医院终点转为高德坐标。
7. 用 amap_route_link.js 生成路线摘要和高德 Web 链接。
## 诊后
1. 让用户上传病历、处方、报告文本或截图 OCR 文本。
2. 先用通俗语言解释:诊断是什么、药怎么吃、接下来做什么。
3. 总结为“医生今天让我做的事”和“哪些情况需要尽快复诊”。
4. 如文本中出现复查日期或复诊时间,再建议设置提醒。
## 回答风格
- 不替代医生诊断
- 先给结论,再给理由,再给下一步
- 任何高危情形优先急诊
- 推荐结果要可执行,不要只讲知识
FILE:references/response_templates.md
# 推荐输出模板
## 诊前输出模板
- 风险判断:
- 推荐科室:主推荐 + 备选
- Top 3 推荐:医院 / 科室 / 医生 / 坐诊信息 / 推荐理由
- 挂号方式:114 / 京通 / 电话
- 下一步:挂号后把截图或文本发上来,我继续帮你做提醒和路线
## 诊中输出模板
- 我已识别到:医院 / 科室 / 医生 / 时间
- 就医准备卡:带什么、几点到、问医生什么
- 三次提醒时间:T-12h / T-6h / T-2h
- 路线:预计距离 / 耗时 / 地图链接
## 诊后输出模板
- 今天医生大意:
- 药怎么吃:
- 接下来要做的 3 件事:
- 什么时候复诊 / 哪些情况尽快复诊:
FILE:references/triage_rules.md
# 急症分流参考规则
以下情况优先建议急诊或直接拨打 120,不应继续普通门诊推荐:
- 持续胸痛、胸闷伴大汗或濒死感
- 明显呼吸困难、无法完整说话、嘴唇发紫
- 意识不清、昏迷、抽搐不止
- 突发偏瘫、口角歪斜、说话含糊
- 大出血、呕血、黑便明显、便血量大
- 严重过敏、喉头紧缩、血压下降
- 高热伴抽搐、婴幼儿精神反应差
对于高危但尚不确定的情况:
- 明确告诉用户本建议不能替代急诊评估
- 不要给出“先在家观察即可”这类强肯定结论
- 可以补充普通门诊建议,但必须降级为“病情稳定后再咨询门诊”
面向C端医疗导诊。根据用户主诉、病史等信息先做安全分流和挂号科室判断,再从内置医院数据中筛选最适合的医院/科室/医生 Top 3;随后给出北京114/京通挂号提示、挂号后提醒建议,以及基于百度地图的到院路线规划链接。
---
name: medical-triage-booking
description: 面向C端医疗导诊。根据用户主诉、病史等信息先做安全分流和挂号科室判断,再从内置医院数据中筛选最适合的医院/科室/医生 Top 3;随后给出北京114/京通挂号提示、挂号后提醒建议,以及基于百度地图的到院路线规划链接。
metadata: {"openclaw":{"emoji":"🏥","requires":{"bins":["python3"]}}}
---
# 医疗导诊、挂号提示与路线规划
当用户需要“看病挂什么科”“推荐医院/医生”“挂号后提醒”“到医院怎么走”时,使用这个 skill。
## 何时使用
适合以下请求:
- 用户描述症状、主诉、病史,想知道挂什么科
- 用户想从内置医院/科室/医生数据里筛选出更合适的医生
- 用户想要北京114/京通挂号提示
- 用户上传挂号截图或文本,想做就医提醒
- 用户想根据挂号医院做百度地图路线规划
## 内置资源
- 医院数据:`{baseDir}/assets/hospital_extracted_final.csv`
- 分诊脚本:`{baseDir}/scripts/triage_and_match.py`
- 路线规划脚本:`{baseDir}/scripts/baidu_route_link.py`
- 地理编码脚本:`{baseDir}/scripts/baidu_geocode.py`
- 提醒时间脚本:`{baseDir}/scripts/appointment_reminders.py`
- 分诊参考:`{baseDir}/references/triage_rules.md`
## 工作流程
### 1. 先收集最少必要信息
优先收集:
- 主诉与持续时间
- 伴随症状(发热、胸痛、呼吸困难、腹痛、皮疹、头痛等)
- 年龄、性别
- 既往史/慢病/近期用药/是否妊娠
- 想就诊的城市或默认北京
若信息不全,先用已有信息做初步判断,但要明确不确定性。
### 2. 先做安全分流
先阅读 `{baseDir}/references/triage_rules.md`。
如果存在明显急症信号(如持续胸痛、明显呼吸困难、意识改变、偏瘫、抽搐不止、大出血等),不要继续做普通门诊推荐;优先建议急诊/120,并把普通挂号建议降级为“病情稳定后再咨询门诊”。
### 3. 用脚本完成科室判断与医院/医生匹配
运行:
```bash
python3 {baseDir}/scripts/triage_and_match.py \
--csv {baseDir}/assets/hospital_extracted_final.csv \
--symptoms "用户主诉" \
--history "既往史或慢病,可为空" \
--age "年龄,可为空" \
--gender "性别,可为空" \
--top-k 3
```
脚本会返回:
- `emergency_flag`: 是否疑似急症
- `department_candidates`: 推断出的科室候选
- `top_matches`: 排名前3的医院/科室/医生及理由
### 4. 组织给用户的推荐结果
最终答复里优先给出:
1. 推荐挂号科室(1个主推荐 + 1~2个备选)
2. Top 3 医院/科室/医生推荐
3. 每个推荐对象的简要理由
4. 明确声明“仅用于分诊辅助,不替代医生诊断”
推荐结果建议按以下结构输出:
- 推荐科室
- Top 3 推荐
- 医院
- 科室
- 医生
- 坐诊时间(若有)
- 推荐理由
- 挂号方式
- 后续提醒
### 5. 固定给出挂号提示
如果用户准备自行挂号,直接给出下面这段提示:
微信内挂号更方便:
- 方式 1:搜索“北京114预约挂号”公众号
- 方式 2:搜索“京通”小程序 → 健康服务 → 预约挂号114
电话方式:
- 方式 1:拨打 010-114 挂号
### 6. 挂号后提醒
如果用户上传挂号截图或粘贴挂号文本:
1. 先从内容中提取医院、科室、医生、就诊日期时间。
2. 若时间明确,运行:
```bash
python3 {baseDir}/scripts/appointment_reminders.py --appointment "2026-03-20 14:30"
```
脚本默认返回 3 个提醒时间:
- T-12h
- T-6h
- T-2h
如果当前运行环境支持提醒/闹钟工具,在得到用户确认后创建 3 次提醒;如果不支持,就把提醒时间明确列给用户,并提示他设置手机闹钟。
同时提醒用户:挂完号后可以把挂号信息截图或文本复制上来,我会继续帮他分析就医信息、安排提醒和路线规划。
### 7. 就医路线规划
当用户已经确定挂号医院,需要到院路线时:
#### 7.1 先拿到起终点坐标
- 先通用户的IP判断出用户的位置
- 如果IP没有判断出来, 则提示用户, 让用户自己提交输入位置
#### 7.2 再做路线规划并生成路径跳转链接
- 将用户输出的位置和医院的位置转换成地图的坐标
#### 7.3 再做路线规划并生成路径跳转链接
- 根据用户的位置坐标,和医院的位置坐标,用amap-jsapi-skill技能生成推荐路线, 并给出线路链接, 点击能跳到web, 查看线路。
如果缺少该环境变量,仍可完成:
- 症状分诊
- 医院/科室/医生推荐
- 挂号提示
- 提醒时间计算
## 结果质量要求
- 不要把推荐写成确诊结论。
- 对数据不足、字段缺失、医生简介为空要明确说明。
- 如果脚本结果与用户症状明显冲突,以安全优先:先提醒急症风险,再给保守建议。
- Top 3 推荐必须尽量具体到“医院 + 科室 + 医生”。
- 当数据质量不足时,要明确提示用户优先咨询医院导诊台或综合科。
FILE:scripts/appointment_reminders.py
#!/usr/bin/env python3
import argparse
import json
import re
from datetime import datetime, timedelta
FORMATS = [
"%Y-%m-%d %H:%M",
"%Y/%m/%d %H:%M",
"%Y.%m.%d %H:%M",
"%Y年%m月%d日 %H:%M",
"%Y年%m月%d日%H:%M",
"%Y-%m-%dT%H:%M",
]
def normalize(text: str) -> str:
text = text.strip()
text = text.replace("上午", " ").replace("下午", " ")
text = re.sub(r"\s+", " ", text)
return text
def parse_dt(text: str) -> datetime:
text = normalize(text)
for fmt in FORMATS:
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
raise ValueError("Unsupported datetime format. Use e.g. 2026-03-20 14:30")
def main() -> int:
p = argparse.ArgumentParser(description="Generate 3 reminder times before a medical appointment")
p.add_argument("--appointment", required=True, help="Appointment datetime, e.g. 2026-03-20 14:30")
args = p.parse_args()
dt = parse_dt(args.appointment)
reminders = [
("T-12h", dt - timedelta(hours=12)),
("T-6h", dt - timedelta(hours=6)),
("T-2h", dt - timedelta(hours=2)),
]
output = {
"appointment": dt.strftime("%Y-%m-%d %H:%M"),
"reminders": [
{"label": label, "time": when.strftime("%Y-%m-%d %H:%M")} for label, when in reminders
],
"note": "默认输出提前半天内的3次提醒:12小时、6小时、2小时。",
}
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/baidu_geocode.py
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from typing import Any, Dict
from urllib.parse import urlencode
from urllib.request import urlopen
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Baidu geocoding helper for OpenClaw skills")
p.add_argument("--address", required=True, help="Address or hospital name")
p.add_argument("--city", default="北京", help="City or region hint, default: 北京")
p.add_argument("--timeout", type=int, default=10)
return p.parse_args()
def geocode(address: str, city: str, ak: str, timeout: int) -> Dict[str, Any]:
url = "https://api.map.baidu.com/geocoding/v3/"
params = {
"address": address,
"city": city,
"output": "json",
"ret_coordtype": "gcj02ll",
"ak": ak,
}
url = url + "?" + urlencode(params)
with urlopen(url, timeout=timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
if data.get("status") != 0:
raise RuntimeError(f"Baidu geocoding failed: status={data.get('status')}, msg={data.get('msg') or data.get('message')}")
result = data.get("result", {})
location = result.get("location", {})
return {
"query": address,
"city": city,
"lat": location.get("lat"),
"lng": location.get("lng"),
"precise": result.get("precise"),
"confidence": result.get("confidence"),
"comprehension": result.get("comprehension"),
"level": result.get("level"),
}
def main() -> int:
args = parse_args()
ak = os.getenv("BAIDU_MAP_AK")
if not ak:
print(json.dumps({"error": "Missing environment variable BAIDU_MAP_AK"}, ensure_ascii=False))
return 2
try:
print(json.dumps(geocode(args.address, args.city, ak, args.timeout), ensure_ascii=False, indent=2))
return 0
except Exception as exc:
print(json.dumps({"error": str(exc)}, ensure_ascii=False))
return 1
if __name__ == "__main__":
sys.exit(main())
FILE:scripts/baidu_route_link.py
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from typing import Any, Dict, Optional
from urllib.parse import urlencode
from urllib.request import urlopen
class BaiduRoutePlanner:
def __init__(self, ak: str, src: str = "openclaw.skill.baidu-route-link", timeout: int = 10):
self.ak = ak
self.src = src
self.timeout = timeout
def plan_route(
self,
mode: str,
origin_lat: float,
origin_lng: float,
dest_lat: float,
dest_lng: float,
coord_type: str = "gcj02",
tactics: int = 0,
alternatives: int = 1,
) -> Dict[str, Any]:
endpoint_map = {
"driving": "https://api.map.baidu.com/directionlite/v1/driving",
"walking": "https://api.map.baidu.com/directionlite/v1/walking",
"riding": "https://api.map.baidu.com/directionlite/v1/riding",
}
if mode not in endpoint_map:
raise ValueError(f"Unsupported mode: {mode}")
params: Dict[str, Any] = {
"origin": f"{origin_lat},{origin_lng}",
"destination": f"{dest_lat},{dest_lng}",
"coord_type": coord_type,
"ak": self.ak,
}
if mode == "driving":
params["tactics"] = tactics
params["alternatives"] = alternatives
url = endpoint_map[mode] + "?" + urlencode(params)
with urlopen(url, timeout=self.timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
if data.get("status") != 0:
raise RuntimeError(
f"Baidu route API failed: status={data.get('status')}, message={data.get('message')}"
)
result = data.get("result", {})
routes = result.get("routes", [])
if not routes:
raise RuntimeError("Baidu route API returned no routes")
route = routes[0]
return {
"raw": data,
"distance_m": route.get("distance"),
"duration_s": route.get("duration"),
"routes_count": len(routes),
}
def build_baidu_link(
self,
origin_lat: float,
origin_lng: float,
dest_lat: float,
dest_lng: float,
origin_name: Optional[str] = None,
dest_name: Optional[str] = None,
mode: str = "driving",
region: Optional[str] = None,
coord_type: str = "gcj02",
) -> str:
if origin_name:
origin = f"name:{origin_name}|latlng:{origin_lat},{origin_lng}"
else:
origin = f"{origin_lat},{origin_lng}"
if dest_name:
destination = f"name:{dest_name}|latlng:{dest_lat},{dest_lng}"
else:
destination = f"{dest_lat},{dest_lng}"
params: Dict[str, str] = {
"origin": origin,
"destination": destination,
"mode": mode,
"coord_type": coord_type,
"output": "html",
"src": self.src,
}
if region:
params["region"] = region
return "http://api.map.baidu.com/direction?" + urlencode(params)
def format_distance(distance_m: Optional[int]) -> str:
if distance_m is None:
return "未知"
if distance_m >= 1000:
return f"{distance_m / 1000:.1f} km"
return f"{distance_m} m"
def format_duration(duration_s: Optional[int]) -> str:
if duration_s is None:
return "未知"
hours = duration_s // 3600
minutes = (duration_s % 3600) // 60
if hours > 0:
return f"{hours}小时{minutes}分钟"
return f"{minutes}分钟"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Baidu route planner + map link generator for OpenClaw skill")
parser.add_argument("--origin-lat", type=float, required=True)
parser.add_argument("--origin-lng", type=float, required=True)
parser.add_argument("--dest-lat", type=float, required=True)
parser.add_argument("--dest-lng", type=float, required=True)
parser.add_argument("--origin-name", type=str, default=None)
parser.add_argument("--dest-name", type=str, default=None)
parser.add_argument("--mode", type=str, default="driving", choices=["driving", "walking", "riding"])
parser.add_argument("--coord-type", type=str, default="gcj02")
parser.add_argument("--region", type=str, default=None)
parser.add_argument("--tactics", type=int, default=0)
parser.add_argument("--alternatives", type=int, default=1)
parser.add_argument("--src", type=str, default="openclaw.skill.baidu-route-link")
parser.add_argument("--timeout", type=int, default=10)
return parser.parse_args()
def main() -> int:
args = parse_args()
ak = os.getenv("BAIDU_MAP_AK", "wK1w1xlWg2Mg6SNLYyLMPl4NuYl9JIf8")
if not ak:
print(json.dumps({"error": "Missing environment variable BAIDU_MAP_AK"}, ensure_ascii=False))
return 2
try:
planner = BaiduRoutePlanner(ak=ak, src=args.src, timeout=args.timeout)
route_info = planner.plan_route(
mode=args.mode,
origin_lat=args.origin_lat,
origin_lng=args.origin_lng,
dest_lat=args.dest_lat,
dest_lng=args.dest_lng,
coord_type=args.coord_type,
tactics=args.tactics,
alternatives=args.alternatives,
)
baidu_link = planner.build_baidu_link(
origin_lat=args.origin_lat,
origin_lng=args.origin_lng,
dest_lat=args.dest_lat,
dest_lng=args.dest_lng,
origin_name=args.origin_name,
dest_name=args.dest_name,
mode=args.mode,
region=args.region,
coord_type=args.coord_type,
)
output = {
"mode": args.mode,
"distance_m": route_info["distance_m"],
"distance_text": format_distance(route_info["distance_m"]),
"duration_s": route_info["duration_s"],
"duration_text": format_duration(route_info["duration_s"]),
"routes_count": route_info["routes_count"],
"baidu_link": baidu_link,
}
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0
except Exception as exc:
print(json.dumps({"error": str(exc)}, ensure_ascii=False))
return 1
if __name__ == "__main__":
sys.exit(main())
FILE:scripts/triage_and_match.py
#!/usr/bin/env python3
import argparse
import csv
import json
import math
import os
import re
from collections import defaultdict
from typing import Dict, List, Tuple
DEPARTMENT_RULES = [
{
"department": "呼吸与危重症医学科",
"aliases": ["呼吸科", "呼吸内科", "肺病科"],
"keywords": ["咳嗽", "咳痰", "发热", "发烧", "胸闷", "气短", "气喘", "呼吸困难", "哮喘", "肺炎", "肺结节", "鼻塞", "流感", "新冠", "支气管", "胸痛"],
},
{
"department": "心血管科",
"aliases": ["心内科", "心血管内科", "心脏科", "心脏中心"],
"keywords": ["胸痛", "胸闷", "心慌", "心悸", "高血压", "血压高", "冠心病", "心绞痛", "气短", "头晕", "水肿"],
},
{
"department": "神经科",
"aliases": ["神经内科", "神经外科", "神经外一科"],
"keywords": ["头痛", "眩晕", "头晕", "肢体麻木", "偏瘫", "抽搐", "癫痫", "面瘫", "失眠", "记忆力", "意识", "中风", "脑梗", "脑出血"],
},
{
"department": "消化科",
"aliases": ["消化内科", "内镜中心"],
"keywords": ["腹痛", "胃痛", "反酸", "烧心", "呕吐", "恶心", "腹泻", "便秘", "黑便", "便血", "胃胀", "肝", "胆", "胰", "消化不良"],
},
{
"department": "内分泌科",
"aliases": ["糖尿病科"],
"keywords": ["血糖", "糖尿病", "甲状腺", "肥胖", "消瘦", "多饮", "多尿", "内分泌", "痛风", "尿酸"],
},
{
"department": "妇产科",
"aliases": ["妇科", "中医妇科", "妇产科(含国际部妇产科病区"],
"keywords": ["月经", "经期", "阴道", "白带", "宫颈", "子宫", "卵巢", "乳房", "怀孕", "妊娠", "备孕", "产检", "妇科"],
},
{
"department": "泌尿外科",
"aliases": ["泌尿科"],
"keywords": ["尿频", "尿急", "尿痛", "血尿", "前列腺", "肾结石", "排尿", "泌尿", "腰痛"],
},
{
"department": "皮肤科",
"aliases": [],
"keywords": ["皮疹", "瘙痒", "湿疹", "荨麻疹", "痘", "痤疮", "脱发", "皮肤", "红斑", "过敏"],
},
{
"department": "眼科",
"aliases": ["眼科特需门诊"],
"keywords": ["眼痛", "视力", "视物模糊", "红眼", "流泪", "眼干", "飞蚊", "白内障", "青光眼"],
},
{
"department": "口腔医学中心",
"aliases": ["口腔科", "牙科"],
"keywords": ["牙痛", "牙龈", "口腔", "智齿", "牙周", "口臭", "龋齿"],
},
{
"department": "骨科",
"aliases": ["骨科·关节外科", "骨科·脊柱外科", "脊柱二科"],
"keywords": ["膝盖", "关节", "骨折", "扭伤", "腰椎", "脊柱", "颈椎", "肩痛", "骨痛", "运动损伤"],
},
{
"department": "风湿免疫科",
"aliases": ["风湿病科", "中医风湿病科"],
"keywords": ["关节痛", "晨僵", "红斑狼疮", "风湿", "类风湿", "免疫", "干燥综合征"],
},
{
"department": "心理科",
"aliases": ["心身医学科"],
"keywords": ["焦虑", "抑郁", "失眠", "情绪", "惊恐", "心理", "压力", "睡不着"],
},
{
"department": "感染疾病科",
"aliases": [],
"keywords": ["乙肝", "丙肝", "感染", "发热", "传染", "艾滋", "结核"],
},
{
"department": "肿瘤科",
"aliases": ["中西医结合肿瘤内科", "放射肿瘤科"],
"keywords": ["肿瘤", "癌", "结节", "化疗", "放疗", "靶向", "肿块"],
},
{
"department": "康复医学科",
"aliases": [],
"keywords": ["康复", "术后恢复", "偏瘫康复", "理疗", "功能训练"],
},
{
"department": "老年病科",
"aliases": [],
"keywords": ["老年", "多病共存", "高龄", "慢病管理"],
},
{
"department": "综合科",
"aliases": ["普通内科", "全科"],
"keywords": ["不确定挂什么科", "多种症状", "体检异常", "慢病复诊"],
},
]
EMERGENCY_KEYWORDS = [
"持续胸痛", "胸痛大汗", "呼吸困难", "意识不清", "昏迷", "抽搐不止", "大出血", "便血很多", "黑便明显",
"呕血", "偏瘫", "口角歪斜", "说话不清", "突发剧烈头痛", "高热惊厥", "严重过敏", "休克",
]
NON_CLINICAL_KEYWORDS = [
"实验室", "药理", "病理", "放射", "职能", "共同制定", "自成立之日", "积极推行", "设有门诊", "临床药理室", "检验科", "输血科",
]
def normalize_text(value: str) -> str:
if value is None:
return ""
value = str(value).strip()
if value.lower() == "nan":
return ""
return value
def tokenize(text: str) -> List[str]:
text = normalize_text(text)
cjk = re.findall(r"[\u4e00-\u9fff]{2,}", text)
latin = re.findall(r"[A-Za-z]{2,}", text)
return cjk + latin
def keyword_hits(text: str, keywords: List[str]) -> Tuple[int, List[str]]:
hits = []
for kw in keywords:
if kw and kw in text:
hits.append(kw)
return len(hits), hits
def infer_departments(symptom_text: str) -> Tuple[List[Dict], bool, List[str]]:
emergency_count, emergency_hits = keyword_hits(symptom_text, EMERGENCY_KEYWORDS)
emergency = emergency_count > 0
scores = []
for rule in DEPARTMENT_RULES:
count, hits = keyword_hits(symptom_text, rule["keywords"])
if count > 0:
scores.append(
{
"department": rule["department"],
"aliases": rule["aliases"],
"score": count,
"hits": hits,
}
)
if not scores:
scores = [{"department": "综合科", "aliases": ["普通内科", "全科"], "score": 1, "hits": ["症状描述不足,建议先做综合分诊"]}]
scores.sort(key=lambda x: (-x["score"], x["department"]))
return scores, emergency, emergency_hits
def load_rows(csv_path: str) -> List[Dict[str, str]]:
rows = []
with open(csv_path, "r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
for row in reader:
rows.append({k: normalize_text(v) for k, v in row.items()})
return rows
def row_is_clinical(row: Dict[str, str]) -> bool:
dept = row.get("department_name", "")
doctor = row.get("doctor_name", "")
hospital = row.get("hospital_name", "")
if not dept or not doctor:
return False
if doctor == dept or doctor == hospital:
return False
if ("科" in doctor or "中心" in doctor) and len(doctor) >= 2 and len(doctor) <= 12:
return False
for kw in NON_CLINICAL_KEYWORDS:
if kw in dept:
return False
return True
def department_match_score(row: Dict[str, str], inferred: List[Dict]) -> Tuple[float, str]:
dept_name = row.get("department_name", "")
doctor_dept = row.get("doctor_department", "")
best = 0.0
reason = ""
for cand in inferred:
targets = [cand["department"]] + cand.get("aliases", [])
for t in targets:
if t and (t in dept_name or t in doctor_dept or dept_name in t or doctor_dept in t):
score = 6.0 + cand["score"] * 1.5
if score > best:
best = score
reason = f"科室匹配:{cand['department']}"
# fuzzy fallback via shared chars
overlap = len(set(dept_name) & set(cand["department"]))
if overlap >= 2:
score = 2.0 + overlap * 0.5 + cand["score"]
if score > best:
best = score
reason = f"科室近似匹配:{cand['department']}"
return best, reason
def content_match_score(row: Dict[str, str], symptom_text: str, inferred: List[Dict]) -> Tuple[float, List[str]]:
bag = " ".join([
row.get("hospital_name", ""),
row.get("hospital_intro", ""),
row.get("department_name", ""),
row.get("department_intro", ""),
row.get("doctor_name", ""),
row.get("doctor_department", ""),
row.get("doctor_intro", ""),
])
score = 0.0
reasons: List[str] = []
symptom_tokens = set(tokenize(symptom_text))
bag_tokens = set(tokenize(bag))
overlap = symptom_tokens & bag_tokens
if overlap:
score += min(4.0, len(overlap) * 1.2)
reasons.append("症状关键词重合:" + "、".join(sorted(list(overlap))[:4]))
for cand in inferred[:3]:
hits = [kw for kw in cand["hits"] if kw in bag]
if hits:
add = min(3.5, len(hits) * 1.0)
score += add
reasons.append(f"擅长/简介命中:{'、'.join(hits[:4])}")
break
schedule = row.get("doctor_schedule", "")
if schedule:
score += 0.5
reasons.append("有坐诊时间信息")
return score, reasons
def aggregate_rows(rows: List[Dict[str, str]], inferred: List[Dict], symptom_text: str, top_k: int) -> List[Dict]:
scored = []
for row in rows:
if not row_is_clinical(row):
continue
d_score, d_reason = department_match_score(row, inferred)
c_score, c_reasons = content_match_score(row, symptom_text, inferred)
total = d_score + c_score
if total <= 0:
continue
reasons = []
if d_reason:
reasons.append(d_reason)
reasons.extend(c_reasons)
scored.append(
{
"hospital_name": row.get("hospital_name", ""),
"hospital_intro": row.get("hospital_intro", ""),
"department_name": row.get("department_name", ""),
"department_intro": row.get("department_intro", ""),
"doctor_name": row.get("doctor_name", ""),
"doctor_department": row.get("doctor_department", ""),
"doctor_intro": row.get("doctor_intro", ""),
"doctor_schedule": row.get("doctor_schedule", ""),
"score": round(total, 2),
"reasons": reasons[:4],
}
)
# unique by hospital+department+doctor, keep best score
best_by_key: Dict[Tuple[str, str, str], Dict] = {}
for item in scored:
key = (item["hospital_name"], item["department_name"], item["doctor_name"])
old = best_by_key.get(key)
if old is None or item["score"] > old["score"]:
best_by_key[key] = item
deduped = list(best_by_key.values())
deduped.sort(key=lambda x: (-x["score"], x["hospital_name"], x["department_name"], x["doctor_name"]))
return deduped[:top_k]
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Infer likely departments from symptoms and match top hospitals/doctors from a CSV dataset.")
p.add_argument("--csv", required=True, help="Path to hospital_extracted_final.csv")
p.add_argument("--symptoms", required=True, help="User symptom narrative")
p.add_argument("--history", default="", help="Past history / chronic conditions / meds")
p.add_argument("--age", default="", help="Age, optional")
p.add_argument("--gender", default="", help="Gender, optional")
p.add_argument("--top-k", type=int, default=3, help="How many matches to return")
return p
def main() -> int:
args = build_parser().parse_args()
if not os.path.exists(args.csv):
print(json.dumps({"error": f"CSV not found: {args.csv}"}, ensure_ascii=False))
return 2
symptom_text = ";".join([x for x in [args.symptoms, args.history, args.age, args.gender] if x])
inferred, emergency, emergency_hits = infer_departments(symptom_text)
rows = load_rows(args.csv)
matches = aggregate_rows(rows, inferred, symptom_text, args.top_k)
output = {
"input": {
"symptoms": args.symptoms,
"history": args.history,
"age": args.age,
"gender": args.gender,
},
"emergency_flag": emergency,
"emergency_hits": emergency_hits,
"department_candidates": inferred[:5],
"top_matches": matches,
"notes": [
"结果仅用于挂号分诊辅助,不替代医生诊断。",
"若出现持续胸痛、明显呼吸困难、意识改变、偏瘫、抽搐不止、大出血等情况,应优先急诊。",
"当前CSV中存在部分科研/实验室类行,脚本已尽量过滤,但仍建议人工复核。",
],
}
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:references/triage_rules.md
# 医疗分诊与推荐参考
## 1. 先做安全分流
出现下列任一情况,优先建议急诊/120,而不是普通门诊挂号:
- 持续胸痛、胸痛伴大汗或明显呼吸困难
- 突发偏瘫、口角歪斜、说话不清、意识障碍
- 抽搐不止、昏迷
- 大出血、呕血、黑便或便血量大
- 严重过敏反应、休克迹象
## 2. 常见症状到科室的粗分诊
- 咳嗽、咳痰、胸闷、气短、哮喘、肺炎倾向 -> 呼吸与危重症医学科 / 呼吸科
- 胸痛、心慌、心悸、高血压、水肿 -> 心血管科 / 心内科
- 头痛、眩晕、肢体麻木、偏瘫、抽搐 -> 神经科 / 神经内科
- 腹痛、反酸、恶心、腹泻、便秘、黑便 -> 消化科 / 消化内科
- 血糖异常、甲状腺、肥胖、痛风 -> 内分泌科
- 月经异常、白带异常、怀孕、备孕 -> 妇科 / 妇产科 / 中医妇科
- 尿频尿急尿痛、血尿、前列腺、肾结石 -> 泌尿外科 / 泌尿科
- 皮疹、湿疹、瘙痒、荨麻疹、痤疮 -> 皮肤科
- 视力下降、眼痛、红眼 -> 眼科
- 牙痛、牙龈肿痛、口腔溃疡 -> 口腔科 / 口腔医学中心
- 关节痛、腰腿痛、扭伤、骨折、脊柱问题 -> 骨科
- 风湿、类风湿、免疫系统相关症状 -> 风湿免疫科 / 风湿病科
- 焦虑、抑郁、惊恐、长期失眠 -> 心理科 / 心身医学科
## 3. 推荐结果的呈现原则
推荐医院/科室/医生时,优先看:
1. 科室是否与症状最贴近
2. 医生简介或科室简介是否命中症状关键词
3. 是否存在坐诊时间信息
4. 当数据不足时,明确告知“不确定”,建议先走综合科或线下导诊台
## 4. 数据文件已知限制
`hospital_extracted_final.csv` 中存在少量科研/实验室/介绍性条目,不适合作为门诊挂号推荐对象。
脚本已做基础过滤,但最终答复仍应人工复核。
当用户需要根据主诉、病史、地区和就诊偏好,推荐最合适的医院与医生,并在可行时生成模拟挂号与陪诊安排时使用。
---
name: medical-doctor-matcher
slug: medical-doctor-matcher
version: 0.1.0
description: 当用户需要根据主诉、病史、地区和就诊偏好,推荐最合适的医院与医生,并在可行时生成模拟挂号与陪诊安排时使用。
metadata:
openclaw:
skillKey: medical-doctor-matcher
category: healthcare
tags:
- healthcare
- doctor-matching
- hospital-recommendation
- appointment
- escort
---
# 医疗就诊匹配助手
## Overview
把用户的主诉、病史、年龄、地区、就诊偏好转成结构化画像,完成科室判断、医院筛选、医生匹配、Top 3 推荐,并在有需要时生成模拟挂号和陪诊安排。
## When to Use
在下面场景触发本技能:
1. 用户说不清该去哪个医院、哪个科、哪个医生。
2. 用户给出症状、病史、检查结果,希望获得就诊建议与医生推荐。
3. 用户希望按地区、医院等级、可挂号时间、是否需要陪诊做联合筛选。
4. 用户需要自动挂号或陪诊安排的模拟流程。
不要在下面场景单独依赖本技能:
1. 明显急危重症:胸痛持续加重、呼吸困难、意识障碍、大出血、抽搐、卒中征象。此时先建议急诊/120。
2. 用户要求明确诊断、处方、治疗方案。本技能只做就医分流与匹配,不替代医生诊疗。
3. 缺少最基本的主诉或地区信息且无法合理补全时。
## Quick Workflow
1. 先收集信息:主诉、持续时间、病史、过敏史、年龄、性别、地区、预算、是否要挂号、是否要陪诊。
2. 参考 `references/symptom_to_specialty.md` 完成科室初筛与风险识别。
3. 需要可执行演示时,使用 `scripts/recommend.py` 读取输入 JSON 与 `data/` 下模拟数据,输出 Top 3 推荐。
4. 输出必须包含:推荐理由、医院信息、医生信息、匹配分、风险提示、是否已生成模拟挂号/陪诊安排。
5. 若用户症状涉及急危重症,先给风险提示,再提供急诊方向,不做普通门诊优先推荐。
## Core Rules
1. 始终先做风险分层,再做医院与医生匹配。
2. 不把“推荐医院/医生”表达成“已经完成诊断”。
3. 推荐结果至少给 3 个选项,且说明为什么排在前面。
4. 当地区信息不足时,优先询问或使用用户提供的城市/区域;没有时只能给出“示例推荐”。
5. 自动挂号与陪诊在本技能中默认是模拟流程,除非你已经接入真实挂号/陪诊 API。
6. 输出时优先使用结构化格式,参考 `schemas/response_schema.json`。
7. 需要更详细的匹配规则时,按需读取 `references/` 下文档,不要把全部规则一次性塞进回答。
## Bundled Files
| 文件 | 用途 |
|---|---|
| `references/workflow.md` | 完整工作流与交互步骤 |
| `references/symptom_to_specialty.md` | 症状到科室映射与风险规则 |
| `references/output_template.md` | 建议输出模板 |
| `schemas/request_schema.json` | 输入结构 |
| `schemas/response_schema.json` | 输出结构 |
| `data/*.json` | 模拟医院、医生、号源、陪诊数据 |
| `scripts/recommend.py` | 基于模拟数据生成推荐、挂号、陪诊结果 |
## Recommended Output Sections
1. 用户病情摘要
2. 风险等级与建议
3. 推荐科室
4. Top 3 医院医生推荐
5. 模拟挂号结果(如需要)
6. 模拟陪诊安排(如需要)
7. 注意事项
## Example Trigger
- “我发烧咳嗽三天,在朝阳区,应该挂哪个医院哪个医生?”
- “妈妈膝盖痛半年了,想在海淀区找骨科专家,并且最好这两天能挂上号。”
- “小孩反复发热,帮我找儿科医院和医生,顺便安排陪诊。”
FILE:LICENSE.txt
MIT License
FILE:README.md
# medical-doctor-matcher
一个按 OpenClaw / AgentSkills 风格实现的自定义技能包,用于 **C 端医疗就诊分流与医院医生匹配**。
## 能力范围
- 根据用户主诉、病史、时长、年龄等信息做初步病情分析
- 结合地区、医院等级、科室、医生擅长方向做匹配
- 推荐匹配分最高的 Top 3 医院/医生
- 生成模拟挂号结果
- 生成模拟陪诊安排
## 目录结构
- `SKILL.md`:技能主说明
- `references/`:匹配规则、流程、输出模板
- `schemas/`:输入输出 JSON 结构
- `data/`:模拟医院、医生、号源、陪诊数据
- `scripts/recommend.py`:演示脚本
## 本地运行示例
```bash
python scripts/recommend.py --input examples/sample_request.json --output examples/sample_response.generated.json
```
## 安装方式
把整个目录放到:
- `<workspace>/skills/medical-doctor-matcher/`
或
- `~/.openclaw/skills/medical-doctor-matcher/`
然后重启 / 刷新 OpenClaw skills。
## 说明
当前版本的“挂号”和“陪诊”使用模拟数据,不连接真实平台。
FILE:scripts/recommend.py
\
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import json
import math
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DATA_DIR = ROOT / "data"
SPECIALTY_RULES = {
"呼吸内科": ["发热", "咳嗽", "咳痰", "气短", "胸闷", "哮喘", "呼吸"],
"感染科": ["发热", "感染", "病毒", "咽痛"],
"心内科": ["胸痛", "胸闷", "心悸", "心前区"],
"骨科": ["膝痛", "膝关节疼痛", "关节", "扭伤", "骨折", "腰痛"],
"运动医学科": ["运动损伤", "膝痛", "半月板", "扭伤"],
"神经内科": ["头痛", "眩晕", "麻木", "无力"],
"皮肤科": ["皮疹", "瘙痒", "过敏", "脱屑"],
"消化内科": ["腹痛", "腹泻", "反酸", "恶心"],
"儿科": ["儿童发热", "儿童咳嗽", "小儿", "宝宝", "孩子", "儿童"],
"全科医学科": ["初诊", "不确定", "发热", "咳嗽", "腹痛", "皮疹"]
}
EMERGENCY_KEYWORDS = ["呼吸困难", "持续胸痛", "意识障碍", "大出血", "抽搐", "单侧无力", "口角歪斜", "言语不清"]
def load_json(name):
with open(DATA_DIR / name, "r", encoding="utf-8") as f:
return json.load(f)
def normalize_text(parts):
return " ".join([str(p) for p in parts if p]).strip()
def detect_risk(text):
for kw in EMERGENCY_KEYWORDS:
if kw in text:
return "高", f"命中急危重症关键词:{kw},建议优先急诊或拨打 120。"
if any(kw in text for kw in ["高热", "持续高热", "症状加重"]):
return "中", "存在需要尽快线下就诊的信号,建议优先近期号源。"
return "低", "暂未命中急危重症关键词,可做普通门诊匹配。"
def infer_specialties(text, age=None):
scores = {}
is_child = age is not None and age < 14
for dept, kws in SPECIALTY_RULES.items():
score = sum(1 for kw in kws if kw in text)
if score > 0:
scores[dept] = score
if is_child:
scores["儿科"] = scores.get("儿科", 0) + 3
if not scores:
scores["全科医学科"] = 1
ordered = sorted(scores.items(), key=lambda x: (-x[1], x[0]))
return [d for d, _ in ordered[:3]]
def title_score(title):
if "主任" in title:
return 1.0
if "副主任" in title:
return 0.9
if "主治" in title:
return 0.75
return 0.6
def level_score(level):
if "三甲" in level:
return 1.0
if "二甲" in level:
return 0.8
return 0.6
def region_score(user_region, hospital_region):
if not user_region:
return 0.5
return 1.0 if user_region == hospital_region else 0.6
def find_next_slot(doctor_id, slots):
candidates = [s for s in slots if s["doctor_id"] == doctor_id and s.get("available")]
if not candidates:
return None
candidates = sorted(candidates, key=lambda x: (x["date"], x["time"]))
return candidates[0]
def specialty_match_score(target_specialties, doctor_specialties, doctor_dept, text):
score = 0.0
if doctor_dept in target_specialties:
score += 0.6
for kw in doctor_specialties:
if kw in text:
score += 0.08
return min(score, 1.0)
def fee_preference_score(user_budget, doctor_fee):
if not user_budget:
return 0.7
mapping = {"低": 1, "中": 2, "高": 3}
u = mapping.get(user_budget, 2)
d = mapping.get(doctor_fee, 2)
diff = abs(u - d)
return {0: 1.0, 1: 0.75, 2: 0.5}.get(diff, 0.5)
def build_recommendations(req):
hospitals = load_json("mock_hospitals.json")
doctors = load_json("mock_doctors.json")
slots = load_json("mock_slots.json")
user = req.get("user_profile", {})
text = normalize_text([
req.get("chief_complaint"),
req.get("history"),
" ".join(req.get("symptoms", [])),
])
region = req.get("current_region")
risk_level, risk_note = detect_risk(text)
target_specialties = infer_specialties(text, user.get("age"))
hospital_map = {h["hospital_id"]: h for h in hospitals}
recs = []
for doctor in doctors:
hospital = hospital_map[doctor["hospital_id"]]
if doctor["department"] not in hospital["departments"]:
continue
dept_hit = doctor["department"] in target_specialties
specialty_score = specialty_match_score(target_specialties, doctor["specialties"], doctor["department"], text)
if specialty_score <= 0 and not dept_hit:
continue
next_slot = find_next_slot(doctor["doctor_id"], slots)
availability_score = 1.0 if next_slot else 0.3
score = (
specialty_score * 40
+ (1.0 if dept_hit else 0.6) * 20
+ title_score(doctor["title"]) * 10
+ region_score(region, hospital["region"]) * 10
+ (doctor["rating"] / 5.0) * 10
+ availability_score * 5
+ fee_preference_score(user.get("budget_level"), doctor["fee_level"]) * 5
)
score += level_score(hospital["level"]) * 5
score += (1.0 if hospital.get("supports_insurance") and user.get("insurance") else 0.5) * 5
reasons = []
if dept_hit:
reasons.append(f"科室与推断目标科室“{doctor['department']}”一致")
matched_kw = [kw for kw in doctor["specialties"] if kw in text]
if matched_kw:
reasons.append(f"医生擅长方向覆盖:{', '.join(matched_kw[:3])}")
if region == hospital["region"]:
reasons.append("医院与用户所在区域一致")
if next_slot:
reasons.append(f"近期可约:{next_slot['date']} {next_slot['time']}")
reasons.append(f"医院等级:{hospital['level']},医生评分:{doctor['rating']}")
recs.append({
"hospital_id": hospital["hospital_id"],
"hospital_name": hospital["name"],
"hospital_region": hospital["region"],
"doctor_id": doctor["doctor_id"],
"doctor_name": doctor["name"],
"department": doctor["department"],
"doctor_title": doctor["title"],
"match_score": round(score, 2),
"next_available_slot": f"{next_slot['date']} {next_slot['time']}" if next_slot else "暂无可用号源",
"reason": ";".join(reasons)
})
recs = sorted(recs, key=lambda x: (-x["match_score"], x["hospital_name"], x["doctor_name"]))
return risk_level, risk_note, target_specialties, recs[:3]
def simulate_registration(req, recommendations):
if not req.get("need_registration") or not recommendations:
return {"enabled": False, "message": "未请求模拟挂号或无可推荐结果。"}
top = recommendations[0]
if "暂无可用号源" in top["next_available_slot"]:
return {"enabled": False, "message": "目标医生暂无可用号源。"}
return {
"enabled": True,
"booking_id": f"REG-{uuid.uuid4().hex[:8].upper()}",
"hospital_name": top["hospital_name"],
"doctor_name": top["doctor_name"],
"department": top["department"],
"appointment_time": top["next_available_slot"],
"note": "这是模拟挂号结果,未连接真实医院挂号系统。"
}
def simulate_escort(req, registration):
companions = load_json("mock_companions.json")
if not req.get("need_escort"):
return {"enabled": False, "message": "未请求陪诊。"}
region = req.get("current_region", "")
matches = [c for c in companions if region in c["regions"]]
if not matches:
return {"enabled": False, "message": "当前区域暂无模拟陪诊员。"}
matches = sorted(matches, key=lambda x: -x["rating"])
chosen = matches[0]
appt_time = registration.get("appointment_time", "待确认")
return {
"enabled": True,
"escort_order_id": f"ESC-{uuid.uuid4().hex[:8].upper()}",
"companion_name": chosen["name"],
"service_area": region,
"service_time": appt_time,
"service_tags": chosen["service_tags"],
"note": "这是模拟陪诊安排,未连接真实陪诊平台。"
}
def main():
parser = argparse.ArgumentParser(description="基于模拟数据的医院医生推荐脚本")
parser.add_argument("--input", required=True, help="输入 JSON 文件")
parser.add_argument("--output", required=False, help="输出 JSON 文件")
args = parser.parse_args()
with open(args.input, "r", encoding="utf-8") as f:
req = json.load(f)
risk_level, risk_note, target_specialties, recommendations = build_recommendations(req)
registration = simulate_registration(req, recommendations)
escort = simulate_escort(req, registration)
case_summary = f"主诉:{req.get('chief_complaint', '')};病史:{req.get('history', '')};地区:{req.get('current_region', '')}"
notes = [
"本结果用于就医分流与预约建议,不替代医生面诊。",
"若出现呼吸困难、持续胸痛、意识障碍等情况,应优先急诊。",
"挂号与陪诊为模拟流程。"
]
result = {
"case_summary": case_summary,
"risk_level": risk_level,
"risk_note": risk_note,
"recommended_specialties": target_specialties,
"top_recommendations": recommendations,
"registration": registration,
"escort": escort,
"notes": notes
}
output_text = json.dumps(result, ensure_ascii=False, indent=2)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(output_text)
else:
print(output_text)
if __name__ == "__main__":
main()
FILE:schemas/request_schema.json
{
"type": "object",
"required": [
"chief_complaint",
"current_region"
],
"properties": {
"user_profile": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"age": {
"type": "integer"
},
"gender": {
"type": "string"
},
"insurance": {
"type": "string"
},
"preferred_hospital_level": {
"type": "string"
},
"preferred_doctor_title": {
"type": "string"
},
"budget_level": {
"type": "string"
}
}
},
"chief_complaint": {
"type": "string"
},
"history": {
"type": "string"
},
"symptoms": {
"type": "array",
"items": {
"type": "string"
}
},
"duration_days": {
"type": "integer"
},
"current_region": {
"type": "string"
},
"need_registration": {
"type": "boolean"
},
"need_escort": {
"type": "boolean"
},
"preferred_time_window": {
"type": "string"
}
}
}
FILE:schemas/response_schema.json
{
"type": "object",
"properties": {
"case_summary": {
"type": "string"
},
"risk_level": {
"type": "string"
},
"recommended_specialties": {
"type": "array",
"items": {
"type": "string"
}
},
"top_recommendations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"hospital_name": {
"type": "string"
},
"doctor_name": {
"type": "string"
},
"department": {
"type": "string"
},
"match_score": {
"type": "number"
},
"reason": {
"type": "string"
},
"next_available_slot": {
"type": "string"
}
}
}
},
"registration": {
"type": "object"
},
"escort": {
"type": "object"
},
"notes": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
FILE:references/output_template.md
# 输出模板
## 1. 用户病情摘要
- 主诉:
- 病史:
- 年龄 / 性别:
- 地区:
- 症状时长:
## 2. 风险等级
- 风险等级:低 / 中 / 高
- 风险提示:
- 是否建议急诊:
## 3. 推荐科室
- 首选科室:
- 备选科室:
## 4. Top 3 医院医生推荐
### 推荐 1
- 医院:
- 医生:
- 科室:
- 匹配分:
- 推荐理由:
- 最近可预约时间:
### 推荐 2
...
### 推荐 3
...
## 5. 模拟挂号
- 是否已生成:
- 订单号:
- 就诊时间:
- 院区:
## 6. 模拟陪诊
- 是否已安排:
- 陪诊员:
- 服务时间:
- 服务内容:
## 7. 注意事项
- 本结果用于就医分流与预约建议,不替代医生面诊。
FILE:references/symptom_to_specialty.md
# 症状到科室映射
## 呼吸系统
- 发热、咳嗽、咳痰、胸闷、气短 -> 呼吸内科
- 发热 + 咽痛 + 上呼吸道症状 -> 感染科 / 耳鼻喉科 / 全科
- 明显呼吸困难、血氧下降 -> 急诊
## 心血管
- 胸痛、心悸、胸闷 -> 心内科
- 持续胸痛、大汗、濒死感 -> 急诊 / 胸痛中心
## 骨科 / 运动医学
- 膝痛、关节肿胀、扭伤、腰痛 -> 骨科 / 运动医学 / 康复科
## 神经
- 头痛、头晕、肢体麻木 -> 神经内科
- 言语不清、单侧无力、突发意识异常 -> 急诊 / 卒中中心
## 皮肤
- 皮疹、瘙痒、脱屑、过敏 -> 皮肤科
## 消化
- 腹痛、腹泻、反酸、恶心 -> 消化内科
## 妇儿
- 儿童发热、咳嗽、腹泻 -> 儿科
- 妇科疼痛、异常出血 -> 妇科
## 风险规则
出现以下任一情况,优先提升风险等级:
- 症状突然加重
- 持续高热不退
- 伴意识障碍
- 伴呼吸困难
- 伴胸痛 / 大出血
FILE:references/workflow.md
# 工作流
## 1. 信息采集
至少收集以下信息:
- 主诉
- 症状持续时间
- 既往病史
- 年龄 / 性别
- 当前城市 / 区域
- 是否需要挂号
- 是否需要陪诊
- 就诊偏好(医院等级、预算、是否要三甲、是否要主任医师)
## 2. 风险分层
优先识别急危重症关键词:
- 呼吸困难
- 持续胸痛
- 意识障碍
- 大出血
- 抽搐
- 单侧肢体无力 / 口角歪斜 / 言语不清
命中时:
- 输出风险等级 = 高
- 建议优先急诊或 120
- 普通门诊推荐降级为补充信息
## 3. 科室判断
参考 `symptom_to_specialty.md`:
- 发热、咳嗽、胸闷 -> 呼吸内科 / 感染科 / 全科
- 膝关节疼痛 -> 骨科 / 运动医学
- 皮疹、瘙痒 -> 皮肤科
- 儿童发热 -> 儿科
- 心悸、胸痛 -> 心内科(重症则急诊)
## 4. 医院筛选
优先考虑:
1. 用户地区匹配
2. 医院是否包含目标科室
3. 医院等级
4. 是否支持医保 / 夜间门诊 / 周末门诊
5. 是否有近期号源
## 5. 医生筛选
医生评分建议:
- 症状 / 疾病方向匹配:40%
- 科室匹配:20%
- 职称 / 资历:10%
- 用户地区与医院便利性:10%
- 医生评分:10%
- 近期可预约性:5%
- 医保 / 价格 / 偏好匹配:5%
## 6. 输出
输出 Top 3 推荐,字段至少包括:
- 医院名称
- 医生名称
- 科室
- 匹配分
- 推荐理由
- 可预约时间
- 是否建议陪诊
## 7. 模拟挂号 / 陪诊
如用户确认需要:
- 从 `data/mock_slots.json` 选择最近可用号源
- 从 `data/mock_companions.json` 选择区域相符陪诊员
- 生成模拟订单号
FILE:examples/sample_request.json
{
"user_profile": {
"name": "王女士",
"age": 32,
"gender": "女",
"insurance": "医保",
"preferred_hospital_level": "三甲",
"preferred_doctor_title": "副主任医师及以上",
"budget_level": "中"
},
"chief_complaint": "发热三天,咳嗽,胸闷",
"history": "既往有轻度哮喘史,无药物过敏史",
"symptoms": [
"发热",
"咳嗽",
"胸闷"
],
"duration_days": 3,
"current_region": "朝阳区",
"need_registration": true,
"need_escort": true,
"preferred_time_window": "近两天"
}
FILE:examples/sample_response.generated.json
{
"case_summary": "主诉:发热三天,咳嗽,胸闷;病史:既往有轻度哮喘史,无药物过敏史;地区:朝阳区",
"risk_level": "低",
"risk_note": "暂未命中急危重症关键词,可做普通门诊匹配。",
"recommended_specialties": [
"呼吸内科",
"全科医学科",
"心内科"
],
"top_recommendations": [
{
"hospital_id": "H002",
"hospital_name": "中日友好医院",
"hospital_region": "朝阳区",
"doctor_id": "D001",
"doctor_name": "张晨",
"department": "呼吸内科",
"doctor_title": "主任医师",
"match_score": 105.35,
"next_available_slot": "2026-03-17 09:00",
"reason": "科室与推断目标科室“呼吸内科”一致;医生擅长方向覆盖:发热, 咳嗽, 哮喘;医院与用户所在区域一致;近期可约:2026-03-17 09:00;医院等级:三甲,医生评分:4.9"
},
{
"hospital_id": "H001",
"hospital_name": "北京协和医院",
"hospital_region": "东城区",
"doctor_id": "D003",
"doctor_name": "王越",
"department": "呼吸内科",
"doctor_title": "主任医师",
"match_score": 94.95,
"next_available_slot": "2026-03-18 10:00",
"reason": "科室与推断目标科室“呼吸内科”一致;医生擅长方向覆盖:咳嗽, 胸闷;近期可约:2026-03-18 10:00;医院等级:三甲,医生评分:4.9"
},
{
"hospital_id": "H001",
"hospital_name": "北京协和医院",
"hospital_region": "东城区",
"doctor_id": "D011",
"doctor_name": "杨涛",
"department": "心内科",
"doctor_title": "副主任医师",
"match_score": 92.6,
"next_available_slot": "2026-03-18 13:00",
"reason": "科室与推断目标科室“心内科”一致;医生擅长方向覆盖:胸闷;近期可约:2026-03-18 13:00;医院等级:三甲,医生评分:4.7"
}
],
"registration": {
"enabled": true,
"booking_id": "REG-F7F0B28D",
"hospital_name": "中日友好医院",
"doctor_name": "张晨",
"department": "呼吸内科",
"appointment_time": "2026-03-17 09:00",
"note": "这是模拟挂号结果,未连接真实医院挂号系统。"
},
"escort": {
"enabled": true,
"escort_order_id": "ESC-14283A4E",
"companion_name": "王阿姨",
"service_area": "朝阳区",
"service_time": "2026-03-17 09:00",
"service_tags": [
"门诊陪诊",
"取药",
"代办"
],
"note": "这是模拟陪诊安排,未连接真实陪诊平台。"
},
"notes": [
"本结果用于就医分流与预约建议,不替代医生面诊。",
"若出现呼吸困难、持续胸痛、意识障碍等情况,应优先急诊。",
"挂号与陪诊为模拟流程。"
]
}
FILE:data/mock_companions.json
[
{
"companion_id": "E001",
"name": "王阿姨",
"city": "北京",
"regions": [
"朝阳区",
"东城区"
],
"service_tags": [
"门诊陪诊",
"取药",
"代办"
],
"rating": 4.9
},
{
"companion_id": "E002",
"name": "李老师",
"city": "北京",
"regions": [
"海淀区",
"西城区"
],
"service_tags": [
"门诊陪诊",
"检查陪同"
],
"rating": 4.8
},
{
"companion_id": "E003",
"name": "张护士",
"city": "北京",
"regions": [
"西城区",
"东城区"
],
"service_tags": [
"儿科陪诊",
"老人陪诊"
],
"rating": 4.9
}
]
FILE:data/mock_doctors.json
[
{
"doctor_id": "D001",
"name": "张晨",
"hospital_id": "H002",
"department": "呼吸内科",
"title": "主任医师",
"rating": 4.9,
"specialties": [
"发热",
"咳嗽",
"肺部感染",
"哮喘",
"胸闷"
],
"fee_level": "高"
},
{
"doctor_id": "D002",
"name": "李敏",
"hospital_id": "H002",
"department": "感染科",
"title": "副主任医师",
"rating": 4.8,
"specialties": [
"发热",
"感染",
"上呼吸道感染",
"病毒感染"
],
"fee_level": "中"
},
{
"doctor_id": "D003",
"name": "王越",
"hospital_id": "H001",
"department": "呼吸内科",
"title": "主任医师",
"rating": 4.9,
"specialties": [
"咳嗽",
"呼吸困难",
"间质性肺病",
"胸闷"
],
"fee_level": "高"
},
{
"doctor_id": "D004",
"name": "赵磊",
"hospital_id": "H003",
"department": "骨科",
"title": "主任医师",
"rating": 4.8,
"specialties": [
"膝关节疼痛",
"半月板损伤",
"骨关节炎"
],
"fee_level": "高"
},
{
"doctor_id": "D005",
"name": "陈雪",
"hospital_id": "H003",
"department": "运动医学科",
"title": "副主任医师",
"rating": 4.7,
"specialties": [
"膝痛",
"运动损伤",
"踝扭伤",
"康复"
],
"fee_level": "中"
},
{
"doctor_id": "D006",
"name": "周宁",
"hospital_id": "H004",
"department": "儿科",
"title": "主任医师",
"rating": 4.9,
"specialties": [
"儿童发热",
"儿童咳嗽",
"小儿感染"
],
"fee_level": "中"
},
{
"doctor_id": "D007",
"name": "高峰",
"hospital_id": "H005",
"department": "心内科",
"title": "主任医师",
"rating": 4.9,
"specialties": [
"胸痛",
"心悸",
"冠心病",
"高血压"
],
"fee_level": "高"
},
{
"doctor_id": "D008",
"name": "刘畅",
"hospital_id": "H006",
"department": "神经内科",
"title": "副主任医师",
"rating": 4.8,
"specialties": [
"头痛",
"眩晕",
"肢体麻木",
"脑血管病"
],
"fee_level": "中"
},
{
"doctor_id": "D009",
"name": "孙楠",
"hospital_id": "H008",
"department": "全科医学科",
"title": "主治医师",
"rating": 4.6,
"specialties": [
"发热",
"咳嗽",
"腹痛",
"皮疹",
"初诊分流"
],
"fee_level": "低"
},
{
"doctor_id": "D010",
"name": "何洁",
"hospital_id": "H008",
"department": "皮肤科",
"title": "主治医师",
"rating": 4.7,
"specialties": [
"皮疹",
"湿疹",
"过敏",
"瘙痒"
],
"fee_level": "低"
},
{
"doctor_id": "D011",
"name": "杨涛",
"hospital_id": "H001",
"department": "心内科",
"title": "副主任医师",
"rating": 4.7,
"specialties": [
"胸闷",
"心悸",
"心律失常"
],
"fee_level": "中"
},
{
"doctor_id": "D012",
"name": "杜娟",
"hospital_id": "H003",
"department": "呼吸内科",
"title": "主治医师",
"rating": 4.5,
"specialties": [
"咳嗽",
"哮喘",
"支气管炎"
],
"fee_level": "低"
}
]
FILE:data/mock_hospitals.json
[
{
"hospital_id": "H001",
"name": "北京协和医院",
"city": "北京",
"region": "东城区",
"level": "三甲",
"departments": [
"呼吸内科",
"感染科",
"心内科",
"消化内科"
],
"supports_insurance": true,
"weekend_clinic": true
},
{
"hospital_id": "H002",
"name": "中日友好医院",
"city": "北京",
"region": "朝阳区",
"level": "三甲",
"departments": [
"呼吸内科",
"感染科",
"全科医学科",
"皮肤科"
],
"supports_insurance": true,
"weekend_clinic": true
},
{
"hospital_id": "H003",
"name": "北京大学第三医院",
"city": "北京",
"region": "海淀区",
"level": "三甲",
"departments": [
"骨科",
"运动医学科",
"康复医学科",
"呼吸内科"
],
"supports_insurance": true,
"weekend_clinic": true
},
{
"hospital_id": "H004",
"name": "北京儿童医院",
"city": "北京",
"region": "西城区",
"level": "三甲",
"departments": [
"儿科",
"呼吸科",
"感染内科"
],
"supports_insurance": true,
"weekend_clinic": true
},
{
"hospital_id": "H005",
"name": "北京安贞医院",
"city": "北京",
"region": "朝阳区",
"level": "三甲",
"departments": [
"心内科",
"胸外科",
"急诊"
],
"supports_insurance": true,
"weekend_clinic": false
},
{
"hospital_id": "H006",
"name": "首都医科大学宣武医院",
"city": "北京",
"region": "西城区",
"level": "三甲",
"departments": [
"神经内科",
"神经外科",
"全科医学科"
],
"supports_insurance": true,
"weekend_clinic": false
},
{
"hospital_id": "H007",
"name": "北京积水潭医院",
"city": "北京",
"region": "西城区",
"level": "三甲",
"departments": [
"骨科",
"运动医学科",
"急诊"
],
"supports_insurance": true,
"weekend_clinic": true
},
{
"hospital_id": "H008",
"name": "北京清和综合门诊",
"city": "北京",
"region": "海淀区",
"level": "专科门诊",
"departments": [
"全科医学科",
"皮肤科",
"消化内科"
],
"supports_insurance": false,
"weekend_clinic": true
}
]
FILE:data/mock_slots.json
[
{
"slot_id": "S001",
"doctor_id": "D001",
"date": "2026-03-17",
"time": "09:00",
"available": true
},
{
"slot_id": "S002",
"doctor_id": "D002",
"date": "2026-03-17",
"time": "13:30",
"available": true
},
{
"slot_id": "S003",
"doctor_id": "D003",
"date": "2026-03-18",
"time": "10:00",
"available": true
},
{
"slot_id": "S004",
"doctor_id": "D004",
"date": "2026-03-17",
"time": "14:00",
"available": true
},
{
"slot_id": "S005",
"doctor_id": "D005",
"date": "2026-03-18",
"time": "09:30",
"available": true
},
{
"slot_id": "S006",
"doctor_id": "D006",
"date": "2026-03-17",
"time": "10:30",
"available": true
},
{
"slot_id": "S007",
"doctor_id": "D007",
"date": "2026-03-17",
"time": "08:30",
"available": true
},
{
"slot_id": "S008",
"doctor_id": "D008",
"date": "2026-03-18",
"time": "11:00",
"available": true
},
{
"slot_id": "S009",
"doctor_id": "D009",
"date": "2026-03-17",
"time": "15:00",
"available": true
},
{
"slot_id": "S010",
"doctor_id": "D010",
"date": "2026-03-17",
"time": "16:00",
"available": true
},
{
"slot_id": "S011",
"doctor_id": "D011",
"date": "2026-03-18",
"time": "13:00",
"available": true
},
{
"slot_id": "S012",
"doctor_id": "D012",
"date": "2026-03-19",
"time": "09:00",
"available": true
}
]