1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Apple Healthの数値を「さくらのAI Engine」で分析して毎朝Slackへ投稿してみた

1
Last updated at Posted at 2026-08-04

はじめに

Apple Watchで睡眠や心拍、歩数を記録していても、ヘルスケアアプリを毎日開かなければ変化を見落としてしまいます。

また、年齢を重ねて体調の変化を意識することが増えたため、Apple HealthのデータをInfluxDBへ保存し、Grafanaのダッシュボード画像を毎朝Slackへ投稿する仕組みを作りました。

さらに、画像を見るだけでなく数値の変化を短い文章で確認できるよう、さくらのAI Engineによる分析を組み込みました。

最終的には、毎朝9時に次の内容がSlackへ届きます。

  • Grafanaダッシュボードの全体画像
  • 睡眠、心拍、HRV、活動量のAI分析
  • 欠損データや集計途中データについての注意

この記事では、完成した構成だけでなく、実装中に遭遇したInfluxDBのCSV解析や推論モデルの出力切れといった問題も紹介します。

AIの出力は、Apple Watchで取得したデータをもとに分析した健康管理の参考情報であり、医療上の診断には使用しません。

作ったもの

全体構成は次のとおりです。

Apple Watch/iPhone
  │
  │ Health Auto Export(REST API/JSON)
  ▼
ingester(Python/Flask)
  │
  ▼
InfluxDB 2.7
  ├─ Grafana ─ Image Renderer ─┐
  │                            │ ダッシュボード画像
  └─ reporter ─ さくらのAI ───┤ 数値の分析コメント
                               ▼
                              Slack

各サービスはDocker Composeで管理しています。

コンポーネント 役割
Health Auto Export Apple HealthのデータをJSONで送信
ingester JSONをInfluxDBのポイントへ変換
InfluxDB ヘルスケア時系列データの保存・集計
Grafana データの可視化
Grafana Image Renderer ダッシュボードをPNG化
reporter 定刻実行、AI分析、Slack投稿
さくらのAI Engine InfluxDBの集計値を文章で分析

なぜ画像ではなく数値をAIへ渡したのか

最初は、Grafanaのスクリーンショットをマルチモーダルモデルへ渡して分析する案を考えました。

しかし、最終的には画像入力を使わず、InfluxDBの数値だけを渡す構成にしました。

理由は次のとおりです。

  • グラフのOCRや目盛りの読み間違いを避けられる
  • AIへ送信する情報を必要な項目だけに限定できる
  • 日付、単位、欠損状態を明示できる
  • 画像サイズに影響されず、入力内容をテストしやすい
  • 将来、前週平均や基準値を機械的に追加しやすい

人間にはGrafana画像、AIには構造化した数値データを渡すことで、それぞれに適した入力を使い分けています。

Apple HealthからInfluxDBまで

Health Auto Exportから、Flaskで作成したingesterへJSONをPOSTします。

POST http://<サーバーIP>:8080/export
Authorization: Bearer <共有シークレット>
Content-Type: application/json

ingesterでは主に次の処理を行います。

  • メトリクス名を小文字のスネークケースへ変換
  • 日時をUTCへ統一
  • 数値をInfluxDBのfieldとして保存
  • 睡眠ステージなどの文字列をtagとして保存
  • 端末名など、系列を不用意に分割する情報を除外
  • 大量データを5,000件ずつ書き込み

代表的なデータ構造は次のとおりです。

measurement field 内容 単位
heart_rate Avg 平均心拍数 count/min
resting_heart_rate qty 安静時心拍数 count/min
heart_rate_variability qty 心拍変動 ms
step_count qty 歩数 count
active_energy qty アクティブエネルギー kJ
sleep_analysis totalSleep 合計睡眠時間 hr

InfluxDBではUTCで保存し、日次集計時にはAsia/Tokyoを指定しています。

AIへ渡すデータを作る

reporterはInfluxDBから直近8日分を取得し、メトリクスの性質に応じて集計します。

  • 合計:歩数、距離、消費エネルギー、運動時間、スタンド時間、上った階数
  • 平均:心拍数、安静時心拍数、HRV、歩行時平均心拍数、歩行速度
  • 最新値:睡眠時間

