# Butler 架构文档

**最后更新**: 2026-03-02

---

## 1. 系统概览

Butler 是一个本地优先的个人 AI 助理，通过 Slack 接收指令，调用工具操作健康数据、执行系统任务。

### 技术栈

| 层级 | 技术 |
|------|------|
| 接入层 | Slack Bolt (Socket Mode) |
| AI 推理 | Gemini（via Google API 或 OpenRouter 代理） |
| 工具系统 | 21 个 Python 函数，OpenAI function calling 格式 |
| 健康数据 | Garmin Connect API + 本地 JSON + SQLite |
| 笔记集成 | Obsidian Vault（Markdown 文件） |
| 数据验证 | Pydantic v2 |
| 日志 | Python logging（分模块输出到 logs/） |

### 核心模块

```
butler/
├── health/          # 健康数据管理（Garmin 同步、查询、分析）
├── slack_bot/       # Slack Bot（消息分发、工具系统、LLM 集成）
├── scripts/         # 独立脚本（cron 任务、bot 管理）
├── skills/          # Claude Code 扩展技能
└── data/health/     # 运行时数据（SQLite + JSON）
```

---

## 2. 架构图

### 消息处理流程

```mermaid
sequenceDiagram
    participant U as User (Slack)
    participant M as main.py
    participant D as Dispatcher
    participant G as GeminiLLM
    participant T as Tool Registry
    participant H as Health Services
    participant DB as Storage (JSON + SQLite)

    U->>M: 发送消息
    M->>M: 去重检查 (client_msg_id)
    M->>D: dispatch(message, channel_id)
    D->>D: 加载对话上下文 (40条滑动窗口)
    D->>G: generate_response(消息 + 工具 schema)
    G-->>D: (文本 + tool_calls)
    alt LLM 未触发工具
        D->>D: Safety Override（关键词检测，强制调用工具）
    end
    D->>T: 执行工具函数
    T->>H: 调用服务层
    H->>DB: 读/写数据
    DB-->>H: 返回数据
    H-->>T: 结果
    T-->>D: tool_result
    D->>G: generate_response(工具结果 → 最终回答)
    G-->>D: 自然语言回答
    D->>D: 格式化 + 分块 (≤3800 char/块)
    D->>U: 发送到 Slack
    D->>D: 保存上下文
```

### 数据同步流程

```mermaid
flowchart TD
    A[sync_garmin 工具调用] --> B[HealthDataSync.sync_all_metrics]
    B --> C{检查上次同步时间}
    C -->|有记录| D[仅同步增量日期]
    C -->|无记录| E[从历史起始日同步]
    D --> F[GarminHealthClient.fetch_*]
    E --> F
    F -->|14 种指标| G[HealthStorage.save_daily_metric]
    F -->|活动数据| H[HealthStorage.save_activity]
    G --> I[JSON 文件存储]
    H --> I
    G --> J[HealthRepository.upsert_daily_metrics_index]
    H --> K[HealthRepository.upsert_activity_index]
    J --> L[(SQLite: health.db)]
    K --> L
    L --> M[更新 last_sync_state]
```

---

## 3. 模块详解

### 3.1 health/ — 健康数据管理

**职责**: Garmin 数据同步、本地存储、查询、分析。

#### 数据流（层级结构）

```
HealthDataSync (orchestrator)
├── GarminHealthClient     → Garmin Connect API 封装（14 个 fetch 方法）
├── HealthStorage          → JSON 文件 I/O（daily_metrics/ + activities/）
├── HealthRepository       → SQLite CRUD（索引 + 同步状态）
└── ManualLogStorage       → 手动日志 JSON（diet/alcohol/supplements/feelings）

HealthDataQuery (只读查询)
├── HealthStorage          → 加载 JSON 数据
└── HealthRepository       → 活动索引查询
```

#### 支持的健康指标（14 种）

| 指标 | API 方法 | 存储路径 |
|------|---------|---------|
| sleep | fetch_sleep_data | daily_metrics/sleep |
| steps | fetch_steps_data | daily_metrics/steps |
| heart_rate | fetch_heart_rates | daily_metrics/heart_rate |
| hrv | fetch_hrv_data | daily_metrics/hrv |
| stress | fetch_stress_data | daily_metrics/stress |
| body_battery | fetch_body_battery | daily_metrics/body_battery |
| spo2 | fetch_spo2_data | daily_metrics/spo2 |
| respiration | fetch_respiration_data | daily_metrics/respiration |
| hydration | fetch_hydration_data | daily_metrics/hydration |
| floors | fetch_floors | daily_metrics/floors |
| intensity_minutes | fetch_intensity_minutes | daily_metrics/intensity_minutes |
| rhr | fetch_rhr_day | daily_metrics/rhr |
| activities | fetch_activities_by_date | activities/ |
| weight | fetch_weigh_ins | body_metrics/weight |

