
Mediwise Health Suite
- 1 installs
- 21 repo stars
- Updated July 27, 2026
- juneyaooo/mediwise-health-suite
Family health management suite that stores health records, diet tracking, weight management, and wearable sync in local SQLite, with optional cloud features.
About
A family health suite covering health profiles, medical history, medication, daily metrics, diet/nutrition tracking, and weight management, stored locally in SQLite. A developer or user uses it to record and query family health data and generate pre-visit summaries.
- Local SQLite storage by default; requires python3 and sqlite3
- Generates pre-visit summaries as text, image, or PDF
Mediwise Health Suite by the numbers
- 1 all-time installs (skills.sh)
- Ranked #2,479 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/juneyaooo/mediwise-health-suite --skill mediwise-health-suiteAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 21 |
| Last updated | July 27, 2026 |
| Repository | juneyaooo/mediwise-health-suite ↗ |
What it does
Family health management suite that stores health records, diet tracking, weight management, and wearable sync in local SQLite, with optional cloud features.
Files
diet-tracker
概述
提供每餐饮食记录、食物条目管理、每日/每周营养摘要、热量趋势分析等功能。与 mediwise-health-tracker 共享数据库,可与 weight-manager 联动形成"饮食 → 热量 → 体重"完整闭环。
数据模型
diet_records(一餐记录)
| 字段 | 说明 |
|---|---|
| id | 记录 ID |
| member_id | 成员 ID |
| meal_type | 餐次: breakfast/lunch/dinner/snack |
| meal_date | 日期 YYYY-MM-DD |
| meal_time | 时间 HH:MM(可选) |
| total_calories | 总热量 kcal |
| total_protein | 总蛋白质 g |
| total_fat | 总脂肪 g |
| total_carbs | 总碳水 g |
| total_fiber | 总膳食纤维 g |
| note | 备注 |
diet_items(食物条目)
| 字段 | 说明 |
|---|---|
| id | 条目 ID |
| record_id | 关联 diet_records.id |
| food_name | 食物名称 |
| amount | 数量 |
| unit | 单位(g/ml/份/个等) |
| calories | 热量 kcal |
| protein | 蛋白质 g |
| fat | 脂肪 g |
| carbs | 碳水 g |
| fiber | 膳食纤维 g |
| note | 备注 |
功能列表
diet.py — 饮食记录 CRUD
| 动作 | 子命令 | 必要参数 | 可选参数 | 说明 |
|---|---|---|---|---|
| add-meal | add-meal | --member-id, --meal-type, --meal-date | --meal-time, --note, --items (JSON) | 添加一餐记录(可同时包含多个食物条目) |
| add-item | add-item | --record-id, --food-name | --amount, --unit, --calories, --protein, --fat, --carbs, --fiber, --note | 向已有餐次追加食物条目 |
| list | list | --member-id | --date, --start-date, --end-date, --meal-type, --limit | 查看饮食记录 |
| delete | delete | --id | --type (record/item) | 删除记录或条目 |
| daily-summary | daily-summary | --member-id, --date | 某日营养摘要 |
nutrition.py — 营养分析
| 动作 | 子命令 | 必要参数 | 可选参数 | 说明 |
|---|---|---|---|---|
| weekly-summary | weekly-summary | --member-id | --end-date | 一周营养趋势(每日热量、平均三大营养素) |
| calorie-trend | calorie-trend | --member-id | --days (默认 7) | 热量趋势分析(N 天每日总热量) |
| nutrition-balance | nutrition-balance | --member-id | --days (默认 7) | 三大营养素比例分析 |
food_lookup.py — 食物营养查询
| 动作 | 子命令 | 必要参数 | 可选参数 | 说明 |
|---|---|---|---|---|
| food-lookup | search | params.query | params.limit (默认5), params.source (auto/cfcd/brands/usda) | 三层数据源搜索食物营养(CFCD6 → 中国品牌外食 → USDA) |
| food-stats | stats | — | — | 查看食物数据库概况(各数据源条目数) |
数据来源(按优先级): 1. CFCD6(离线):《中国食物成分表标准版第6版》1657 条,覆盖粮谷、肉蛋奶、蔬果、水产等 2. cn-brands(离线):339 条,奶茶、外卖、便利店、火锅等外食场景 3. USDA FoodData Central(在线):国际食材兜底,需配置 USDA_API_KEY 环境变量
使用流程
记录一餐的标准流程(不得跳步):
1. 确认成员身份(通过 mediwise-health-tracker 的 list-members) 2. 逐一查询每种食物的营养数据(food-lookup search,见下方"强制规则") 3. 用查询到的营养数据调用 add-meal,通过 --items JSON 一次录入多个食物 4. 如需追加食物,使用 add-item 向已有餐次添加 5. 使用 daily-summary 查看当天营养摄入 6. 使用 weekly-summary 或 calorie-trend 查看长期趋势
营养数据强制规则
禁止用 AI 自身知识直接估算营养数值写入数据库。 记录每种食物之前,必须先调用 food-lookup search 查询,用数据库返回的数据填充 --items。
# 步骤 1:先查每种食物
python3 {baseDir}/scripts/food_lookup.py search --query "炸排骨" --owner-id "<sender_id>"
python3 {baseDir}/scripts/food_lookup.py search --query "米饭" --owner-id "<sender_id>"
# 步骤 2:用查询结果里的营养数据填 --items,再记录
python3 {baseDir}/scripts/diet.py add-meal \
--member-id <id> --meal-type lunch --meal-date 2025-03-15 \
--items '[{"food_name":"炸排骨","amount":150,"unit":"g","calories":298,"protein":21.2,"fat":19.3,"carbs":9.1,"note":"来源:CFCD6"}]' \
--owner-id "<sender_id>"查询未命中时的处理:
- 三层数据源(CFCD6 → 中国品牌外食 → USDA)都未找到时,告知用户"未查到该食物的营养数据",询问用户是否手动输入营养值,或跳过该条目,不得自行估算后直接写入。
- 查到多个候选项时,展示给用户确认,选择最贴近的后再录入。
- 记录时在
note字段写明数据来源(如"来源:CFCD6"、"来源:用户手动输入")。
items JSON 格式
--items 参数接受 JSON 数组。所有营养字段必须来自 `food-lookup search` 的查询结果,不得由 AI 自行估算填充:
[
{"food_name": "鸡胸脯肉", "amount": 150, "unit": "g", "calories": 158, "protein": 31.6, "fat": 3.2, "carbs": 0.0, "note": "来源:CFCD6"},
{"food_name": "米饭", "amount": 200, "unit": "g", "calories": 232, "protein": 4.6, "fat": 0.6, "carbs": 51.5, "note": "来源:CFCD6"}
]自动换算规则:CFCD6/USDA 数据按 amount(克)换算;中国品牌/外食数据按每份直接使用。
注意事项
- 每次调用脚本必须携带 `--owner-id`(强制):从会话上下文获取发送者 ID(格式
<channel>:<user_id>,如feishu:ou_xxx或qqbot:12345),作为所有脚本的--owner-id参数,不得省略。 - 禁止 AI 估算营养数据:所有热量/蛋白质/脂肪/碳水/膳食纤维数值必须来自
food-lookup search,或经用户明确确认的手动输入,不得由 AI 凭自身知识估算后直接写入。 note字段必须记录数据来源,便于用户事后核查。- meal_type 支持: breakfast(早餐)、lunch(午餐)、dinner(晚餐)、snack(加餐/零食)
# Git files
.git/
.gitignore
# Documentation (keep only essential)
DELIVERY_SUMMARY.md
PROJECT_COMPLETE.md
FINAL_REPORT.md
RELEASE_SUMMARY.md
# Test data
**/test_data/
# Temporary files
*.tmp
*.log
.DS_Store
# Python cache
__pycache__/
*.pyc
*.pyo
# IDE
.vscode/
.idea/
# Mediwise Health Suite - Local data (NEVER package)
*.db
*.db-wal
*.db-shm
*.sqlite
*.sqlite-wal
*.sqlite-shm
*.sqlite3
*.sqlite3-wal
*.sqlite3-shm
data/
**/data/
attachments/
**/attachments/
exports/
**/exports/
!exports/.gitkeep
backups/
**/backups/
logs/
**/logs/
# MediWise Health Suite — 环境变量配置模板
# 复制为 .env 并填入你的值,或直接在系统/Docker 中设置这些变量
# 所有变量均为可选,未设置时使用默认行为(本地 SQLite,无 AI 功能)
#
# 推荐使用 direnv / Docker --env-file / systemd EnvironmentFile 加载此文件
# ============================================================
# 多模态视觉模型(图片识别体检报告、化验单、病历等)
# 【强烈推荐配置】不配置则无法识别图片/PDF
# ============================================================
# --- 方案 A:硅基流动(国内,推荐)---
# Qwen2.5-VL 系列视觉能力强,价格低,国内访问稳定
# 注册(含邀请奖励):https://cloud.siliconflow.cn/i/MOlLXTYM
MEDIWISE_VISION_PROVIDER=siliconflow
MEDIWISE_VISION_MODEL=Qwen/Qwen2.5-VL-72B-Instruct
MEDIWISE_VISION_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
MEDIWISE_VISION_BASE_URL=https://api.siliconflow.cn/v1
# --- 方案 B:Google Gemini 3(多模态效果强,推荐海外用户)---
# 获取 API Key:https://aistudio.google.com/app/apikey
# MEDIWISE_VISION_PROVIDER=openai
# MEDIWISE_VISION_MODEL=gemini-3.1-pro-preview
# MEDIWISE_VISION_API_KEY=AIzaxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# MEDIWISE_VISION_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai
# --- 方案 C:OpenAI GPT-4o ---
# MEDIWISE_VISION_PROVIDER=openai
# MEDIWISE_VISION_MODEL=gpt-4o
# MEDIWISE_VISION_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# MEDIWISE_VISION_BASE_URL=https://api.openai.com/v1
# --- 方案 D:阶跃星辰 Step-1V(国内备选)---
# MEDIWISE_VISION_PROVIDER=openai
# MEDIWISE_VISION_MODEL=step-1v-32k
# MEDIWISE_VISION_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# MEDIWISE_VISION_BASE_URL=https://api.stepfun.com/v1
# --- 方案 E:本地 Ollama(完全离线,需自行部署)---
# 支持 llava / minicpm-v / qwen2-vl 等本地视觉模型
# MEDIWISE_VISION_PROVIDER=ollama
# MEDIWISE_VISION_MODEL=qwen2-vl:7b
# MEDIWISE_VISION_API_KEY=ollama
# MEDIWISE_VISION_BASE_URL=http://localhost:11434/v1
# ============================================================
# 纯文本 LLM(结构化提取、快速录入解析)
# 不设置则自动复用上面的视觉模型(推荐)
# 如需单独配置更快/更便宜的文本模型可在此填写
# ============================================================
# MEDIWISE_LLM_PROVIDER=siliconflow
# MEDIWISE_LLM_MODEL=Qwen/Qwen2.5-72B-Instruct
# MEDIWISE_LLM_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# MEDIWISE_LLM_BASE_URL=https://api.siliconflow.cn/v1
# 注册硅基流动(含邀请奖励):https://cloud.siliconflow.cn/i/MOlLXTYM
# ============================================================
# 多租户隔离(共享实例必填,个人使用可不填)
# 每个用户/渠道设置不同的 MEDIWISE_OWNER_ID,数据自动隔离
# ============================================================
# MEDIWISE_OWNER_ID=user_alice
# ============================================================
# 数据存储路径(可选,默认存在系统用户数据目录)
# ============================================================
# MEDIWISE_DATA_DIR=/data/mediwise
# MEDIWISE_MEDICAL_DB_PATH=/data/mediwise/medical.db
# MEDIWISE_LIFESTYLE_DB_PATH=/data/mediwise/lifestyle.db
# ============================================================
# USDA 食材数据库(可选,用于国际食材查询)
# 免费注册:https://api.data.gov/signup/
# 未设置时使用内置离线数据库
# ============================================================
# USDA_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
问题描述 / Bug Description
简要描述遇到的问题。 A clear and concise description of what the bug is.
复现步骤 / Steps to Reproduce
1. 执行命令 '...' 2. 输入 '...' 3. 看到错误 '...'
Steps to reproduce the behavior: 1. Run command '...' 2. Enter '...' 3. See error '...'
预期行为 / Expected Behavior
描述你期望发生什么。 A clear and concise description of what you expected to happen.
实际行为 / Actual Behavior
描述实际发生了什么。 A clear and concise description of what actually happened.
错误信息 / Error Messages
粘贴完整的错误信息
Paste the full error message here环境信息 / Environment
- OS: [e.g. Ubuntu 22.04, macOS 14.0, Windows 11]
- Python Version: [e.g. 3.8.10]
- OpenClaw Version: [e.g. 2026.3.0]
- MediWise Health Suite Version: [e.g. 1.0.0]
附加信息 / Additional Context
添加任何其他有助于解决问题的信息。 Add any other context about the problem here.
截图 / Screenshots
如果适用,添加截图帮助说明问题。 If applicable, add screenshots to help explain your problem.
功能描述 / Feature Description
简要描述你希望添加的功能。 A clear and concise description of the feature you'd like to see.
使用场景 / Use Case
描述这个功能将如何使用,解决什么问题。 Describe how this feature would be used and what problem it solves.
示例对话 / Example Dialogue:
用户:"..."
助手:"..."建议的实现方式 / Suggested Implementation
如果你有实现想法,请描述。 If you have ideas about how to implement this, please describe.
替代方案 / Alternatives
你是否考虑过其他替代方案? Have you considered any alternative solutions?
优先级 / Priority
- [ ] 高 / High - 核心功能缺失
- [ ] 中 / Medium - 重要但有替代方案
- [ ] 低 / Low - 锦上添花
附加信息 / Additional Context
添加任何其他有助于理解这个功能请求的信息。 Add any other context about the feature request here.
变更描述 / Description
简要描述这个 PR 的变更内容。 Please include a summary of the changes.
Fixes # (issue)
变更类型 / Type of Change
- [ ] 🐛 Bug 修复 / Bug fix (non-breaking change which fixes an issue)
- [ ] ✨ 新功能 / New feature (non-breaking change which adds functionality)
- [ ] 💥 破坏性变更 / Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] 📝 文档更新 / Documentation update
- [ ] 🎨 代码风格 / Code style update (formatting, renaming)
- [ ] ♻️ 重构 / Refactoring (no functional changes)
- [ ] ⚡️ 性能优化 / Performance improvement
- [ ] ✅ 测试 / Tests
测试 / Testing
描述你如何测试了这些变更。 Describe the tests you ran to verify your changes.
- [ ] 手动测试通过 / Manual testing passed
- [ ] 添加了单元测试 / Added unit tests
- [ ] 所有现有测试通过 / All existing tests pass
测试步骤 / Test Steps
1. 执行 '...' 2. 输入 '...' 3. 验证 '...'
检查清单 / Checklist
- [ ] 我的代码遵循项目的代码风格 / My code follows the style guidelines
- [ ] 我已进行自我审查 / I have performed a self-review
- [ ] 我已添加必要的注释 / I have commented my code where necessary
- [ ] 我已更新相关文档 / I have updated the documentation
- [ ] 我的变更没有产生新的警告 / My changes generate no new warnings
- [ ] 我已添加测试证明修复有效或功能正常 / I have added tests that prove my fix/feature works
- [ ] 新旧测试都通过 / New and existing tests pass
截图 / Screenshots
如果适用,添加截图。 If applicable, add screenshots.
附加信息 / Additional Notes
添加任何其他信息。 Add any other notes about the PR here.
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Mediwise Health Suite - Local data (NEVER commit)
*.db
*.db-wal
*.db-shm
*.sqlite
*.sqlite-wal
*.sqlite-shm
*.sqlite3
*.sqlite3-wal
*.sqlite3-shm
data/
**/data/
attachments/
**/attachments/
exports/
**/exports/
!exports/.gitkeep
backups/
**/backups/
logs/
**/logs/
# Mediwise - test data with PII
**/test_data/*.db
**/test_data/*.sqlite
**/test_data/*.json
!**/test_data/.gitkeep
# Mediwise - local config may contain API keys (default stored outside repo, but guard anyway)
config.json
**/config.json
Changelog
All notable changes to MediWise Health Suite will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Planned
- Integration with more wearable devices
- Enhanced AI-powered health insights
- Mobile app companion
- Export to standard medical formats (HL7, FHIR)
[0.3.0] - 2026-03-15
Added
setup.py backupcommand: packs all databases (medical.db,lifestyle.db,config.json) into a portable.tar.gzarchive for device migrationsetup.py restorecommand: restores data from a backup archive and automatically runs schema migrations to the latest versionsetup.py list-vision-providerscommand: lists all built-in vision provider presets with default model, base URL, and API key hints- Built-in provider presets for vision model setup (siliconflow, gemini, openai, stepfun, ollama):
--modeland--base-urlare now auto-filled, only--providerand--api-keyare required - Conversational vision model setup guidance in
SKILL.md: AI now guides users through configuration via chat without exposing CLI commands checkcommand now outputsvision_quick_setupfield with actionable next steps when vision model is not configured.gitignorenow explicitly excludesconfig.jsonto prevent accidental API key exposure- Updated
SKILL.md,INSTALLATION.md, andQUICKSTART.mdwith backup/restore documentation, migration workflow, and simplified vision setup instructions
[1.0.0] - 2026-03-08
Added
- Initial release of MediWise Health Suite
- 5 health management skills:
mediwise-health-tracker: Core health records managementdiet-tracker: Diet trackingweight-manager: Weight managementhealth-monitor: Smart health monitoring and alerts (待完善)wearable-sync: Wearable device sync (待完善)- Shared SQLite database for all health data
- Doctor visit summary generation (text/image/PDF)
- Image recognition for medical reports
- Multi-level health alerts
- Medication and follow-up reminders
- Daily health briefings
- Comprehensive documentation (Chinese and English)
Security
- All data stored locally in SQLite
- No cloud upload of personal health information
- Multi-tenant isolation support
Contributing to MediWise Health Suite
感谢您考虑为 MediWise Health Suite 做出贡献!
Thank you for considering contributing to MediWise Health Suite!
如何贡献 / How to Contribute
报告问题 / Reporting Issues
如果您发现 bug 或有功能建议:
If you find a bug or have a feature suggestion:
1. 检查 Issues 是否已有相关问题 2. 如果没有,创建新 Issue,提供详细信息:
- 问题描述
- 复现步骤
- 预期行为
- 实际行为
- 系统环境(OS、Python 版本等)
提交代码 / Submitting Code
1. Fork 仓库
git clone https://github.com/JuneYaooo/mediwise-health-suite.git
cd mediwise-health-suite2. 创建分支
git checkout -b feature/your-feature-name
# 或
git checkout -b fix/your-bug-fix3. 进行修改
- 遵循现有代码风格
- 添加必要的注释
- 更新相关文档
4. 测试
- 确保所有功能正常工作
- 测试边界情况
- 不要包含真实用户数据
5. 提交
git add .
git commit -m "feat: add new feature" # 或 "fix: fix bug"6. 推送并创建 Pull Request
git push origin feature/your-feature-name代码规范 / Code Standards
Python 代码
- 使用 PEP 8 风格
- 函数和类添加 docstring
- 变量命名清晰易懂
- 避免硬编码路径和敏感信息
SKILL.md 文件
- 必须包含 YAML frontmatter(name, description)
- description 要清晰描述触发条件
- 使用中英文双语
提交信息 / Commit Messages
使用语义化提交信息:
feat:新功能fix:Bug 修复docs:文档更新style:代码格式(不影响功能)refactor:重构test:测试相关chore:构建/工具相关
隐私和安全 / Privacy and Security
⚠ 重要提醒:
- 不要提交包含真实用户数据的文件
- 不要提交 API keys、密码等敏感信息
- 不要提交
.db或.sqlite文件 - 测试数据应使用匿名/虚构信息
文档 / Documentation
如果您的更改影响用户使用方式,请更新:
- README.md
- 相关 SKILL.md
- references/ 目录下的文档
许可证 / License
提交代码即表示您同意将代码以 MIT 许可证发布。
By submitting code, you agree to license your contribution under the MIT License.
行为准则 / Code of Conduct
- 尊重所有贡献者
- 保持友好和专业
- 接受建设性批评
- 关注项目最佳利益
问题?/ Questions?
如有疑问,请:
- 创建 Issue
感谢您的贡献!🎉
Thank you for your contributions! 🎉
interface:
display_name: "饮食记录"
short_description: "记录每日饮食、食物条目,并分析热量与营养趋势变化情况"
default_prompt: "Use $diet-tracker to log meals and summarize calorie and nutrition trends."
/**
* Diet Tracker - OpenClaw Skill
*
* ESM entry point that routes actions to Python scripts.
*/
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const execFileAsync = promisify(execFile);
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCRIPTS_DIR = resolve(__dirname, 'scripts');
const HEALTH_SCRIPTS_DIR = resolve(__dirname, '..', 'mediwise-health-tracker', 'scripts');
/**
* Action-to-script routing table.
*/
const ROUTES = {
'add-meal': (inputs) => {
const args = ['add-meal', '--member-id', inputs.member_id,
'--meal-type', inputs.params?.meal_type ?? 'lunch',
'--meal-date', inputs.params?.meal_date ?? ''];
if (inputs.params?.meal_time) args.push('--meal-time', inputs.params.meal_time);
if (inputs.params?.note) args.push('--note', inputs.params.note);
if (inputs.params?.items) args.push('--items', JSON.stringify(inputs.params.items));
return { script: 'diet.py', args };
},
'add-item': (inputs) => {
const args = ['add-item', '--record-id', inputs.params?.record_id ?? ''];
const p = inputs.params ?? {};
args.push('--food-name', p.food_name ?? '');
if (p.amount != null) args.push('--amount', String(p.amount));
if (p.unit) args.push('--unit', p.unit);
if (p.calories != null) args.push('--calories', String(p.calories));
if (p.protein != null) args.push('--protein', String(p.protein));
if (p.fat != null) args.push('--fat', String(p.fat));
if (p.carbs != null) args.push('--carbs', String(p.carbs));
if (p.fiber != null) args.push('--fiber', String(p.fiber));
if (p.note) args.push('--note', p.note);
return { script: 'diet.py', args };
},
'list-meals': (inputs) => {
const args = ['list', '--member-id', inputs.member_id];
const p = inputs.params ?? {};
if (p.date) args.push('--date', p.date);
if (p.start_date) args.push('--start-date', p.start_date);
if (p.end_date) args.push('--end-date', p.end_date);
if (p.meal_type) args.push('--meal-type', p.meal_type);
if (p.limit) args.push('--limit', String(p.limit));
return { script: 'diet.py', args };
},
'delete-meal': (inputs) => {
const args = ['delete', '--id', inputs.params?.id ?? ''];
if (inputs.params?.type) args.push('--type', inputs.params.type);
return { script: 'diet.py', args };
},
'daily-summary': (inputs) => ({
script: 'diet.py',
args: ['daily-summary', '--member-id', inputs.member_id,
'--date', inputs.params?.date ?? ''],
}),
'weekly-summary': (inputs) => {
const args = ['weekly-summary', '--member-id', inputs.member_id];
if (inputs.params?.end_date) args.push('--end-date', inputs.params.end_date);
return { script: 'nutrition.py', args };
},
'calorie-trend': (inputs) => {
const args = ['calorie-trend', '--member-id', inputs.member_id];
if (inputs.params?.days) args.push('--days', String(inputs.params.days));
return { script: 'nutrition.py', args };
},
'nutrition-balance': (inputs) => {
const args = ['nutrition-balance', '--member-id', inputs.member_id];
if (inputs.params?.days) args.push('--days', String(inputs.params.days));
return { script: 'nutrition.py', args };
},
'food-lookup': (inputs) => {
const args = ['search', '--query', inputs.params?.query ?? ''];
if (inputs.params?.limit) args.push('--limit', String(inputs.params.limit));
if (inputs.params?.source) args.push('--source', inputs.params.source);
if (inputs.params?.no_brands) args.push('--no-brands');
return { script: 'food_lookup.py', args };
},
'food-stats': () => ({ script: 'food_lookup.py', args: ['stats'] }),
};
/**
* Run a Python script and return parsed JSON output.
*/
async function runScript(script, args) {
const scriptPath = resolve(SCRIPTS_DIR, script);
const { stdout } = await execFileAsync('python3', [scriptPath, ...args], {
timeout: 30_000,
env: { ...process.env, PYTHONPATH: HEALTH_SCRIPTS_DIR },
});
return JSON.parse(stdout.trim());
}
/**
* OpenClaw Skill entry point.
*/
export async function execute(inputs, context) {
const { action } = inputs;
const log = context?.log ?? console.log;
log(`[diet-tracker] action=${action}`);
const routeFn = ROUTES[action];
if (!routeFn) {
return { status: 'error', error: `Unknown action: ${action}` };
}
try {
const { script, args } = routeFn(inputs);
const ownerId = inputs.owner_id;
if (ownerId) {
args.push('--owner-id', ownerId);
}
log(`[diet-tracker] script=${script} args=${args.join(' ')}`);
const result = await runScript(script, args);
return { status: 'ok', result };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log(`[diet-tracker] error: ${message}`);
return { status: 'error', error: message };
}
}
{
"name": "@mediwise/diet-tracker-skill",
"version": "0.1.0",
"type": "module",
"main": "index.js",
"description": "日常饮食记录与营养分析 OpenClaw Skill",
"author": "mediwise",
"license": "MIT",
"keywords": ["openclaw", "skill", "health", "diet", "nutrition"]
}
"""饮食记录 CRUD 与每日摘要。"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import os
_logger = logging.getLogger(__name__)
# Unified path setup
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'shared'))
from path_setup import setup_mediwise_path
setup_mediwise_path()
from health_db import (
ensure_db,
get_medical_connection,
get_lifestyle_connection,
generate_id,
now_iso,
row_to_dict,
rows_to_list,
output_json,
transaction,
verify_member_ownership,
)
from validators import validate_date, validate_date_optional
from metric_utils import get_member_or_error
VALID_MEAL_TYPES = ["breakfast", "lunch", "dinner", "snack"]
_GRAM_UNITS = {'g', '克', 'gram', 'grams'}
def _autofill_item_nutrition(item: dict) -> dict:
"""Auto-fill nutrition from food_lookup when user didn't provide calorie data."""
if item.get('calories'):
return item
food_name = item.get('food_name', '')
if not food_name:
return item
try:
import food_lookup as _fl
result = _fl.get_by_name(food_name)
if not result:
# Exact match failed; try search and take the best result
hits = _fl.search(food_name, limit=1, source='auto')
if hits and hits.get('results'):
result = hits['results'][0]
if not result:
return item
# Scale per-100g sources by amount if user specified grams
amount = item.get('amount')
unit = (item.get('unit') or '').lower().strip()
scale = 1.0
if result.get('per') == '100g' and amount and unit in _GRAM_UNITS:
scale = float(amount) / 100.0
def _val(field):
v = result.get(field)
return round(v * scale, 1) if v is not None else 0
item = dict(item)
item['calories'] = _val('kcal')
item['protein'] = _val('protein')
item['fat'] = _val('fat')
item['carbs'] = _val('carbs')
item['fiber'] = _val('fiber')
if not item.get('note'):
item['note'] = f"营养数据来源: {result.get('source_name', result.get('source', ''))}"
except Exception as e:
_logger.warning("food_lookup auto-fill failed for '%s': %s", food_name, e)
return item
MEAL_TYPE_NAMES = {
"breakfast": "早餐",
"lunch": "午餐",
"dinner": "晚餐",
"snack": "加餐",
}
def _parse_items(items_json):
"""Parse and validate items JSON array."""
if not items_json:
return []
if isinstance(items_json, str):
items = json.loads(items_json)
else:
items = items_json
if not isinstance(items, list):
raise ValueError("items 必须为 JSON 数组")
for i, item in enumerate(items):
if not isinstance(item, dict):
raise ValueError(f"items[{i}] 必须为对象")
if not item.get("food_name"):
raise ValueError(f"items[{i}].food_name 不能为空")
return [_autofill_item_nutrition(item) for item in items]
def _compute_totals(conn, record_id):
"""Recompute and update totals for a diet record from its items."""
rows = conn.execute(
"SELECT calories, protein, fat, carbs, fiber FROM diet_items WHERE record_id=? AND is_deleted=0",
(record_id,)
).fetchall()
totals = {"total_calories": 0, "total_protein": 0, "total_fat": 0, "total_carbs": 0, "total_fiber": 0}
for r in rows:
totals["total_calories"] += r["calories"] or 0
totals["total_protein"] += r["protein"] or 0
totals["total_fat"] += r["fat"] or 0
totals["total_carbs"] += r["carbs"] or 0
totals["total_fiber"] += r["fiber"] or 0
conn.execute(
"""UPDATE diet_records SET total_calories=?, total_protein=?, total_fat=?, total_carbs=?, total_fiber=?
WHERE id=?""",
(totals["total_calories"], round(totals["total_protein"], 1),
round(totals["total_fat"], 1), round(totals["total_carbs"], 1),
round(totals["total_fiber"], 1), record_id)
)
return totals
def add_meal(args):
"""添加一餐记录(含多个食物条目)。"""
ensure_db()
with transaction(domain="medical") as medical_conn:
m = get_member_or_error(medical_conn, args.member_id)
if not m:
output_json({"status": "error", "message": f"未找到成员: {args.member_id}"})
return
if not verify_member_ownership(medical_conn, args.member_id, args.owner_id):
output_json({"status": "error", "message": "无权访问该成员"})
return
if args.meal_type not in VALID_MEAL_TYPES:
output_json({"status": "error", "message": f"不支持的餐次类型: {args.meal_type},支持: {', '.join(VALID_MEAL_TYPES)}"})
return
try:
meal_date = validate_date(args.meal_date, "用餐日期")
except ValueError as e:
output_json({"status": "error", "message": str(e)})
return
try:
items = _parse_items(args.items)
except (ValueError, json.JSONDecodeError) as e:
output_json({"status": "error", "message": f"食物条目格式错误: {e}"})
return
with transaction(domain="lifestyle") as conn:
# Re-verify member still exists before writing (reduces TOCTOU window)
member_check = get_medical_connection()
try:
if not member_check.execute(
"SELECT 1 FROM members WHERE id=? AND is_deleted=0", (args.member_id,)
).fetchone():
output_json({"status": "error", "message": f"成员已不存在: {args.member_id}"})
return
finally:
member_check.close()
record_id = generate_id()
conn.execute(
"""INSERT INTO diet_records
(id, member_id, meal_type, meal_date, meal_time, total_calories, total_protein, total_fat, total_carbs, total_fiber, note, created_at, is_deleted)
VALUES (?, ?, ?, ?, ?, 0, 0, 0, 0, 0, ?, ?, 0)""",
(record_id, args.member_id, args.meal_type, meal_date, args.meal_time, args.note, now_iso())
)
for item in items:
item_id = generate_id()
conn.execute(
"""INSERT INTO diet_items
(id, record_id, food_name, amount, unit, calories, protein, fat, carbs, fiber, note, created_at, is_deleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)""",
(item_id, record_id, item["food_name"],
item.get("amount"), item.get("unit"),
item.get("calories", 0), item.get("protein", 0), item.get("fat", 0),
item.get("carbs", 0), item.get("fiber", 0),
item.get("note"), now_iso())
)
_compute_totals(conn, record_id)
conn.commit()
record = row_to_dict(conn.execute("SELECT * FROM diet_records WHERE id=?", (record_id,)).fetchone())
item_rows = rows_to_list(conn.execute(
"SELECT * FROM diet_items WHERE record_id=? AND is_deleted=0", (record_id,)
).fetchall())
record["items"] = item_rows
meal_name = MEAL_TYPE_NAMES.get(args.meal_type, args.meal_type)
output_json({
"status": "ok",
"message": f"已记录{m['name']}的{meal_date}{meal_name}({len(items)}个食物,共{record['total_calories']}kcal)",
"record": record
})
def add_item(args):
"""向已有餐次追加食物条目。"""
ensure_db()
with transaction(domain="lifestyle") as conn:
record = conn.execute(
"SELECT * FROM diet_records WHERE id=? AND is_deleted=0", (args.record_id,)
).fetchone()
if not record:
output_json({"status": "error", "message": f"未找到餐次记录: {args.record_id}"})
return
medical_conn = get_medical_connection()
try:
if not verify_member_ownership(medical_conn, record["member_id"], args.owner_id):
output_json({"status": "error", "message": "无权访问该餐次记录"})
return
finally:
medical_conn.close()
if not args.food_name:
output_json({"status": "error", "message": "食物名称不能为空"})
return
# Auto-fill nutrition from food_lookup if not provided
if not args.calories:
_filled = _autofill_item_nutrition({
'food_name': args.food_name,
'amount': args.amount,
'unit': args.unit,
})
args.calories = args.calories or _filled.get('calories', 0)
args.protein = args.protein or _filled.get('protein', 0)
args.fat = args.fat or _filled.get('fat', 0)
args.carbs = args.carbs or _filled.get('carbs', 0)
args.fiber = args.fiber or _filled.get('fiber', 0)
args.note = args.note or _filled.get('note')
item_id = generate_id()
conn.execute(
"""INSERT INTO diet_items
(id, record_id, food_name, amount, unit, calories, protein, fat, carbs, fiber, note, created_at, is_deleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)""",
(item_id, args.record_id, args.food_name,
args.amount, args.unit,
args.calories or 0, args.protein or 0, args.fat or 0,
args.carbs or 0, args.fiber or 0,
args.note, now_iso())
)
_compute_totals(conn, args.record_id)
conn.commit()
item = row_to_dict(conn.execute("SELECT * FROM diet_items WHERE id=?", (item_id,)).fetchone())
output_json({
"status": "ok",
"message": f"已向餐次 {args.record_id} 添加食物: {args.food_name}",
"item": item
})
def list_meals(args):
"""查看某日/某段时间的饮食记录。"""
ensure_db()
medical_conn = get_medical_connection()
try:
if not verify_member_ownership(medical_conn, args.member_id, args.owner_id):
output_json({"status": "error", "message": "无权访问该成员"})
return
finally:
medical_conn.close()
conn = get_lifestyle_connection()
try:
sql = "SELECT * FROM diet_records WHERE member_id=? AND is_deleted=0"
params = [args.member_id]
if args.date:
sql += " AND meal_date=?"
params.append(args.date)
else:
if args.start_date:
sql += " AND meal_date>=?"
params.append(args.start_date)
if args.end_date:
sql += " AND meal_date<=?"
params.append(args.end_date)
if args.meal_type:
if args.meal_type not in VALID_MEAL_TYPES:
output_json({"status": "error", "message": f"不支持的餐次类型: {args.meal_type}"})
return
sql += " AND meal_type=?"
params.append(args.meal_type)
sql += " ORDER BY meal_date DESC, meal_time DESC"
if args.limit:
sql += " LIMIT ?"
params.append(int(args.limit))
rows = conn.execute(sql, params).fetchall()
records = rows_to_list(rows)
# Batch-fetch all diet_items for the returned records in one query
record_ids = [rec["id"] for rec in records]
items_by_record = {rid: [] for rid in record_ids}
if record_ids:
placeholders = ",".join("?" for _ in record_ids)
all_items = conn.execute(
f"SELECT * FROM diet_items WHERE record_id IN ({placeholders}) AND is_deleted=0 ORDER BY created_at",
record_ids
).fetchall()
for item in rows_to_list(all_items):
items_by_record[item["record_id"]].append(item)
for rec in records:
rec["items"] = items_by_record.get(rec["id"], [])
output_json({"status": "ok", "count": len(records), "records": records})
finally:
conn.close()
def delete_record(args):
"""删除饮食记录或食物条目(软删除)。"""
ensure_db()
delete_type = args.type or "record"
with transaction(domain="lifestyle") as conn:
if delete_type == "item":
row = conn.execute(
"""SELECT di.*, dr.member_id
FROM diet_items di
JOIN diet_records dr ON dr.id=di.record_id
WHERE di.id=? AND di.is_deleted=0 AND dr.is_deleted=0""",
(args.id,)
).fetchone()
if not row:
output_json({"status": "error", "message": f"未找到食物条目: {args.id}"})
return
medical_conn = get_medical_connection()
try:
if not verify_member_ownership(medical_conn, row["member_id"], args.owner_id):
output_json({"status": "error", "message": "无权访问该食物条目"})
return
finally:
medical_conn.close()
conn.execute("UPDATE diet_items SET is_deleted=1 WHERE id=?", (args.id,))
_compute_totals(conn, row["record_id"])
conn.commit()
output_json({"status": "ok", "message": f"食物条目已删除: {row['food_name']}"})
else:
row = conn.execute("SELECT * FROM diet_records WHERE id=? AND is_deleted=0", (args.id,)).fetchone()
if not row:
output_json({"status": "error", "message": f"未找到餐次记录: {args.id}"})
return
medical_conn = get_medical_connection()
try:
if not verify_member_ownership(medical_conn, row["member_id"], args.owner_id):
output_json({"status": "error", "message": "无权访问该餐次记录"})
return
finally:
medical_conn.close()
conn.execute("UPDATE diet_records SET is_deleted=1 WHERE id=?", (args.id,))
conn.execute("UPDATE diet_items SET is_deleted=1 WHERE record_id=?", (args.id,))
conn.commit()
meal_name = MEAL_TYPE_NAMES.get(row["meal_type"], row["meal_type"])
output_json({"status": "ok", "message": f"已删除{row['meal_date']}{meal_name}记录"})
def daily_summary(args):
"""某日营养摘要(总热量/三大营养素)。"""
ensure_db()
conn = get_lifestyle_connection()
try:
try:
date = validate_date(args.date, "日期")
except ValueError as e:
output_json({"status": "error", "message": str(e)})
return
medical_conn = get_medical_connection()
try:
m = get_member_or_error(medical_conn, args.member_id)
if not m:
output_json({"status": "error", "message": f"未找到成员: {args.member_id}"})
return
if not verify_member_ownership(medical_conn, args.member_id, args.owner_id):
output_json({"status": "error", "message": "无权访问该成员"})
return
finally:
medical_conn.close()
records = conn.execute(
"SELECT * FROM diet_records WHERE member_id=? AND meal_date=? AND is_deleted=0 ORDER BY meal_time",
(args.member_id, date)
).fetchall()
records = rows_to_list(records)
# Batch-fetch all diet_items for the day's records in one query
record_ids = [rec["id"] for rec in records]
items_by_record = {rid: [] for rid in record_ids}
if record_ids:
placeholders = ",".join("?" for _ in record_ids)
all_items = conn.execute(
f"SELECT record_id, food_name, calories FROM diet_items WHERE record_id IN ({placeholders}) AND is_deleted=0",
record_ids
).fetchall()
for item in rows_to_list(all_items):
items_by_record[item["record_id"]].append(
{"food_name": item["food_name"], "calories": item["calories"]}
)
totals = {"calories": 0, "protein": 0, "fat": 0, "carbs": 0, "fiber": 0}
meals = []
for rec in records:
totals["calories"] += rec["total_calories"] or 0
totals["protein"] += rec["total_protein"] or 0
totals["fat"] += rec["total_fat"] or 0
totals["carbs"] += rec["total_carbs"] or 0
totals["fiber"] += rec["total_fiber"] or 0
meals.append({
"meal_type": rec["meal_type"],
"meal_type_name": MEAL_TYPE_NAMES.get(rec["meal_type"], rec["meal_type"]),
"calories": rec["total_calories"] or 0,
"items": items_by_record.get(rec["id"], []),
})
# Round totals
for k in ("protein", "fat", "carbs", "fiber"):
totals[k] = round(totals[k], 1)
# Macronutrient ratio
total_macro_g = totals["protein"] + totals["fat"] + totals["carbs"]
ratio = {}
if total_macro_g > 0:
ratio = {
"protein_pct": round(totals["protein"] / total_macro_g * 100, 1),
"fat_pct": round(totals["fat"] / total_macro_g * 100, 1),
"carbs_pct": round(totals["carbs"] / total_macro_g * 100, 1),
}
output_json({
"status": "ok",
"member_name": m["name"],
"date": date,
"meal_count": len(meals),
"meals": meals,
"totals": totals,
"macro_ratio": ratio,
})
finally:
conn.close()
def main():
parser = argparse.ArgumentParser(description="饮食记录管理")
sub = parser.add_subparsers(dest="command", required=True)
# add-meal
p_add = sub.add_parser("add-meal")
p_add.add_argument("--member-id", required=True)
p_add.add_argument("--meal-type", required=True, help=f"餐次: {', '.join(VALID_MEAL_TYPES)}")
p_add.add_argument("--meal-date", required=True, help="日期 YYYY-MM-DD")
p_add.add_argument("--meal-time", default=None, help="时间 HH:MM")
p_add.add_argument("--items", default=None, help="食物条目 JSON 数组")
p_add.add_argument("--note", default=None)
p_add.add_argument("--owner-id", default=None)
# add-item
p_item = sub.add_parser("add-item")
p_item.add_argument("--record-id", required=True, help="餐次记录 ID")
p_item.add_argument("--food-name", required=True, help="食物名称")
p_item.add_argument("--amount", type=float, default=None, help="数量")
p_item.add_argument("--unit", default=None, help="单位(g/ml/份/个)")
p_item.add_argument("--calories", type=float, default=None, help="热量 kcal")
p_item.add_argument("--protein", type=float, default=None, help="蛋白质 g")
p_item.add_argument("--fat", type=float, default=None, help="脂肪 g")
p_item.add_argument("--carbs", type=float, default=None, help="碳水 g")
p_item.add_argument("--fiber", type=float, default=None, help="膳食纤维 g")
p_item.add_argument("--note", default=None)
p_item.add_argument("--owner-id", default=None)
# list
p_list = sub.add_parser("list")
p_list.add_argument("--member-id", required=True)
p_list.add_argument("--date", default=None, help="指定日期 YYYY-MM-DD")
p_list.add_argument("--start-date", default=None)
p_list.add_argument("--end-date", default=None)
p_list.add_argument("--meal-type", default=None)
p_list.add_argument("--limit", type=int, default=None, help="最多返回条数")
p_list.add_argument("--owner-id", default=None)
# delete
p_del = sub.add_parser("delete")
p_del.add_argument("--id", required=True)
p_del.add_argument("--type", default=None, help="删除类型: record(默认)或 item")
p_del.add_argument("--owner-id", default=None)
# daily-summary
p_sum = sub.add_parser("daily-summary")
p_sum.add_argument("--member-id", required=True)
p_sum.add_argument("--date", required=True, help="日期 YYYY-MM-DD")
p_sum.add_argument("--owner-id", default=None)
args = parser.parse_args()
commands = {
"add-meal": add_meal,
"add-item": add_item,
"list": list_meals,
"delete": delete_record,
"daily-summary": daily_summary,
}
commands[args.command](args)
if __name__ == "__main__":
main()
"""食物营养查询 - 三层数据源:
优先级:
1. CFCD6(离线,优先)
数据来源:《中国食物成分表标准版第6版》(中国疾病预防控制中心营养与健康所)
JSON 格式整理:https://github.com/Sanotsu/china-food-composition-data
收录 1657 条中国食材,覆盖谷物、蔬菜、水果、肉蛋奶、水产等全品类
2. cn-brands(离线,外食场景兜底)
数据来源:https://github.com/H1an1/health-coach(references/cn-brands.md)
来源注明:产品包装标注、品牌官方信息、薄荷健康等平台
收录 339 条,覆盖奶茶、外卖、便利店、火锅、烧烤、早餐等
3. USDA FoodData Central(在线,国际食材兜底)
数据来源:美国农业部 https://fdc.nal.usda.gov/
免费 API,需在环境变量 USDA_API_KEY 配置注册密钥
注册地址:https://api.data.gov/signup/
用法:
python food_lookup.py search --query 鸡胸肉
python food_lookup.py search --query tofu --source usda
python food_lookup.py search --query 宫保鸡丁 --limit 3
python food_lookup.py stats
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import unicodedata
import urllib.parse
import urllib.request
# ── 路径 ───────────────────────────────────────────────────────────────────
_SCRIPT_DIR = os.path.dirname(__file__)
_DATA_DIR = os.path.join(_SCRIPT_DIR, '..', 'data')
_CFCD_PATH = os.path.join(_DATA_DIR, 'cfcd6.json')
_BRANDS_PATH = os.path.join(
_SCRIPT_DIR, '..', '..', '..',
'mediwise-health-suite', # 兼容独立部署
'diet-tracker', 'data', 'cn_brands.json',
)
# 同目录下也看一眼
_BRANDS_PATH2 = os.path.join(_DATA_DIR, 'cn_brands.json')
# ── USDA API ────────────────────────────────────────────────────────────────
USDA_SEARCH_URL = 'https://api.nal.usda.gov/fdc/v1/foods/search'
# 支持的 dataType:Foundation 和 SR Legacy 提供每 100g 数据
USDA_DATA_TYPES = 'Foundation,SR%20Legacy,Survey%20(FNDDS)'
# ── 营养素 ID(USDA) ────────────────────────────────────────────────────────
_USDA_NUTRIENT_IDS = {
1008: 'kcal',
1003: 'protein',
1004: 'fat',
1005: 'carbs',
1079: 'fiber',
1253: 'cholesterol',
1087: 'Ca',
1089: 'Fe',
1162: 'vitC',
}
# ── 内部缓存 ────────────────────────────────────────────────────────────────
_cfcd_cache: list[dict] | None = None
_brands_cache: list[dict] | None = None
# ══════════════════════════════════════════════════════════════════════════════
# 数据加载
# ══════════════════════════════════════════════════════════════════════════════
def _load_cfcd() -> list[dict]:
global _cfcd_cache
if _cfcd_cache is None:
if not os.path.exists(_CFCD_PATH):
_cfcd_cache = []
else:
with open(_CFCD_PATH, encoding='utf-8') as f:
_cfcd_cache = json.load(f)
return _cfcd_cache
def _load_brands() -> list[dict]:
"""尝试加载 cn_brands.json(由 parse_brands.py 生成)。"""
global _brands_cache
if _brands_cache is not None:
return _brands_cache
for path in (_BRANDS_PATH2, _BRANDS_PATH):
if os.path.exists(path):
with open(path, encoding='utf-8') as f:
_brands_cache = json.load(f)
return _brands_cache
_brands_cache = []
return _brands_cache
# ══════════════════════════════════════════════════════════════════════════════
# 搜索工具
# ══════════════════════════════════════════════════════════════════════════════
def _normalize(text: str) -> str:
"""转小写、去空格,用于模糊匹配。"""
return unicodedata.normalize('NFKC', text).lower().replace(' ', '')
def _score(query: str, food: dict) -> int:
"""返回匹配分数(越高越好,0 = 不匹配)。"""
q = _normalize(query)
name = _normalize(food.get('name', ''))
brand = _normalize(food.get('brand', ''))
aliases = [_normalize(a) for a in food.get('aliases', [])]
all_names = [name] + aliases
# 完全匹配名称
if q in all_names:
return 100
# 名称以查询开头
if any(n.startswith(q) for n in all_names):
return 80
# 名称包含查询
if any(q in n for n in all_names):
return 60
# 查询包含在名称中
if any(n in q for n in all_names if len(n) >= 2):
return 40
# 品牌名匹配(返回该品牌的所有产品)
if brand and (q in brand or brand in q):
return 30
# 中文字符部分重叠匹配(≥2字公共前缀,处理"鸡胸肉"↔"鸡胸脯肉"等变体)
if len(q) >= 2:
for n in all_names:
prefix_len = 0
for a, b in zip(q, n):
if a == b:
prefix_len += 1
else:
break
if prefix_len >= 2:
return 20
return 0
def _search_local(query: str, foods: list[dict], limit: int = 5) -> list[dict]:
scored = [(f, _score(query, f)) for f in foods]
scored = [(f, s) for f, s in scored if s > 0]
scored.sort(key=lambda x: -x[1])
return [f for f, _ in scored[:limit]]
# ══════════════════════════════════════════════════════════════════════════════
# CFCD 查询
# ══════════════════════════════════════════════════════════════════════════════
def search_cfcd(query: str, limit: int = 5) -> list[dict]:
"""在《中国食物成分表第6版》中搜索。返回标准化结果列表。"""
foods = _load_cfcd()
hits = _search_local(query, foods, limit)
return [_fmt_cfcd(h) for h in hits]
def _fmt_cfcd(item: dict) -> dict:
return {
'name': item.get('name', ''),
'name_en': item.get('name_en'),
'category': item.get('category', ''),
'subcategory': item.get('subcategory', ''),
'per': '100g',
'edible_pct': item.get('edible_pct'),
'kcal': item.get('kcal'),
'protein': item.get('protein'),
'fat': item.get('fat'),
'carbs': item.get('carbs'),
'fiber': item.get('fiber'),
'water': item.get('water'),
'cholesterol': item.get('cholesterol'),
'Ca': item.get('Ca'),
'Fe': item.get('Fe'),
'vitC': item.get('vitC'),
'source': 'cfcd6',
'source_name': '中国食物成分表第6版(中国疾控中心)',
'source_url': 'https://github.com/Sanotsu/china-food-composition-data',
}
# ══════════════════════════════════════════════════════════════════════════════
# cn-brands 查询
# ══════════════════════════════════════════════════════════════════════════════
def search_brands(query: str, limit: int = 5) -> list[dict]:
"""在中国外食/品牌食品库中搜索。"""
foods = _load_brands()
hits = _search_local(query, foods, limit)
return [_fmt_brand(h) for h in hits]
def _fmt_brand(item: dict) -> dict:
return {
'name': item.get('name', ''),
'brand': item.get('brand', ''),
'category': item.get('category', ''),
'per': item.get('per', '份'),
'serving_desc': item.get('serving_desc', ''),
'kcal': item.get('kcal'),
'protein': item.get('protein'),
'fat': item.get('fat'),
'carbs': item.get('carbs'),
'fiber': item.get('fiber'),
'note': item.get('note', ''),
'source': 'cn_brands',
'source_name': '中国品牌/外食数据库(H1an1/health-coach)',
'source_url': 'https://github.com/H1an1/health-coach',
}
# ══════════════════════════════════════════════════════════════════════════════
# USDA FoodData Central 查询
# ══════════════════════════════════════════════════════════════════════════════
def _get_usda_key() -> str | None:
"""从环境变量或 config 文件读取 USDA API key。"""
key = os.environ.get('USDA_API_KEY', '').strip()
if key:
return key
# 尝试读取项目 config
try:
sys.path.insert(0, _SCRIPT_DIR)
import config as _cfg
return getattr(_cfg, 'USDA_API_KEY', None) or None
except Exception:
return None
def search_usda(query: str, limit: int = 5) -> list[dict]:
"""从 USDA FoodData Central 查询。需要 USDA_API_KEY 环境变量。"""
key = _get_usda_key()
if not key:
return [{'error': '未配置 USDA_API_KEY,请在环境变量或 config.py 中设置'}]
encoded = urllib.parse.quote(query)
url = (
f'{USDA_SEARCH_URL}'
f'?query={encoded}'
f'&api_key={key}'
f'&pageSize={limit}'
f'&dataType={USDA_DATA_TYPES}'
)
try:
req = urllib.request.Request(url, headers={'User-Agent': 'mediwise-health/1.0'})
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read())
except Exception as e:
return [{'error': f'USDA API 请求失败: {e}'}]
results = []
for food in data.get('foods', [])[:limit]:
nutrients = {n['nutrientId']: n['value'] for n in food.get('foodNutrients', [])}
results.append({
'name': food.get('description', ''),
'name_en': food.get('description', ''),
'category': food.get('foodCategory', ''),
'brand': food.get('brandOwner', ''),
'per': '100g',
'kcal': nutrients.get(1008),
'protein': nutrients.get(1003),
'fat': nutrients.get(1004),
'carbs': nutrients.get(1005),
'fiber': nutrients.get(1079),
'cholesterol': nutrients.get(1253),
'Ca': nutrients.get(1087),
'Fe': nutrients.get(1089),
'vitC': nutrients.get(1162),
'fdc_id': food.get('fdcId'),
'data_type': food.get('dataType'),
'source': 'usda',
'source_name': 'USDA FoodData Central(美国农业部)',
'source_url': 'https://fdc.nal.usda.gov/',
})
return results
# ══════════════════════════════════════════════════════════════════════════════
# 统一入口:三层查询
# ══════════════════════════════════════════════════════════════════════════════
def search(
query: str,
limit: int = 5,
source: str = 'auto',
include_brands: bool = True,
) -> dict:
"""
统一食物查询接口。
source:
'auto' — 按优先级:CFCD → cn-brands → USDA
'cfcd' — 仅查《中国食物成分表》
'brands' — 仅查外食/品牌库
'usda' — 仅查 USDA
'all' — 所有来源合并返回
"""
query = query.strip()
if not query:
return {'status': 'error', 'message': '查询词不能为空'}
if source == 'cfcd':
return {'status': 'ok', 'query': query, 'results': search_cfcd(query, limit), 'source': 'cfcd'}
if source == 'brands':
return {'status': 'ok', 'query': query, 'results': search_brands(query, limit), 'source': 'brands'}
if source == 'usda':
return {'status': 'ok', 'query': query, 'results': search_usda(query, limit), 'source': 'usda'}
if source == 'all':
cfcd_r = search_cfcd(query, limit)
brand_r = search_brands(query, limit) if include_brands else []
usda_r = search_usda(query, limit)
return {
'status': 'ok',
'query': query,
'cfcd_results': cfcd_r,
'brand_results': brand_r,
'usda_results': usda_r,
}
# auto 模式:CFCD 优先,未命中再查 brands,再查 USDA
cfcd_r = search_cfcd(query, limit)
if cfcd_r:
return {'status': 'ok', 'query': query, 'results': cfcd_r, 'source': 'cfcd'}
if include_brands:
brand_r = search_brands(query, limit)
if brand_r:
return {'status': 'ok', 'query': query, 'results': brand_r, 'source': 'cn_brands'}
usda_r = search_usda(query, limit)
if usda_r and 'error' not in usda_r[0]:
return {'status': 'ok', 'query': query, 'results': usda_r, 'source': 'usda'}
return {
'status': 'not_found',
'query': query,
'message': f'未找到"{query}"的营养数据,建议手动输入或换个关键词',
'results': [],
}
def get_by_name(name: str) -> dict | None:
"""精确匹配食物名称,返回单条结果(用于饮食录入自动填充)。"""
# CFCD 精确匹配
for food in _load_cfcd():
if food.get('name') == name:
return _fmt_cfcd(food)
# brands 精确匹配
for food in _load_brands():
if food.get('name') == name:
return _fmt_brand(food)
return None
# ══════════════════════════════════════════════════════════════════════════════
# 数据库概况
# ══════════════════════════════════════════════════════════════════════════════
def db_stats() -> dict:
cfcd = _load_cfcd()
brands = _load_brands()
categories: dict[str, int] = {}
for f in cfcd:
cat = f.get('category', '其他')
categories[cat] = categories.get(cat, 0) + 1
return {
'cfcd_total': len(cfcd),
'cfcd_with_kcal': sum(1 for f in cfcd if f.get('kcal') is not None),
'cfcd_source': '《中国食物成分表标准版第6版》中国疾病预防控制中心营养与健康所',
'cfcd_json_repo': 'https://github.com/Sanotsu/china-food-composition-data',
'brands_total': len(brands),
'brands_source': '中国品牌/外食数据(产品包装、品牌官方信息、薄荷健康等)',
'brands_repo': 'https://github.com/H1an1/health-coach',
'usda_source': 'USDA FoodData Central https://fdc.nal.usda.gov/',
'usda_available': bool(_get_usda_key()),
'cfcd_categories': categories,
}
# ══════════════════════════════════════════════════════════════════════════════
# CLI
# ══════════════════════════════════════════════════════════════════════════════
def _output(data: dict) -> None:
# 尝试导入项目通用输出函数,否则直接 print
try:
sys.path.insert(0, _SCRIPT_DIR)
from health_db import output_json
output_json(data)
except Exception:
print(json.dumps(data, ensure_ascii=False, indent=2))
def main():
parser = argparse.ArgumentParser(description='食物营养查询')
sub = parser.add_subparsers(dest='command', required=True)
p = sub.add_parser('search', help='搜索食物')
p.add_argument('--query', '-q', required=True, help='食物名称(中文或英文)')
p.add_argument('--limit', type=int, default=5)
p.add_argument('--source', default='auto',
choices=['auto', 'cfcd', 'brands', 'usda', 'all'],
help='数据来源(默认 auto 按优先级查询)')
p.add_argument('--no-brands', action='store_true', help='跳过品牌/外食数据库')
p.add_argument('--owner-id', default=None) # accepted but unused (multi-tenant injection)
stats_p = sub.add_parser('stats', help='查看数据库概况')
stats_p.add_argument('--owner-id', default=None)
args = parser.parse_args()
if args.command == 'search':
result = search(
args.query,
limit=args.limit,
source=args.source,
include_brands=not args.no_brands,
)
_output(result)
elif args.command == 'stats':
_output(db_stats())
if __name__ == '__main__':
main()
"""营养分析、热量趋势。"""
from __future__ import annotations
import argparse
import sys
import os
from datetime import datetime, timedelta
# Unified path setup
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'shared'))
from path_setup import setup_mediwise_path
setup_mediwise_path()
from health_db import ensure_db, get_medical_connection, get_lifestyle_connection, rows_to_list, output_json, verify_member_ownership
from metric_utils import get_member_or_error
def _get_member(member_id, owner_id=None):
medical_conn = get_medical_connection()
try:
m = get_member_or_error(medical_conn, member_id)
if not m:
return None
if not verify_member_ownership(medical_conn, member_id, owner_id):
return None
return m
finally:
medical_conn.close()
def weekly_summary(args):
"""一周营养趋势(每日热量、平均三大营养素)。"""
ensure_db()
m = _get_member(args.member_id, args.owner_id)
if not m:
output_json({"status": "error", "message": f"未找到成员或无权访问: {args.member_id}"})
return
if args.end_date:
end = datetime.strptime(args.end_date, "%Y-%m-%d").date()
else:
end = datetime.now().date()
start = end - timedelta(days=6)
lifestyle_conn = get_lifestyle_connection()
try:
rows = lifestyle_conn.execute(
"""SELECT meal_date,
SUM(total_calories) as calories,
SUM(total_protein) as protein,
SUM(total_fat) as fat,
SUM(total_carbs) as carbs,
SUM(total_fiber) as fiber
FROM diet_records
WHERE member_id=? AND meal_date>=? AND meal_date<=? AND is_deleted=0
GROUP BY meal_date
ORDER BY meal_date""",
(args.member_id, start.isoformat(), end.isoformat())
).fetchall()
finally:
lifestyle_conn.close()
daily = rows_to_list(rows)
# Compute weekly averages
days_with_data = len(daily)
if days_with_data > 0:
avg = {
"calories": round(sum(d["calories"] or 0 for d in daily) / days_with_data, 1),
"protein": round(sum(d["protein"] or 0 for d in daily) / days_with_data, 1),
"fat": round(sum(d["fat"] or 0 for d in daily) / days_with_data, 1),
"carbs": round(sum(d["carbs"] or 0 for d in daily) / days_with_data, 1),
"fiber": round(sum(d["fiber"] or 0 for d in daily) / days_with_data, 1),
}
else:
avg = {"calories": 0, "protein": 0, "fat": 0, "carbs": 0, "fiber": 0}
output_json({
"status": "ok",
"member_name": m["name"],
"period": {"start": start.isoformat(), "end": end.isoformat()},
"days_with_data": days_with_data,
"daily": daily,
"average": avg,
})
def calorie_trend(args):
"""热量趋势分析(N 天每日总热量)。"""
ensure_db()
m = _get_member(args.member_id, args.owner_id)
if not m:
output_json({"status": "error", "message": f"未找到成员或无权访问: {args.member_id}"})
return
days = args.days or 7
end = datetime.now().date()
start = end - timedelta(days=days - 1)
lifestyle_conn = get_lifestyle_connection()
try:
rows = lifestyle_conn.execute(
"""SELECT meal_date, SUM(total_calories) as calories
FROM diet_records
WHERE member_id=? AND meal_date>=? AND meal_date<=? AND is_deleted=0
GROUP BY meal_date
ORDER BY meal_date""",
(args.member_id, start.isoformat(), end.isoformat())
).fetchall()
finally:
lifestyle_conn.close()
daily = rows_to_list(rows)
# Fill in missing days with 0
date_map = {d["meal_date"]: d["calories"] or 0 for d in daily}
trend = []
current = start
while current <= end:
ds = current.isoformat()
trend.append({"date": ds, "calories": date_map.get(ds, 0)})
current += timedelta(days=1)
total = sum(d["calories"] for d in trend)
avg = round(total / days, 1) if days > 0 else 0
output_json({
"status": "ok",
"member_name": m["name"],
"days": days,
"period": {"start": start.isoformat(), "end": end.isoformat()},
"trend": trend,
"total_calories": total,
"average_daily": avg,
})
def nutrition_balance(args):
"""三大营养素比例分析。"""
ensure_db()
m = _get_member(args.member_id, args.owner_id)
if not m:
output_json({"status": "error", "message": f"未找到成员或无权访问: {args.member_id}"})
return
days = args.days or 7
end = datetime.now().date()
start = end - timedelta(days=days - 1)
lifestyle_conn = get_lifestyle_connection()
try:
row = lifestyle_conn.execute(
"""SELECT SUM(total_calories) as calories,
SUM(total_protein) as protein,
SUM(total_fat) as fat,
SUM(total_carbs) as carbs,
SUM(total_fiber) as fiber
FROM diet_records
WHERE member_id=? AND meal_date>=? AND meal_date<=? AND is_deleted=0""",
(args.member_id, start.isoformat(), end.isoformat())
).fetchone()
finally:
lifestyle_conn.close()
protein = (row["protein"] or 0) if row else 0
fat = (row["fat"] or 0) if row else 0
carbs = (row["carbs"] or 0) if row else 0
fiber = (row["fiber"] or 0) if row else 0
calories = (row["calories"] or 0) if row else 0
total_macro_g = protein + fat + carbs
ratio = {}
if total_macro_g > 0:
ratio = {
"protein_pct": round(protein / total_macro_g * 100, 1),
"fat_pct": round(fat / total_macro_g * 100, 1),
"carbs_pct": round(carbs / total_macro_g * 100, 1),
}
# Reference: Chinese Dietary Guidelines recommendation
# Protein 10-15%, Fat 20-30%, Carbs 50-65%
assessment = []
if ratio:
if ratio["protein_pct"] < 10:
assessment.append("蛋白质摄入偏低,建议增加鱼肉蛋奶豆类")
elif ratio["protein_pct"] > 20:
assessment.append("蛋白质比例偏高")
if ratio["fat_pct"] > 35:
assessment.append("脂肪摄入偏高,建议减少油炸食物")
elif ratio["fat_pct"] < 15:
assessment.append("脂肪摄入偏低")
if ratio["carbs_pct"] > 70:
assessment.append("碳水摄入偏高,建议增加蔬菜蛋白质比例")
elif ratio["carbs_pct"] < 40:
assessment.append("碳水摄入偏低")
output_json({
"status": "ok",
"member_name": m["name"],
"days": days,
"period": {"start": start.isoformat(), "end": end.isoformat()},
"totals": {
"calories": round(calories, 1),
"protein": round(protein, 1),
"fat": round(fat, 1),
"carbs": round(carbs, 1),
"fiber": round(fiber, 1),
},
"daily_average": {
"calories": round(calories / days, 1) if days > 0 else 0,
"protein": round(protein / days, 1) if days > 0 else 0,
"fat": round(fat / days, 1) if days > 0 else 0,
"carbs": round(carbs / days, 1) if days > 0 else 0,
"fiber": round(fiber / days, 1) if days > 0 else 0,
},
"macro_ratio": ratio,
"assessment": assessment,
})
def main():
parser = argparse.ArgumentParser(description="营养分析")
sub = parser.add_subparsers(dest="command", required=True)
p_ws = sub.add_parser("weekly-summary")
p_ws.add_argument("--member-id", required=True)
p_ws.add_argument("--end-date", default=None, help="统计截止日期 YYYY-MM-DD,默认今天")
p_ws.add_argument("--owner-id", default=None)
p_ct = sub.add_parser("calorie-trend")
p_ct.add_argument("--member-id", required=True)
p_ct.add_argument("--days", type=int, default=7, help="统计天数,默认 7")
p_ct.add_argument("--owner-id", default=None)
p_nb = sub.add_parser("nutrition-balance")
p_nb.add_argument("--member-id", required=True)
p_nb.add_argument("--days", type=int, default=7, help="统计天数,默认 7")
p_nb.add_argument("--owner-id", default=None)
args = parser.parse_args()
commands = {
"weekly-summary": weekly_summary,
"calorie-trend": calorie_trend,
"nutrition-balance": nutrition_balance,
}
commands[args.command](args)
if __name__ == "__main__":
main()
健康管理 Agent 配置指南
为什么需要独立的健康管理 Agent?
1. 上下文隔离 - 健康数据与日常工作/聊天分开,避免混淆 2. 隐私保护 - 敏感健康信息不会泄露到其他对话 3. 专注体验 - 专门的健康助手,更专业的交互 4. 独立工作区 - 健康档案、Skills 和配置独立管理
快速开始
方法 1:使用向导(推荐)
openclaw agents add health向导会引导你完成配置。
方法 2:手动配置
编辑 ~/.openclaw/openclaw.json:
{
agents: {
list: [
{
id: "main",
name: "Main Assistant",
workspace: "~/.openclaw/workspace",
default: true,
},
{
id: "health",
name: "Health Manager",
workspace: "~/.openclaw/workspace-health",
agentDir: "~/.openclaw/agents/health/agent",
identity: {
name: "健康助手",
description: "专注于家庭健康管理的 AI 助手"
},
model: "anthropic/claude-sonnet-4-5",
sandbox: {
mode: "all", // 隔离健康工作区,不与其他 agent 共享文件系统
scope: "agent",
},
tools: {
// 最小权限配置(推荐起点,见下方"权限说明")
allow: ["exec", "read", "write"],
deny: ["browser", "sessions_list", "sessions_history"],
},
},
],
},
// ... bindings 见下方方案 A/B/C
}权限说明
下表说明每个工具的用途和风险等级,请按需开启:
| 工具 | 是否必需 | 用途 | 风险说明 |
|---|---|---|---|
exec | 必需 | 运行健康管理 Python 脚本 | Agent 可执行脚本命令;sandbox 模式可限制影响范围 |
read | 必需 | 读取导出报告、化验单图片 | 限于 workspace 目录内 |
write | 推荐 | 保存就医摘要、导出报告 | 限于 workspace 目录内 |
sessions_list | 可选 | health_memory 功能:检索历史会话 | 可访问该 agent 的历史会话列表 |
sessions_history | 可选 | health_memory 功能:读取历史会话内容 | 可读取该 agent 历史对话全文 |
推荐做法:从最小权限(exec + read + write)开始,只在需要 health_memory 功能时才添加 sessions_list 和 sessions_history。
推荐配置方案
方案 A:家庭健康群组(推荐)
创建专门的群组用于健康管理(支持 QQ、飞书、企业微信、钉钉、WhatsApp、Telegram 等):
{
agents: {
list: [
{
id: "health",
name: "Family Health",
workspace: "~/.openclaw/workspace-health",
identity: { name: "家庭健康助手" },
groupChat: {
mentionPatterns: ["@health", "@健康", "@健康助手"],
},
},
],
},
bindings: [
// QQ 群组示例
{
agentId: "health",
match: {
channel: "qq",
peer: { kind: "group", id: "123456789" },
},
},
// 或飞书群组
{
agentId: "health",
match: {
channel: "feishu",
peer: { kind: "group", id: "oc_xxx" },
},
},
// 或企业微信群组
{
agentId: "health",
match: {
channel: "wecom",
peer: { kind: "group", id: "wrXXXXXXXX" },
},
},
],
}优点:家人都能访问、上下文完全隔离、可设置提及模式
方案 B:个人健康私信
将特定联系人的私信路由到健康 agent:
{
bindings: [
// QQ 私信示例
{
agentId: "health",
match: {
channel: "qq",
peer: { kind: "dm", id: "987654321" },
},
},
// 或飞书私信
{
agentId: "health",
match: {
channel: "feishu",
peer: { kind: "dm", id: "ou_xxx" },
},
},
],
}优点:完全私密、一对一交互
方案 C:多渠道隔离
使用不同渠道分离日常和健康(例如:QQ 用于日常,飞书用于健康管理):
{
agents: {
list: [
{
id: "main",
workspace: "~/.openclaw/workspace",
model: "anthropic/claude-sonnet-4-5",
},
{
id: "health",
workspace: "~/.openclaw/workspace-health",
model: "anthropic/claude-opus-4-5",
},
],
},
bindings: [
{ agentId: "main", match: { channel: "qq" } },
{ agentId: "health", match: { channel: "feishu" } },
],
}优点:渠道级隔离、可为健康管理使用更强大的模型
安装 MediWise Skills
推荐:直接 git clone 到正确路径(最稳妥)
mkdir -p ~/.openclaw/workspace-health/skills
git clone https://github.com/JuneYaooo/mediwise-health-suite.git \
~/.openclaw/workspace-health/skills/mediwise-health-suite或使用 ClawdHub(务必先 cd 进工作区目录):
# 先进入 agent 工作区,再安装
cd ~/.openclaw/workspace-health
clawdhub install JuneYaooo/mediwise-health-suite为什么要先 cd?
clawhub install会把文件装到当前目录的skills/下。如果在项目根目录运行,
skill 会被放到插件根目录之外,触发 OpenClaw 的 "escapes plugin root" 沙箱保护,
导致 SKILL.md 无法加载,脚本无法被 agent 调用。
安装后验证路径:
bash ~/.openclaw/workspace-health/skills/mediwise-health-suite/install-check.sh配置视觉模型(图片/PDF 识别必填)
化验单图片、体检报告等识别功能需要配置外部多模态视觉模型:
cd ~/.openclaw/workspace-health/skills/mediwise-health-suite
cp .env.example .env
# 编辑 .env,填入视觉模型 API Key或通过 setup.py 交互配置:
cd ~/.openclaw/workspace-health/skills/mediwise-health-suite/mediwise-health-tracker/scripts
python3 setup.py set-vision \
--provider siliconflow \
--model Qwen/Qwen2.5-VL-72B-Instruct \
--api-key sk-xxx \
--base-url https://api.siliconflow.cn/v1
python3 setup.py test-vision详细配置方案(含 Gemini、GPT-4o 等选项)见 .env.example 或 INSTALLATION.md。
验证配置
# 列出所有 agents
openclaw agents list --bindings
# 测试健康 agent
openclaw chat --agent health "帮我添加一个家庭成员"数据隔离
系统提供两个层级的数据隔离:
Agent 级隔离
每个 agent 有独立的:
- 工作区:
~/.openclaw/workspace-health - 会话存储:
~/.openclaw/agents/health/sessions - 认证配置:
~/.openclaw/agents/health/agent/auth-profiles.json - 数据库:
health.db存储在工作区(新版拆分为medical.db/lifestyle.db)
用户级隔离(群聊场景)
在家庭群组中,系统通过 owner_id(发送者的平台 ID)自动隔离不同用户的数据:
家庭健康群(QQ 群 123456789)
├── 张三(QQ: 111)→ owner_id="qq_111"
│ ├── 自己的健康档案
│ ├── 爸爸的健康档案
│ └── 妈妈的健康档案
│
├── 李四(QQ: 222)→ owner_id="qq_222"
│ ├── 自己的健康档案
│ └── 老婆的健康档案
│
└── 张三和李四的数据完全隔离,互不可见工作原理:
- 群聊中每条消息的发送者 ID 由平台自动提供
- 路由层(
index.js)自动将发送者 ID 作为owner_id传给脚本 - 所有查询和写入操作都按
owner_id过滤 - 无需用户手动指定,全程自动
安全建议
1. 启用沙箱:sandbox.mode: "all" 隔离健康数据 2. 最小权限:从 exec + read + write 开始,按需添加其他权限(见上方"权限说明"表格) 3. 设置提及模式:避免在群组中误触发 4. 定期备份:备份 ~/.openclaw/workspace-health/medical.db 与 ~/.openclaw/workspace-health/lifestyle.db 5. 可选功能谨慎开启:
- USDA 食物库:设置
USDA_API_KEY后才会请求api.nal.usda.gov,否则默认离线 - 向量搜索:需手动执行
setup.py set-embedding启用,默认关闭 - 后端 API:需手动执行
setup.py set-backend启用,默认关闭
使用示例
在健康群组中(QQ、飞书、企微、钉钉等):
用户: @健康 帮我添加一个家庭成员,叫张三,是我爸爸,65岁
助手: 好的,我来帮您添加...
用户: @健康 记录今天血压 130/85,心率 72
助手: 已为您记录今天的健康指标...
用户: @健康 我准备去看医生,帮我整理一下最近的情况
助手: 好的,我先为您生成一份就医前摘要...总结
使用独立的健康管理 agent 可以:
- ✅ 完全隔离健康数据和日常对话
- ✅ 提供更专业的健康管理体验
- ✅ 保护隐私,避免数据泄露
- ✅ 灵活配置权限和工具
- ✅ 支持多人共享(家庭群组)
推荐使用方案 A(家庭健康群组),既能保证隔离,又方便家人共同使用。支持 QQ、飞书、企业微信、钉钉等常用渠道。
MediWise 健康管理总览
这套健康管理能力的目标,不是做一个只会记数据的工具,而是做一个最懂用户健康背景、最适合陪伴式使用的健康助手。
它把日常健康记录、病程追踪、风险提醒、健康建议、就医准备、图片/PDF 识别、生活方式管理整合在同一个体系里,让用户从“随手记录”一路走到“准备就医时快速整理重点”。
一句话定位
一个面向个人和家庭的健康管理助手:平时能记、能查、能提醒;准备看医生时还能先帮你整理一版就医摘要,并按需导出成图片或 PDF。
核心能力地图
1. 家庭健康档案
以 mediwise-health-tracker 为核心,负责管理整个家庭的健康信息,包括:
- 成员信息:姓名、关系、性别、出生日期、血型
- 基础病史:既往史、过敏史、联系方式、紧急联系人
- 病程记录:门诊、住院、急诊、症状、诊断、检验、影像
- 用药信息:当前在用药、历史用药、停药原因
- 日常指标:血压、血糖、心率、血氧、体温、体重等
- 查询能力:健康摘要、时间线、在用药、全家概览
医疗健康数据默认存储在 medical.db,生活方式数据(饮食、体重、运动、可穿戴)存储在 lifestyle.db,两库通过 member_id 关联。
mediwise-health-tracker/SKILL.mdmediwise-health-tracker/scripts/member.pymediwise-health-tracker/scripts/medical_record.pymediwise-health-tracker/scripts/query.py
2. 日常健康记录与图片/PDF 录入
系统支持多种录入方式:
- 纯文本快速录入:适合“血压 130/85,心率 72”这种简短描述
- 复杂文本智能提取:适合一整段病情、就诊、检验、用药描述
- 图片 / PDF 录入:适合化验单、处方、体检报告、门诊单
- 多附件批量处理:支持用户连续发多张图,统一识别和确认
这意味着用户不必自己手动拆字段,系统可以先尽量帮他结构化,再进入确认和保存流程。
对应入口:
mediwise-health-tracker/references/intake-query-vision.mdmediwise-health-tracker/scripts/quick_entry.pymediwise-health-tracker/scripts/smart_intake.py
3. 查询、总结与自然语言整理
系统不是简单把数据库结果吐出来,而是强调:
- 不直接给 JSON
- 要把数字变成趋势
- 要把记录变成时间线
- 要把散乱信息变成用户看得懂、医生也能快速理解的摘要
典型输出包括:
- 当前健康摘要
- 最近病程变化
- 最近指标变化
- 当前在用药清单
- 全家健康概况
对应入口:
mediwise-health-tracker/scripts/query.pymediwise-health-tracker/references/intake-query-vision.md
4. 主动健康监测与提醒
这部分由 health-monitor 和 health_advisor 共同完成,负责:
- 识别近期异常指标
- 判断某类指标是否长期没测
- 判断是否存在复查逾期
- 结合提醒系统生成主动提示
- 形成每日健康简报
目标不是只在用户问时才回答,而是基于已有数据做轻度主动提醒。
对应入口:
health-monitor/SKILL.mdhealth-monitor/scripts/check.pyhealth-monitor/scripts/threshold.pymediwise-health-tracker/scripts/health_advisor.pymediwise-health-tracker/scripts/briefing_report.py
5. 饮食、体重、运动与可穿戴数据
为了把健康管理做成闭环,系统还接入了生活方式相关模块:
- 饮食记录:每餐、食物条目、热量和营养趋势
- 体重管理:目标、热量收支、BMI/BMR/TDEE、身体指标
- 运动记录:运动项目和消耗
- 可穿戴同步:手环手表数据同步入库
这部分让系统不仅知道”生病了什么情况”,也知道”平时生活方式是什么状态”。
生活方式数据默认存储在 lifestyle.db,不会与医疗主线表混在同一个库中。
对应入口:
diet-tracker/SKILL.mdweight-manager/SKILL.mdwearable-sync/SKILL.md
6. 家庭共用与数据隔离
系统支持一家人在同一个群聊中共同使用健康助手,同时保证每个人的数据完全隔离。
隔离机制
系统通过 owner_id(发送者的平台用户 ID,如 QQ 号)自动隔离数据:
- 自动识别:群聊中每条消息的发送者会被自动识别,无需手动指定
- 数据隔离:每个用户只能查看和管理自己添加的成员和记录
- 独立档案:用户 A 添加的”爸爸”和用户 B 添加的”爸爸”是两份独立档案,互不影响
典型使用场景
场景 1:夫妻在家庭群里各自记录
张三(QQ: 111): @健康 记录今天血压 130/85
助手:已为您记录血压 130/85
李四(QQ: 222): @健康 记录今天血压 118/75
助手:已为您记录血压 118/75
张三(QQ: 111): @健康 帮我看最近的血压趋势
助手:[只显示张三自己的血压记录]场景 2:子女为父母代管健康
张三(QQ: 111): @健康 添加家庭成员”妈妈”,65岁,有高血压
助手:已添加家庭成员”妈妈”
张三(QQ: 111): @健康 帮妈妈记录今天血压 145/92
助手:已为”妈妈”记录血压 145/92。收缩压偏高,建议关注。
张三(QQ: 111): @健康 帮妈妈整理一份就医摘要
助手:好的,我为”妈妈”整理就医前摘要...场景 3:家庭群中多人各自管理
用户 A 的数据空间 用户 B 的数据空间
├── 自己 ├── 自己
├── 爸爸 ├── 妈妈
└── 孩子 └── 孩子(独立档案)
(互相不可见,互不影响)适用场景对比
| 需求 | 推荐方式 |
|---|---|
| 一家人各自记录自己的健康 | 家庭群 — 每人数据自动隔离 |
| 子女帮父母管理健康档案 | 家庭群 — 子女添加父母为”家庭成员” |
| 完全不想让家人知道 | 私信 — 单独和健康助手对话 |
| 共享同一份健康档案 | 同一账号 — 用同一个平台 ID 发消息 |
这套系统最重要的新增价值:就医前摘要
这是目前最像“懂你”的一层能力。
当用户最近准备去看医生时,系统不再只是简单地说“你去医院吧”,而是会:
1. 先让用户用自然语言描述这次不舒服的情况 2. 结合历史记录自动提取重点 3. 汇总最近相关的病情变化 4. 补上相关既往史、过敏史、当前在用药 5. 提示可识别的中高风险药物相互作用 6. 先生成一版短文摘要 7. 再追问用户是否需要整理成图片或 PDF
这让用户在门诊前不需要再自己手忙脚乱地翻聊天记录、化验单和病历,也能让医生在很短时间内快速理解背景。
对应实现:
mediwise-health-tracker/scripts/doctor_visit_report.pymediwise-health-tracker/references/visit-prep.md
当前推荐交互流程
场景 A:平时日常使用
用户会这样说:
- “帮我记一下今天血压 138/88。”
- “我今天开始吃氨氯地平。”
- “帮我看一下最近血糖怎么样。”
- “帮我发个健康简报。”
系统应该:
- 优先快速录入
- 查询时用自然语言总结
- 必要时做异常提醒
场景 B:最近不舒服,但还没去医院
用户会这样说:
- “我最近老是头晕。”
- “我胃不太舒服。”
- “胸口有点闷。”
系统应该:
- 先做危险信号判断
- 结合已有健康档案给出方向性建议
- 建议就医时,顺便提供就医前摘要
场景 C:准备去看医生
用户会这样说:
- “我最近想去看医生,帮我整理一下。”
- “我先描述一下,你帮我总结重点。”
- “帮我做一份给医生看的摘要。”
系统应该:
- 默认先生成一段简短就医摘要
- 然后追问:要不要整理成图片或 PDF
- 用户明确需要时,再导出图片版或 PDF 版
这是目前最推荐、也最有差异化的用户体验之一。
用户问“你可以做什么”时,建议重点提的能力
如果用户只是泛泛地问“你能做什么”,建议优先强调下面这些,而不是一股脑把所有功能都列完:
- 我可以帮你记录和整理健康档案
- 我可以帮你追踪指标、用药、病程和提醒
- 我可以帮你识别化验单、体检报告、处方图片
- 如果你最近准备去看医生,我还可以先帮你整理一版就医摘要;如果你需要,我再帮你导出成图片或 PDF,方便给医生看
这套表达更容易让用户理解系统的价值。
产品风格原则
这套健康管理能力目前已经形成了比较清晰的风格:
1. 记录要轻:尽量减少用户手填负担 2. 总结要清楚:不直接甩结构化原始数据 3. 风险要保守:医疗问题先搜索、先验证、不乱下结论 4. 就医要顺:从“有点不舒服”到“准备看医生”之间有连续支持 5. 展示要实用:短文优先,图片和 PDF 作为增强导出
现在的完整闭环
可以把整个系统理解成下面这条链路:
记录 → 积累 → 查询 → 监测 → 提醒 → 就医准备 → 图片/PDF 导出
这也是这套健康管理系统当前最完整、最有产品感的一条主线。
Installation Guide - 安装指南
中文
路径注意事项(重要)
>
OpenClaw 的沙箱安全机制要求:skill 文件必须位于 agent 工作区(插件根目录)内部,
否则会触发 "escapes plugin root" 保护,SKILL.md 内容无法注入给 agent,脚本也无法被调用。
>
-clawhub install会把 skill 装到执行命令时所在目录的skills/子目录。
- 因此,请务必先 cd 进入 agent 工作区目录,再运行安装命令。- 或者直接用 git clone 指定完整目标路径(见方式 2),最不容易出错。方式 1:通过 ClawdHub 安装(推荐)
必须先进入 agent 工作区目录再安装:
# 先进入你的 OpenClaw agent 工作区(路径以实际配置为准)
cd ~/.openclaw/workspace-health
# 安装命令会将 skill 放到 ./skills/mediwise-health-suite/
clawdhub install JuneYaooo/mediwise-health-suite安装完成后,运行路径检测脚本确认位置正确:
bash ~/.openclaw/workspace-health/skills/mediwise-health-suite/install-check.sh方式 2:手动安装
步骤 1:克隆仓库
# 克隆到 OpenClaw skills 目录
git clone https://github.com/JuneYaooo/mediwise-health-suite.git \
~/.openclaw/skills/mediwise-health-suite
# 或克隆到自定义位置
git clone https://github.com/JuneYaooo/mediwise-health-suite.git \
~/my-skills/mediwise-health-suite步骤 2:安装依赖
cd ~/.openclaw/skills/mediwise-health-suite
# 安装 Python 依赖(如果有)
pip install -r requirements.txt步骤 3:配置多模态视觉模型(图片识别必填)
图片/PDF 识别(化验单、体检报告等)需要配置外部视觉模型,否则图片类功能无法使用。
推荐方式:通过环境变量配置(支持 .env 文件)
复制模板文件并填入你的 API Key:
cd ~/.openclaw/skills/mediwise-health-suite
cp .env.example .env
# 编辑 .env,填入 MEDIWISE_VISION_API_KEY 等变量方案 A(国内推荐):硅基流动 Qwen2.5-VL
# 免费注册(含邀请奖励):https://cloud.siliconflow.cn/i/MOlLXTYM
export MEDIWISE_VISION_PROVIDER=siliconflow
export MEDIWISE_VISION_MODEL=Qwen/Qwen2.5-VL-72B-Instruct
export MEDIWISE_VISION_API_KEY=sk-xxx
export MEDIWISE_VISION_BASE_URL=https://api.siliconflow.cn/v1方案 B(海外推荐):Google Gemini
export MEDIWISE_VISION_PROVIDER=openai
export MEDIWISE_VISION_MODEL=gemini-3.1-pro-preview
export MEDIWISE_VISION_API_KEY=AIzaxxx
export MEDIWISE_VISION_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai方案 C:通过 setup.py 配置(内置预设,只需填 API Key)
cd ~/.openclaw/skills/mediwise-health-suite/mediwise-health-tracker/scripts
# 查看所有内置预设(含默认模型和 Base URL)
python3 setup.py list-vision-providers
# 选择预设后只需填 --provider 和 --api-key,模型和 Base URL 自动填入
python3 setup.py set-vision --provider siliconflow --api-key sk-xxx # 国内
python3 setup.py set-vision --provider gemini --api-key AIza-xxx # 海外
python3 setup.py set-vision --provider ollama --api-key ollama # 本地离线
# 验证配置
python3 setup.py test-vision不配置视觉模型时,文本录入和基础健康记录功能仍可正常使用,只有图片/PDF 识别功能不可用。
步骤 4:验证安装
重启 OpenClaw,然后测试:
"你好,帮我添加一个家庭成员"如果 OpenClaw 响应并询问成员信息,说明安装成功。
方式 3:从源码安装(开发者)
# 克隆仓库
git clone https://github.com/JuneYaooo/mediwise-health-suite.git
cd mediwise-health-suite
# 创建符号链接到 OpenClaw skills 目录
ln -s $(pwd) ~/.openclaw/skills/mediwise-health-suite
# 安装开发依赖
pip install -r requirements.txt配置(可选)
在 OpenClaw 配置文件中添加:
位置: ~/.openclaw/config.json 或项目的 .openclaw/config.json
{
"plugins": {
"mediwise-health-suite": {
"enableDailyBriefing": true,
"reminderCheckInterval": 60000,
"scriptsDir": "~/.openclaw/skills/mediwise-health-suite"
}
}
}数据库初始化
首次使用时,系统会自动创建数据库(默认拆分为医疗与生活方式两库):
~/.openclaw/skills/mediwise-health-suite/data/medical.db
~/.openclaw/skills/mediwise-health-suite/data/lifestyle.db如果需要手动初始化:
cd ~/.openclaw/skills/mediwise-health-suite/mediwise-health-tracker/scripts
python3 setup.py init如果从旧版本升级(单库 health.db),可执行迁移命令:
cd ~/.openclaw/skills/mediwise-health-suite/mediwise-health-tracker/scripts
python3 setup.py migrate-split-db
python3 setup.py migration-status数据备份与迁移(换设备 / 换小龙虾)
如需将数据迁移到新设备或新的 OpenClaw 实例,使用内置的备份/恢复命令:
# 旧环境:打包所有数据库和配置
cd ~/.openclaw/skills/mediwise-health-suite/mediwise-health-tracker/scripts
python3 setup.py backup --output ~/mediwise-backup.tar.gz将生成的 mediwise-backup.tar.gz 文件传到新设备,然后在新环境执行:
# 新环境:安装 skill 后恢复数据(Schema 自动升级)
cd ~/.openclaw/skills/mediwise-health-suite/mediwise-health-tracker/scripts
python3 setup.py restore --input ~/mediwise-backup.tar.gz备份文件包含:medical.db、lifestyle.db、config.json(以及旧版 health.db,如存在)。恢复完成后,Schema 会自动升级到最新版本,无需手动干预。
故障排查
问题 1:Skills 未加载
解决方案:
# 检查 skills 目录
ls ~/.openclaw/skills/mediwise-health-suite
# 重启 OpenClaw
openclaw restart问题 2:Python 脚本执行失败
解决方案:
# 检查 Python 版本
python3 --version # 应该 >= 3.8
# 检查脚本权限
chmod +x ~/.openclaw/skills/mediwise-health-suite/*/scripts/*.py问题 3:数据库权限错误
解决方案:
# 检查数据库目录权限
mkdir -p ~/.openclaw/skills/mediwise-health-suite/data
chmod 755 ~/.openclaw/skills/mediwise-health-suite/data---
English
Important: Install Path
>
OpenClaw's sandbox requires skill files to be located inside the agent workspace
(plugin root directory). Installing outside triggers an "escapes plugin root" error,
which silently prevents SKILL.md from being injected and scripts from being called.
>
-clawhub installplaces the skill in theskills/subdirectory of your current working directory.
- Alwayscdinto your agent workspace first, or usegit clonewith the full target path (Method 2).
Method 1: Install via ClawdHub (Recommended)
You must `cd` into the agent workspace before installing:
# Navigate to your OpenClaw agent workspace first
cd ~/.openclaw/workspace-health
# This installs to ./skills/mediwise-health-suite/
clawdhub install JuneYaooo/mediwise-health-suiteAfter installation, verify the path is correct:
bash ~/.openclaw/workspace-health/skills/mediwise-health-suite/install-check.shMethod 2: Manual Installation
Step 1: Clone Repository
# Clone to OpenClaw skills directory
git clone https://github.com/JuneYaooo/mediwise-health-suite.git \
~/.openclaw/skills/mediwise-health-suite
# Or clone to custom location
git clone https://github.com/JuneYaooo/mediwise-health-suite.git \
~/my-skills/mediwise-health-suiteStep 2: Install Dependencies
cd ~/.openclaw/skills/mediwise-health-suite
# Install Python dependencies (if any)
pip install -r requirements.txtStep 3: Configure Multimodal Vision Model (Required for Image Recognition)
Image/PDF recognition (lab reports, checkup reports, etc.) requires configuring an external vision model. Without this, image-based features will not work.
Recommended: Configure via environment variables (supports .env file)
cd ~/.openclaw/skills/mediwise-health-suite
cp .env.example .env
# Edit .env and fill in MEDIWISE_VISION_API_KEY and related variablesOption A (Recommended for China): SiliconFlow Qwen2.5-VL
# Register free (with referral bonus): https://cloud.siliconflow.cn/i/MOlLXTYM
export MEDIWISE_VISION_PROVIDER=siliconflow
export MEDIWISE_VISION_MODEL=Qwen/Qwen2.5-VL-72B-Instruct
export MEDIWISE_VISION_API_KEY=sk-xxx
export MEDIWISE_VISION_BASE_URL=https://api.siliconflow.cn/v1Option B (Recommended internationally): Google Gemini
export MEDIWISE_VISION_PROVIDER=openai
export MEDIWISE_VISION_MODEL=gemini-3.1-pro-preview
export MEDIWISE_VISION_API_KEY=AIzaxxx
export MEDIWISE_VISION_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openaiOption C: Configure via setup.py (built-in presets — only API Key required)
cd ~/.openclaw/skills/mediwise-health-suite/mediwise-health-tracker/scripts
# List all built-in presets (with default model and Base URL)
python3 setup.py list-vision-providers
# Pick a preset: --model and --base-url are auto-filled
python3 setup.py set-vision --provider siliconflow --api-key sk-xxx # China
python3 setup.py set-vision --provider gemini --api-key AIza-xxx # International
python3 setup.py set-vision --provider ollama --api-key ollama # Fully offline
# Verify configuration
python3 setup.py test-visionWithout a vision model, text-based entry and basic health recording still work. Only image/PDF recognition is unavailable.
Step 4: Verify Installation
Restart OpenClaw, then test:
"Hello, help me add a family member"If OpenClaw responds and asks for member information, installation is successful.
Method 3: Install from Source (Developers)
# Clone repository
git clone https://github.com/JuneYaooo/mediwise-health-suite.git
cd mediwise-health-suite
# Create symbolic link to OpenClaw skills directory
ln -s $(pwd) ~/.openclaw/skills/mediwise-health-suite
# Install development dependencies
pip install -r requirements.txtConfiguration (Optional)
Add to OpenClaw configuration file:
Location: ~/.openclaw/config.json or project's .openclaw/config.json
{
"plugins": {
"mediwise-health-suite": {
"enableDailyBriefing": true,
"reminderCheckInterval": 60000,
"scriptsDir": "~/.openclaw/skills/mediwise-health-suite"
}
}
}Database Initialization
On first use, the system will automatically create databases (split into medical and lifestyle by default):
~/.openclaw/skills/mediwise-health-suite/data/medical.db
~/.openclaw/skills/mediwise-health-suite/data/lifestyle.dbTo manually initialize:
cd ~/.openclaw/skills/mediwise-health-suite/mediwise-health-tracker/scripts
python3 setup.py initIf upgrading from the legacy single database (health.db), run the migration:
cd ~/.openclaw/skills/mediwise-health-suite/mediwise-health-tracker/scripts
python3 setup.py migrate-split-db
python3 setup.py migration-statusData Backup and Migration (New Device / New Instance)
To migrate data to a new device or a new OpenClaw instance, use the built-in backup/restore commands:
# Old environment: pack all databases and config
cd ~/.openclaw/skills/mediwise-health-suite/mediwise-health-tracker/scripts
python3 setup.py backup --output ~/mediwise-backup.tar.gzTransfer mediwise-backup.tar.gz to the new device, then run:
# New environment: install the skill, then restore data (schema auto-upgrades)
cd ~/.openclaw/skills/mediwise-health-suite/mediwise-health-tracker/scripts
python3 setup.py restore --input ~/mediwise-backup.tar.gzThe archive contains medical.db, lifestyle.db, config.json (and the legacy health.db if present). The database schema is automatically upgraded to the latest version after restore — no manual steps needed.
Troubleshooting
Issue 1: Skills Not Loaded
Solution:
# Check skills directory
ls ~/.openclaw/skills/mediwise-health-suite
# Restart OpenClaw
openclaw restartIssue 2: Python Script Execution Failed
Solution:
# Check Python version
python3 --version # Should be >= 3.8
# Check script permissions
chmod +x ~/.openclaw/skills/mediwise-health-suite/*/scripts/*.pyIssue 3: Database Permission Error
Solution:
# Check database directory permissions
mkdir -p ~/.openclaw/skills/mediwise-health-suite/data
chmod 755 ~/.openclaw/skills/mediwise-health-suite/datainterface:
display_name: "健康监测"
short_description: "监测健康指标异常情况,并生成分级告警和趋势变化摘要"
default_prompt: "Use $health-monitor to review health metrics, detect anomalies, and summarize alerts."
{
"name": "@mediwise/health-monitor-skill",
"version": "0.1.0",
"type": "module",
"description": "智能健康监测与告警 OpenClaw Skill",
"author": "mediwise",
"license": "MIT",
"keywords": ["openclaw", "skill", "health", "monitor", "alert", "trend"]
}
"""Alert management CLI for health-monitor.
View, resolve, and manage health monitoring alerts.
"""
from __future__ import annotations
import sys
import os
import json
import argparse
import importlib
# Unified path setup
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
from shared.path_setup import setup_mediwise_path
setup_mediwise_path()
health_db = importlib.import_module("health_db")
def _deny_member(member_id):
health_db.output_json({"status": "error", "message": f"无权访问成员: {member_id}"})
def _deny_alert(alert_id):
health_db.output_json({"status": "error", "message": f"无权访问告警: {alert_id}"})
def _verify_member_access(conn, member_id, owner_id):
if owner_id and not health_db.verify_member_ownership(conn, member_id, owner_id):
_deny_member(member_id)
return False
return True
def cmd_list(args):
"""List active (unresolved) alerts for a member."""
health_db.ensure_db()
conn = health_db.get_connection()
try:
if not _verify_member_access(conn, args.member_id, getattr(args, "owner_id", None)):
return
sql = """SELECT * FROM monitor_alerts
WHERE member_id=? AND is_resolved=0
ORDER BY
CASE level
WHEN 'emergency' THEN 0
WHEN 'urgent' THEN 1
WHEN 'warning' THEN 2
WHEN 'info' THEN 3
END,
created_at DESC"""
params = [args.member_id]
if args.level:
sql = """SELECT * FROM monitor_alerts
WHERE member_id=? AND is_resolved=0 AND level=?
ORDER BY created_at DESC"""
params.append(args.level)
rows = conn.execute(sql, params).fetchall()
alerts = health_db.rows_to_list(rows)
health_db.output_json({
"status": "ok",
"count": len(alerts),
"alerts": alerts,
})
finally:
conn.close()
def cmd_resolve(args):
"""Mark an alert as resolved."""
health_db.ensure_db()
with health_db.transaction() as conn:
row = conn.execute(
"SELECT * FROM monitor_alerts WHERE id=? AND is_resolved=0",
(args.alert_id,)
).fetchone()
if not row:
health_db.output_json({"status": "error", "message": f"未找到未解决告警: {args.alert_id}"})
return
owner_id = getattr(args, "owner_id", None)
if owner_id and not health_db.verify_member_ownership(conn, row["member_id"], owner_id):
_deny_alert(args.alert_id)
return
resolved_at = health_db.now_iso()
conn.execute(
"""UPDATE monitor_alerts
SET is_resolved=1,
status='resolved',
resolved_at=?,
updated_at=?,
resolved_by=?,
resolution_note=?
WHERE id=?""",
(resolved_at, resolved_at, owner_id, getattr(args, "note", None), args.alert_id)
)
if row["last_reminder_id"]:
conn.execute(
"UPDATE reminders SET is_active=0, updated_at=? WHERE id=? AND is_deleted=0",
(resolved_at, row["last_reminder_id"]),
)
health_db.append_audit_event(
conn,
"alert.resolved",
member_id=row["member_id"],
owner_id=owner_id,
record_type="monitor_alert",
record_id=args.alert_id,
payload={
"status": "resolved",
"level": row["level"],
"note_present": bool(getattr(args, "note", None)),
},
)
conn.commit()
health_db.output_json({
"status": "ok",
"message": "告警已标记为已解决",
"alert_id": args.alert_id,
})
def cmd_history(args):
"""Show alert history (including resolved)."""
health_db.ensure_db()
conn = health_db.get_connection()
try:
if not _verify_member_access(conn, args.member_id, getattr(args, "owner_id", None)):
return
limit = int(args.limit) if args.limit else 20
rows = conn.execute(
"""SELECT * FROM monitor_alerts
WHERE member_id=?
ORDER BY created_at DESC LIMIT ?""",
(args.member_id, limit)
).fetchall()
alerts = health_db.rows_to_list(rows)
health_db.output_json({
"status": "ok",
"count": len(alerts),
"alerts": alerts,
})
finally:
conn.close()
def main():
parser = argparse.ArgumentParser(description="健康告警管理")
sub = parser.add_subparsers(dest="command", required=True)
p_list = sub.add_parser("list", help="查看未解决告警")
p_list.add_argument("--member-id", required=True)
p_list.add_argument("--owner-id", default=None)
p_list.add_argument("--level", default=None, choices=["info", "warning", "urgent", "emergency"])
p_resolve = sub.add_parser("resolve", help="标记告警已解决")
p_resolve.add_argument("--alert-id", required=True)
p_resolve.add_argument("--owner-id", default=None)
p_resolve.add_argument("--note", default=None)
p_history = sub.add_parser("history", help="告警历史")
p_history.add_argument("--member-id", required=True)
p_history.add_argument("--owner-id", default=None)
p_history.add_argument("--limit", default="20")
args = parser.parse_args()
commands = {"list": cmd_list, "resolve": cmd_resolve, "history": cmd_history}
commands[args.command](args)
if __name__ == "__main__":
main()
"""Anomaly detection engine for health-monitor.
Checks health_metrics against thresholds and generates alerts.
Supports time-window based checking and 24h deduplication.
"""
from __future__ import annotations
import sys
import os
import argparse
import re
import importlib
import logging
from datetime import datetime, timedelta
# Unified path setup
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
from shared.path_setup import setup_mediwise_path
setup_mediwise_path()
sys.path.insert(0, os.path.dirname(__file__))
health_db = importlib.import_module("health_db")
metric_utils = importlib.import_module("metric_utils")
parse_metric_value = metric_utils.parse_metric_value
extract_numeric_value = metric_utils.extract_numeric_value
METRIC_UNITS = metric_utils.METRIC_UNITS
from threshold import get_thresholds
load_config = importlib.import_module("config").load_config
logger = logging.getLogger(__name__)
def _parse_window(window_str: str) -> timedelta:
"""Parse a time window string like '1h', '24h', '7d' into timedelta."""
match = re.match(r'^(\d+)(h|d|m)$', window_str.strip())
if not match:
return timedelta(hours=1) # default 1h
num = int(match.group(1))
unit = match.group(2)
if unit == 'h':
return timedelta(hours=num)
elif unit == 'd':
return timedelta(days=num)
elif unit == 'm':
return timedelta(minutes=num)
return timedelta(hours=1)
def _has_recent_alert(conn, member_id: str, metric_type: str, level: str,
cooldown_hours: int = 24) -> bool:
"""Check if an alert of the same type+level was created within cooldown period."""
cutoff = (datetime.now() - timedelta(hours=cooldown_hours)).strftime("%Y-%m-%d %H:%M:%S")
row = conn.execute(
"""SELECT 1 FROM monitor_alerts
WHERE member_id=? AND metric_type=? AND level=? AND created_at>=?
LIMIT 1""",
(member_id, metric_type, level, cutoff)
).fetchone()
return row is not None
def _determine_level(value: float, direction: str, levels: dict) -> str | None:
"""Determine the highest alert level triggered.
Checks from emergency → urgent → warning (highest first).
Returns the level name or None if no threshold is breached.
"""
for level in ("emergency", "urgent", "warning"):
threshold = levels.get(level)
if threshold is None:
continue
if direction == "above" and value > threshold:
return level
if direction == "below" and value < threshold:
return level
return None
def _create_alert(conn, member_id: str, metric_type: str, level: str,
title: str, detail: str, metric_value: str,
threshold_value: float):
"""Insert a new alert record."""
alert_id = health_db.generate_id()
now = health_db.now_iso()
owner_id = health_db.get_member_owner_id(conn, member_id)
conn.execute(
"""INSERT INTO monitor_alerts
(id, member_id, metric_type, level, title, detail, metric_value,
threshold_value, status, updated_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(alert_id, member_id, metric_type, level, title, detail,
metric_value, threshold_value, "open", now, now)
)
health_db.append_audit_event(
conn,
"alert.created",
member_id=member_id,
owner_id=owner_id,
record_type="monitor_alert",
record_id=alert_id,
payload={
"metric_type": metric_type,
"level": level,
"status": "open",
},
)
return alert_id
def _create_reminder_for_alert(member_id: str, alert_id: str, level: str, title: str, detail: str):
"""Create a reminder based on alert level."""
if level == "info":
return None
try:
reminder_mod = importlib.import_module("reminder")
priority_map = {
"warning": "normal",
"urgent": "high",
"emergency": "urgent",
}
priority = priority_map.get(level, "normal")
result = reminder_mod.create_reminder(
member_id=member_id,
reminder_type="custom",
title=f"健康告警: {title}",
schedule_type="once",
schedule_value=(datetime.now() + timedelta(minutes=1)).strftime("%Y-%m-%d %H:%M"),
content=detail,
related_record_id=alert_id,
related_record_type="monitor_alert",
priority=priority,
)
return result.get("id") if isinstance(result, dict) else None
except Exception as e:
logger.warning("Failed to create reminder for alert '%s': %s", title, e)
return None
def _check_metric_value(conn, member_id: str, member_name: str,
metric_type: str, value: float, unit: str,
thresholds: dict, cooldown_hours: int,
alerts_generated: list,
reminders_to_create: list):
"""Check a single metric value against thresholds and generate alerts.
This unified function handles both blood pressure sub-metrics and
regular single-value metrics identically.
"""
directions = thresholds.get(metric_type, {})
for direction, levels in directions.items():
level = _determine_level(value, direction, levels)
if level and not _has_recent_alert(conn, member_id, metric_type, level, cooldown_hours):
threshold_val = levels[level]
dir_cn = "高于" if direction == "above" else "低于"
display_unit = METRIC_UNITS.get(metric_type, unit)
title = f"{member_name} {metric_type} {dir_cn}阈值"
detail = f"{metric_type}: {value} {display_unit},{level} 阈值: {threshold_val} {display_unit}"
alert_id = _create_alert(conn, member_id, metric_type, level,
title, detail, str(value), threshold_val)
alerts_generated.append({
"alert_id": alert_id,
"metric_type": metric_type,
"level": level,
"title": title,
"value": value,
"threshold": threshold_val,
})
reminders_to_create.append({
"alert_id": alert_id,
"member_id": member_id,
"level": level,
"title": title,
"detail": detail,
})
def check_member(member_id: str, window: str = "1h") -> dict:
"""Run anomaly detection for a single member.
Args:
member_id: Member to check.
window: Time window string (e.g. "1h", "24h", "7d").
Returns:
Dict with alerts generated.
"""
health_db.ensure_db()
thresholds = get_thresholds(member_id)
# Load cooldown config
cfg = load_config()
cooldown_hours = cfg.get("monitor", {}).get("alert_cooldown_hours", 24)
td = _parse_window(window)
cutoff = (datetime.now() - td).strftime("%Y-%m-%d %H:%M:%S")
conn = health_db.get_connection()
alerts_generated = []
reminders_to_create = []
try:
# Get member name
member = conn.execute(
"SELECT name FROM members WHERE id=? AND is_deleted=0",
(member_id,)
).fetchone()
if not member:
return {"status": "error", "message": f"未找到成员: {member_id}"}
member_name = member["name"]
# Fetch recent metrics within window
metrics = health_db.rows_to_list(conn.execute(
"""SELECT * FROM health_metrics
WHERE member_id=? AND is_deleted=0 AND measured_at>=?
ORDER BY measured_at DESC""",
(member_id, cutoff)
).fetchall())
for metric in metrics:
mt = metric["metric_type"]
raw_value = metric["value"]
parsed = parse_metric_value(raw_value)
# Blood pressure: expand to systolic + diastolic sub-checks
if mt == "blood_pressure":
for sub_key, threshold_key in [("systolic", "blood_pressure_systolic"),
("diastolic", "blood_pressure_diastolic")]:
val = parsed.get(sub_key)
if val is None:
continue
try:
val = float(val)
except (TypeError, ValueError):
continue
_check_metric_value(conn, member_id, member_name,
threshold_key, val, "mmHg",
thresholds, cooldown_hours, alerts_generated,
reminders_to_create)
else:
# Single-value metrics
val = extract_numeric_value(raw_value, mt)
if val is None:
continue
_check_metric_value(conn, member_id, member_name,
mt, val, "",
thresholds, cooldown_hours, alerts_generated,
reminders_to_create)
conn.commit()
finally:
conn.close()
for reminder_payload in reminders_to_create:
reminder_id = _create_reminder_for_alert(
reminder_payload["member_id"],
reminder_payload["alert_id"],
reminder_payload["level"],
reminder_payload["title"],
reminder_payload["detail"],
)
if reminder_id:
with health_db.transaction() as conn:
conn.execute(
"UPDATE monitor_alerts SET last_reminder_id=? WHERE id=?",
(reminder_id, reminder_payload["alert_id"]),
)
conn.commit()
return {
"status": "ok",
"member_id": member_id,
"window": window,
"metrics_checked": len(metrics),
"alerts_generated": len(alerts_generated),
"alerts": alerts_generated,
}
def cmd_run(args):
"""Run check for a member."""
result = check_member(args.member_id, args.window or "1h")
health_db.output_json(result)
def cmd_run_all(args):
"""Run check for all members."""
health_db.ensure_db()
conn = health_db.get_connection()
try:
members = conn.execute(
"SELECT id, name FROM members WHERE is_deleted=0"
).fetchall()
finally:
conn.close()
window = args.window or "1h"
results = []
total_alerts = 0
for m in members:
result = check_member(m["id"], window)
total_alerts += result.get("alerts_generated", 0)
results.append(result)
health_db.output_json({
"status": "ok",
"members_checked": len(members),
"total_alerts": total_alerts,
"window": window,
"results": results,
})
def main():
parser = argparse.ArgumentParser(description="健康异常检测")
sub = parser.add_subparsers(dest="command", required=True)
p_run = sub.add_parser("run", help="检查单个成员")
p_run.add_argument("--member-id", required=True)
p_run.add_argument("--window", default="1h", help="检查时间窗口: 1h/24h/7d")
p_all = sub.add_parser("run-all", help="检查所有成员")
p_all.add_argument("--window", default="1h", help="检查时间窗口: 1h/24h/7d")
args = parser.parse_args()
commands = {"run": cmd_run, "run-all": cmd_run_all}
commands[args.command](args)
if __name__ == "__main__":
main()
interface:
display_name: "家庭健康档案"
short_description: "管理健康档案、病程记录、简报和就医前摘要图生成"
default_prompt: "Use $mediwise-health-tracker to manage records, summarize health history, or generate a doctor-visit summary image."
{
"name": "@mediwise/health-tracker-skill",
"version": "0.3.0",
"type": "module",
"main": "index.js",
"description": "家庭健康管理 OpenClaw Skill",
"author": "mediwise",
"license": "MIT",
"keywords": ["openclaw", "skill", "health", "medical"]
}
{
"name": "mediwise-health-suite",
"version": "1.0.0",
"description": "Family health management suite for OpenClaw AI. Implemented: health records, diet tracking, weight management. Partial: health monitoring, wearable sync.",
"main": "SKILL.md",
"scripts": {
"test": "echo \"No tests specified yet\" && exit 0"
},
"keywords": [
"health",
"medical",
"family",
"tracking",
"diet",
"weight",
"records",
"openclaw",
"skill",
"chinese",
"健康管理",
"医疗"
],
"author": "MediWise Team",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/JuneYaooo/mediwise-health-suite.git"
},
"bugs": {
"url": "https://github.com/JuneYaooo/mediwise-health-suite/issues"
},
"homepage": "https://github.com/JuneYaooo/mediwise-health-suite#readme",
"engines": {
"node": ">=14.0.0",
"python": ">=3.8.0"
}
}
# MediWise Health Suite - Python Dependencies
# No external Python packages required for basic functionality
# All skills use Python standard library and SQLite3 (built-in)
# Optional dependencies for enhanced features:
# requests>=2.28.0 # For medical search API calls (if needed)
# pillow>=9.0.0 # For image processing (if needed)
# Shared utilities for OpenClaw project modules.
"""Unified path setup for cross-module imports.
All skill modules (health-monitor, wearable-sync, diet-tracker, weight-manager)
need to import from mediwise-health-tracker/scripts. This module provides a
single function to set up that path correctly.
"""
from __future__ import annotations
import os
import sys
def setup_mediwise_path():
"""Ensure mediwise-health-tracker/scripts is first on sys.path."""
scripts_dir = os.path.abspath(
os.path.join(os.path.dirname(__file__), '..', 'mediwise-health-tracker', 'scripts')
)
sys.path = [path for path in sys.path if os.path.abspath(path or os.curdir) != scripts_dir]
sys.path.insert(0, scripts_dir)
interface:
display_name: "设备同步"
short_description: "同步手环手表健康数据,并标准化写入健康档案系统之中"
default_prompt: "Use $wearable-sync to sync wearable data and normalize it into health records."
"""Wearable device data providers."""
from .base import BaseProvider
from .gadgetbridge import GadgetbridgeProvider
__all__ = ["BaseProvider", "GadgetbridgeProvider"]