AIへ渡すJSONは、概ね次のような形式です。値は記事用の例です。

[
  {
    "date": "2026-08-03",
    "metric": "sleep_analysis",
    "field": "totalSleep",
    "value": 6.75,
    "unit": "hr"
  },
  {
    "date": "2026-08-03",
    "metric": "resting_heart_rate",
    "field": "qty",
    "value": 52.0,
    "unit": "count/min"
  },
  {
    "date": "2026-08-03",
    "metric": "step_count",
    "field": "qty",
    "value": 6432,
    "unit": "count"
  }
]

画像、Slackの情報、APIトークンはAIへ送りません。

さくらのAI Engineを呼び出す

今回はOpenAI互換のChat Completions APIとgpt-oss-120bを使用しました。

resp = requests.post(
    "https://api.ai.sakura.ad.jp/v1/chat/completions",
    headers={
        "Authorization": f"Bearer {SAKURA_AI_TOKEN}",
        "Content-Type": "application/json",
    },
    json={
        "model": "gpt-oss-120b",
        "messages": [
            {
                "role": "system",
                "content": (
                    "あなたは健康データの変化を簡潔に整理する"
                    "アシスタントです。"
                ),
            },
            {"role": "user", "content": prompt},
        ],
        "temperature": 0.2,
        "max_tokens": 2400,
        "stream": False,
    },
    timeout=90,
)
resp.raise_for_status()

プロンプトでは次の条件を指定しています。

  • 渡したJSONの数値だけを分析する
  • 最新値、前日、直近の傾向を比較する
  • 睡眠、心拍、活動量を中心にまとめる
  • 当日分は集計途中の可能性があると考慮する
  • 欠損値や古い値を明示する
  • 診断や断定をしない
  • Slack向けに4つの見出しと箇条書きを使い、900文字以内で出力する

アカウントトークンは.envで管理します。

SAKURA_AI_TOKEN=<UUID>:<シークレット>

トークンは発行時に表示されるUUIDとシークレットの組を、そのまま設定する必要があります。

実装中に詰まった点

1. InfluxDBのCSVで単位を数値として読んでしまった

InfluxDBのQuery APIはannotated CSVを返します。系列ごとにテーブルが分かれ、列順の異なるヘッダーが途中に再登場することがあります。

最初のヘッダーだけをcsv.DictReaderへ渡した実装では、units列を_valueとして読み、次のエラーになりました。

ValueError: could not convert string to float: 'units'

各テーブルのヘッダーを検出し直すことで解決しました。

header = None

for values in csv.reader(io.StringIO(response_text)):
    if not values or (values[0] and values[0].startswith("#")):
        continue

    if "_measurement" in values and "_value" in values:
        header = values
        continue

    if header is None:
        continue

    row = dict(zip(header, values))

2. AIの回答本文がnullになった

max_tokensを700にしていたところ、gpt-oss-120bが内部推論で上限を使い切り、ユーザー向けのcontentnullになりました。

そこで、上限を2,400へ増やし、プロンプト側では「表を使わず、4つの見出しと箇条書きで900文字以内」と指示しました。

content = choice["message"].get("content")
analysis = content.strip() if isinstance(content, str) else ""

if not analysis:
    raise RuntimeError(
        "Sakura AI returned no answer text "
        f"(finish_reason={choice.get('finish_reason')!r})"
    )

推論上限と、実際に欲しい回答の長さは別々に設計する必要があると分かりました。

3. 当日分の活動量が極端に少なく見えた

朝の投稿時点では、その日の歩数や運動時間はまだ集計途中です。単純に前日と比較すると、AIが「活動量が急減した」と解釈する可能性があります。

プロンプトへ、当日分は集計途中の可能性があること、欠損と異常を混同しないことを明記しました。

AIが失敗しても画像投稿は止めない

朝の通知では、AI分析よりもダッシュボード画像の投稿継続を優先します。

try:
    analysis = analyze_health_metrics(fetch_health_metrics())
    comment = build_comment(analysis)