#### 关键类

| 类 | 文件 | 职责 |
|----|------|------|
| `HealthDataSync` | `services/data_sync.py` | 同步编排，包含重试逻辑 (tenacity) |
| `GarminHealthClient` | `services/garmin_client.py` | Garmin API 封装 |
| `HealthStorage` | `services/storage.py` | JSON 文件读写 |
| `HealthRepository` | `db/repository.py` | SQLite CRUD |
| `HealthDataQuery` | `services/query.py` | 只读查询入口 |
| `ManualLogStorage` | `services/manual_log_storage.py` | 手动日志持久化 |
| `WeeklyReportGenerator` | `analytics/weekly_report.py` | 周报生成 |

---

### 3.2 slack_bot/ — Slack Bot

#### 子模块职责

| 子模块 | 职责 |
|--------|------|
| `main.py` | Bot 入口，注册 Slack 事件处理器 |
| `dispatcher.py` | 消息分发核心（580+ 行） |
| `llm/gemini.py` | Gemini API 封装 |
| `context/storage.py` | 对话上下文持久化（每 channel 独立） |
| `tools/registry.py` | 21 个工具的 schema + 函数映射 |
| `tools/groups.py` | 工具预设（light/standard/full/all） |
| `shell/` | Shell Bot 组件（默认禁用） |
| `obsidian/` | Obsidian Vault 集成 |

#### Dispatcher 核心逻辑

```python
# dispatcher.py 处理流程
def dispatch(message_text, channel_id, user_id, ...):
    # 1. 去重
    # 2. 下载 Slack 上传的图片（PIL 压缩为 JPEG）
    # 3. 加载对话上下文（ContextStorage）
    # 4. LLM 调用 #1（带工具 schema）
    # 5. Safety Override：LLM 未触发工具时，关键词强制调用
    # 6. 执行工具函数，收集结果
    # 7. LLM 调用 #2（注入工具结果，生成最终回答）
    # 8. 格式化 + 分块发送（max 3800 char/块，最多 3 块）
    # 9. 保存上下文
```

**Safety Override** — 当 LLM 未触发工具时的关键词兜底：
- "sync/update/fetch" + "garmin" → `sync_garmin`
- "sleep/steps/heart rate/body battery" → `get_daily_detailed_stats`
- "past X days/weeks" → `get_metric_history`
- "search/搜索" → `search_web`
- 食物词汇 + 餐型词汇 → `log_diet`

#### 工具模式（Tool Mode）

通过 `.env` 的 `TOOL_MODE` 控制，适配不同代理能力：

| 模式 | 工具数 | 适用场景 |
|------|--------|---------|
| `light` | 6 | 代理工具数量受限 |
| `standard` | 10 | 默认均衡配置 |
| `full` | 15 | 完整健康功能 |
| `all` | 21 | 包含 Shell/System 工具 |

---

### 3.3 数据存储

#### SQLite（health.db）— 索引层

WAL 模式，用于快速索引查询。

```sql
-- 同步记录（每次同步的执行日志）
CREATE TABLE sync_records (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    data_type TEXT NOT NULL,
    start_date TEXT NOT NULL,
    end_date TEXT NOT NULL,
    status TEXT NOT NULL,  -- 'success', 'failed', 'partial'
    records_synced INTEGER DEFAULT 0,
    error_message TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 每种指标最后同步状态（增量同步的基准）
CREATE TABLE last_sync_state (
    data_type TEXT PRIMARY KEY,
    last_sync_date TEXT NOT NULL,
    total_records INTEGER DEFAULT 0,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 每日指标文件索引
CREATE TABLE daily_metrics_index (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    metric_type TEXT NOT NULL,
    date TEXT NOT NULL,
    file_path TEXT NOT NULL,
    has_data BOOLEAN DEFAULT 1,
    UNIQUE(metric_type, date)
);

-- 活动索引（支持日期范围查询）
CREATE TABLE activity_index (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    activity_id TEXT UNIQUE NOT NULL,
    activity_type TEXT,
    date TEXT NOT NULL,
    duration_seconds INTEGER,
    distance_meters REAL,
    file_path TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```

#### JSON 文件 — 数据层

