# Milestone: Lifestyle Logging 接入 & 分析能力扩展

**日期**: 2026-03-02
**耗时**: 约 1 个工作日

---

## 背景

Garmin 手表近期支持了 Lifestyle Logging 功能，可在 App 内记录酒精、咖啡因、饮食质量、断食、轻运动等生活行为数据。这部分数据此前只能通过 Obsidian 日记手动补充，现在可以统一在 Garmin 体系内维护，数据质量和一致性都有显著提升。

同时，本次发现并修复了 SpO2 和 Body Battery 指标的历史数据字段名 bug，并对现有分析能力做了全面扩展。

---

## 一、Bug 修复：Garmin 同步日期问题

### 问题
每次要求同步 Garmin 数据时，Bot 总是同步不到最新日期，需要用户手动说明"今天是 X 月 X 日"。

### 根因
1. **LLM 系统提示日期 stale**：`GeminiLLM.__init__()` 只调用一次 `get_system_instruction()`，缓存为 `self.system_instruction`，服务跨天运行时日期不更新。
2. **Safety Override 忽略用户指定日期**：`dispatcher.py` 的 `sync_garmin` 安全覆盖逻辑始终传 `target_date: None`。

### 修复
- `slack_bot/llm/gemini.py`：`_generate_with_openai()` 改为每次请求动态调用 `get_system_instruction()`
- `health/utils/time_utils.py`：新增 `get_cst_now()` / `get_cst_today()` 明确使用 UTC+8
- `slack_bot/dispatcher.py`：
  - Safety Override 解析"今天/昨天/前天"关键词，传入正确的 CST 日期
  - 趋势查询和单日查询的 `datetime.now()` 全部替换为 `get_cst_today()`

---

## 二、新功能：Garmin Lifestyle Logging 接入

### 涉及文件

| 文件 | 改动 |
|------|------|
| `health/models/daily_metrics.py` | 新增 `LifestyleLoggingData` Pydantic 模型 |
| `health/config.py` | 注册 `lifestyle_logging` 到 `DATA_TYPE_CONFIG` |
| `health/services/garmin_client.py` | 新增 `fetch_lifestyle_logging()` 方法 |
| `health/services/storage.py` | `save_daily_metric()` 支持 `LifestyleLoggingData` |
| `health/analytics/engine.py` | `get_dataframe()` 接入 `gl_*` 前缀列 |
| `tests/test_lifestyle_logging.py` | 新增 8 个单元测试（全部通过）|

### LifestyleLoggingData 模型字段

```
酒精:    alcohol_logged, alcohol_beer/wine/spirit/other
早咖啡:  morning_caffeine_logged, morning_caffeine_coffee/tea/other
晚咖啡:  late_caffeine_logged, late_caffeine_coffee/tea/other
饮食:    healthy_meals, heavy_meals, late_meals
其他:    light_exercise, intermittent_fasting
```

### 解析关键逻辑
- Garmin API 无论当天是否有记录都会返回 8 个模板条目，通过 `logStatus == "YES"` 判断是否实际记录
- 无任何记录时返回 `None`，不写入存储
- `logStatus` 字段存在即为 True（NONE 类型），QUANTITY 类型额外读取 `details[].amount`

### Pandas DataFrame 集成（engine.py）
lifestyle_logging 数据以 `gl_` 前缀列合并进 `HealthAnalyst.get_dataframe()`，支持相关性分析：

```
gl_alcohol, gl_alcohol_total
gl_morning_caffeine, gl_morning_coffee
gl_late_caffeine
gl_healthy_meals, gl_heavy_meals, gl_late_meals
gl_light_exercise, gl_fasting
```

### Cron 自动覆盖
`data_sync.sync_incremental()` 通过 `getattr(client, f"fetch_{metric_type}")` 动态发现方法，`lifestyle_logging` 已自动纳入每日 08:00 同步，无需额外配置。

---

## 三、分析能力扩展

`slack_bot/tools/health_read.py` 新增 8 个分析函数，并更新 `get_aggregated_analysis()` 路由：

| 函数 | 分析内容 |
|------|---------|
| `_analyze_spo2` | 平均/最低血氧，SpO2 < 94% 天数占比 |
| `_analyze_respiration` | 平均呼吸率，> 20 bpm 异常天数 |
| `_analyze_hydration` | 摄水量，目标达成率 |
| `_analyze_floors` | 爬楼层数，目标达成率 |
| `_analyze_body_battery` | 日峰值/谷值，充电/放电净值，低电量天数 |
| `_analyze_intensity_minutes` | 中等/高强度分钟数，对照 WHO 150 min/周 |
| `_analyze_weight` | 体重/体脂/肌肉量，周期内变化 |
| `_analyze_lifestyle_logging` | 各行为记录频率（酒精/咖啡因/饮食/断食）|

---

## 四、历史数据修复

### SpO2 字段名 bug

**根因**：`garmin_client.fetch_spo2()` 使用错误的 key 名，导致 Pydantic 字段全为 None，`exclude_none=True` 下不写入 JSON，历史文件只有 `date` + `raw_data`。

| 错误 key | 正确 key |
|---------|---------|
| `averageSpo2` | `averageSpO2` |
| `lowestSpo2` | `lowestSpO2` |
| `highestSpo2`（不存在）| `latestSpO2` |
| `spo2ValueDescriptorsDTOList` | `spO2ValueDescriptorsDTOList` |

**修复**：
- `garmin_client.py`：更正 4 个 key 名
- `_analyze_spo2()`：增加 `raw_data` fallback，兼容旧存储文件
- 历史数据重新解析：786 条文件中 **326 条**成功回填（460 条为无测量数据天）

### Body Battery 字段 bug

**根因**：`highest_value` / `lowest_value` 映射自 `highestBodyBatteryValue` / `lowestBodyBatteryValue`，但这两个 key 实际不存在于 Garmin API 响应中。

**修复**：
- `_analyze_body_battery()`：从 `timeline`（`bodyBatteryValuesArray`）推导 max/min；guard 条件改为 `charged_vals`
- 历史数据重新解析：786 条文件中 **176 条**成功回填（610 条无有效 timeline）

---

## 五、基础设施

- **Cron 注册**：
  - 每日 08:00：`scripts/daily_sync.py`（全量指标增量同步）
  - 每周一 11:00：`scripts/weekly_report.py`（健康周报推送 Slack）
- **服务重启**：`systemctl restart butler-health`，19:08 CST 上线

---

## 测试验证

```bash
# 单元测试（8/8 通过）
pytest tests/test_lifestyle_logging.py -v --no-cov

# SpO2 历史分析验证
# 平均 SpO2 93.3%，323 天有效数据，最低 71%

# Body Battery 历史分析验证
# 平均峰值 69/100，充放净值 +1，低电量天 20%
```