except Exception:
    log.exception("Failed to generate AI analysis")
    comment = "AI分析に失敗したため、画像のみ投稿します"

post_to_slack(image_bytes, filename, comment)

さくらのAI EngineやInfluxDBの一時的な障害があっても、Grafana画像を取得できればSlack投稿は継続します。

Slackへの投稿

画像アップロードにはSlackのExternal Upload APIを使います。

1. files.getUploadURLExternal
2. 発行されたURLへPNGをアップロード
3. files.completeUploadExternal

reporterは毎朝9時に次の処理を実行します。

Grafana画像を生成
  ↓
InfluxDBの日次値を取得
  ↓
さくらのAI Engineで分析
  ↓
画像と分析コメントをSlackへ投稿

Slack投稿のイメージ

実際の投稿では具体的な数値が表示されますが、以下は記事掲載用に数値を伏せた例です。

🌅 2026-08-05 ヘルスケアレポート
────────────────────

睡眠
• 直近の睡眠時間は前回より減少していました。一部の短い記録は、
  記録途中の可能性があります。

心拍・HRV
• 平均心拍は前日より上昇し、安静時心拍は低下していました。
  HRVにも変化が見られます。

活動量
• 当日の歩数、消費エネルギー、立ち時間は直前数日より極端に少なく、
  データが集計途中または停止している可能性があります。

データ状況
• 睡眠や活動量には欠損または途中停止の可能性があります。
  欠損が多い日は、翌日以降のデータも含めて傾向を確認します。

────────────────────
ℹ️ AIによる数値の整理です。
健康管理の参考情報であり、医療上の診断ではありません。

実測結果

実データを使ってテスト投稿した、ある1回の結果です。

InfluxDB集計値:103件
Grafana画像:約403KB
AI分析:成功
Slack投稿:成功

さくらのAI EngineのAPI応答に含まれるusageもログへ記録しました。

prompt_tokens     : 4,398
completion_tokens : 1,492
total_tokens      : 5,890

gpt-oss-120bの単価で比例計算すると、入力約0.066円、出力約0.112円、合計約0.178円相当です。

ただし、さくらのAI EngineのChat Completionsには月3,000リクエストの無料枠があります。
※記載時点の情報となります。

トークン数ではなくリクエスト数が基準です。無償プランでは無料枠を超えても自動課金されず、レート制限が適用されます。今回のテストは無料枠内のため、実際の請求は0円です。

使ってみて分かったこと

構造化データはAIに渡す前の処理が重要

AIへ生データをそのまま渡すより、日次集計、単位、日付を明示した方が回答を制御しやすくなりました。

一方で、欠損や当日途中の値を説明しなければ、AIは数値の急変として解釈する可能性があります。

AIは主処理ではなく補助処理にすると運用しやすい

AI分析を通知処理の必須条件にすると、API障害で朝のレポート全体が止まります。

画像投稿を主処理、AI分析を追加情報として扱うことで、日常運用しやすくなりました。

利用量はAPI応答から記録できる

usage.prompt_tokensusage.completion_tokensをログへ残すことで、無料枠のリクエスト数とは別に、処理ごとのトークン量や将来の料金を把握できます。

今後改善したいこと

  • AI分析結果と利用トークン数を履歴として保存する
  • 月間リクエスト数と推定料金を可視化する
  • AIの文章に含まれる数値が入力JSONと一致するか検証する
  • AI分析失敗時のリトライを追加する

まとめ

さくらのAI Engineを使い、Apple Healthの数値データを毎朝自動分析する仕組みを構築しました。

今回のポイントは、画像をそのままAIへ渡すのではなく、InfluxDBで集計した数値だけをJSONとして渡したことです。これにより、入力内容を限定しつつ、日付や単位を含めて分析させられました。

実装では、InfluxDBのannotated CSV、推論モデルの出力上限、当日途中データの扱いなど、APIを一度呼ぶだけでは分からない問題にも遭遇しました。

AIを日常運用へ組み込むには、分析精度だけでなく、欠損データ、失敗時のフォールバック、利用量の記録まで含めた設計が重要だと感じました。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?