```
data/health/
├── daily_metrics/{metric_type}/{YYYY}/{MM}/{DD}.json
├── activities/{YYYY}/{MM}/{DD}.json
├── body_metrics/weight/{YYYY}/{MM}/{DD}.json
├── manual_logs/{YYYY}/{MM}/{DD}.json
└── slack_context/{channel_id}.json
```

**示例 — sleep JSON**:
```json
{
  "date": "2026-03-01",
  "sleep_score": 82,
  "total_sleep_seconds": 27120,
  "deep_sleep_seconds": 6300,
  "rem_sleep_seconds": 7200
}
```

**示例 — manual_logs JSON**:
```json
{
  "date": "2026-03-01",
  "diet_entries": [{"time": "08:00", "description": "燕麦", "meal_type": "breakfast"}],
  "alcohol_entries": [],
  "supplement_entries": [{"time": "07:00", "supplement_name": "镁", "dosage": "400mg"}],
  "feeling_entries": [],
  "fasting_mode": null
}
```

---

## 4. 设计决策

### 为什么选择 SQLite + JSON 双存储？

- **JSON**：人类可读，便于调试和迁移；每天一文件，互不干扰
- **SQLite**：支持跨日期范围的快速索引查询，避免遍历文件系统
- 两者互补，而非冗余

### 为什么使用 Safety Override 机制？

Gemini（尤其通过 OpenRouter 代理时）偶尔不触发 function call。Safety Override 是一个关键词检测兜底层，确保核心操作（同步、查询、记录）的稳定性。

### 为什么用两阶段 LLM 生成？

- 阶段 1：让 LLM 决定调用哪些工具
- 工具执行：收集真实数据
- 阶段 2：LLM 基于真实数据生成回答，杜绝幻觉

### 为什么本地优先？

所有数据存储在本机（`data/`），不依赖外部数据库，便于备份、迁移和隐私保护。

### 为什么禁用 Shell Bot？

Shell Bot 权限过高（可执行任意系统命令），在当前 Linux 服务器环境中默认关闭，需显式设置 `SHELL_BOT_ENABLED=true` 才能启动。

---

## 5. 扩展指南

### 如何添加新的健康指标

1. 在 `health/config.py` 的 `DATA_TYPE_CONFIG` 中添加新条目
2. 在 `GarminHealthClient` 中实现对应的 `fetch_*` 方法
3. 在 `health/models/daily_metrics.py` 中添加 Pydantic 模型
4. 在 `HealthStorage` 中处理存储/加载逻辑
5. 更新 `HealthDataQuery.get_daily_summary()` 包含新指标

### 如何添加新工具

1. 在 `slack_bot/tools/` 下实现工具函数（static method 或普通函数）
2. 在 `slack_bot/tools/registry.py` 的 `TOOLS_SCHEMA` 中添加工具定义
3. 在 `TOOL_FUNCTIONS` 字典中注册函数映射
4. 根据工具复杂度决定加入哪个工具组（`groups.py`）

### 如何集成新数据源

1. 仿照 `GarminHealthClient` 创建新的 client 类
2. 在 `HealthDataSync` 中添加对应的同步方法
3. 在 `HealthStorage` 中添加存储路径和格式
4. 创建对应的工具函数暴露给 Slack Bot

---

## 6. Bot 运维

### 启动/停止

```bash
# 启动 Health Bot
python scripts/bot_manager.py start

# 停止
python scripts/bot_manager.py stop

# 查看状态
python scripts/bot_manager.py status
```

### Cron 任务

```bash
# 每日 Garmin 数据同步（参考 docs/CRON_SETUP.md）
0 9 * * * cd /root/projects/butler && source venv/bin/activate && python scripts/daily_sync.py

# 每周一 11:00 生成健康周报
0 11 * * 1 cd /root/projects/butler && source venv/bin/activate && python scripts/weekly_report.py
```

### 关键日志文件

```
logs/
├── slack_bot.log      # Health Bot 主日志
├── health.log         # 健康数据模块
├── health_sync.log    # 同步任务日志
└── weekly_report.log  # 周报生成日志
```

### 关键环境变量

```bash
SLACK_BOT_TOKEN=xoxb-...
SLACK_APP_TOKEN=xapp-...
GEMINI_API_KEY=...
GEMINI_MODEL=google/gemini-3-flash-preview
GEMINI_BASE_URL=https://openrouter.ai/api  # 代理地址
GARMIN_EMAIL=...
GARMIN_PASSWORD=...
TOOL_MODE=all              # light/standard/full/all
SHELL_BOT_ENABLED=false    # 本机保持 false
SLACK_REPORT_CHANNEL=C0XX  # 周报发送频道
```
