Skill: sunlinlin-aragon/ai-medical-care-manager-skill-amap-reminder-poster (version 1.0.1)

--- 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" } } } } } ```

path: package.json
content: {
  "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"
  }
}


path: README.md
content: # 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`，把这次就医经历整理成适合发小红书、朋友圈的记录型文案或海报文案。



path: scripts/amap_geocode.js
content: #!/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);
})();


path: scripts/amap_ip_locate.js
content: #!/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);
})();


path: scripts/amap_route_link.js
content: #!/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();


path: scripts/appointment_reminders.py
content: #!/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())


path: references/triage_rules.md
content: # 急症分流参考规则

以下情况优先建议急诊或直接拨打 120，不应继续普通门诊推荐：

- 持续胸痛、胸闷伴大汗或濒死感
- 明显呼吸困难、无法完整说话、嘴唇发紫
- 意识不清、昏迷、抽搐不止
- 突发偏瘫、口角歪斜、说话含糊
- 大出血、呕血、黑便明显、便血量大
- 严重过敏、喉头紧缩、血压下降
- 高热伴抽搐、婴幼儿精神反应差

对于高危但尚不确定的情况：
- 明确告诉用户本建议不能替代急诊评估
- 不要给出“先在家观察即可”这类强肯定结论
- 可以补充普通门诊建议，但必须降级为“病情稳定后再咨询门诊”


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)