個人の健康データ基盤「yuru」をゼロから作り直して運用に乗せるまでの全記録
Streamlit + Supabase で組んで放置死したセルフ健康トラッカーを、Mac Studio 常駐のローカル完結システムとして作り直した。設計 → 実装 → 移行 → 本稼働 → 機能拡張 → 障害対応 まで、実際に踏んだ地雷を含めて技術中心で振り返る。自分用のメモ。
0. 何を作ったか
yuru(旧 yuru_health v2)は、複数のウェアラブル/デバイス/クラウドAPIから健康データを自動集約し、AIに日次・週次で分析させる個人用の health data 基盤。
設計思想は3つに集約される。
Data Maximal — Raw JSON を全部保存する。使い方は後で考える
Input Minimal — 手動入力は極限まで減らす
ゆるストイック — YAGNI徹底。具体的なボトルネックが出るまで機能を作らない
自分(PM兼アーキテクト)が設計判断とタスクブリーフを書き、実装は Claude Code に委譲、レビューは別のClaudeと壁打ちする、という分業で進めた。この記事はその壁打ちログの再構成である。
最終的な構成:
| レイヤー | 技術 |
|---|---|
| ランタイム | Mac Studio 常駐(LaunchDaemon) |
| 操作DB | SQLite(WAL) |
| 分析DB | DuckDB |
| アーカイブ | 日次 Parquet スナップショット |
| フロント | FastAPI + HTMX + Tailwind + PWA |
| AI | ローカル vLLM(GX10)→ 後に Proxy 経由 DeepSeek V4 Flash |
| 配信 | Discord Webhook |
| ネットワーク | Tailscale(宅外アクセス) |
データソースは Oura Ring Gen3 / Withings(体組成計)/ SwitchBot(CO2・温湿度)/ OpenWeatherMap の4つ。
1. なぜ作り直したか
旧アプリは Streamlit + Supabase(PostgreSQL Free tier) + GitHub Actions(15分cron) + Gemini API という構成だった。これがSupabase Free tier の90日無アクセス自動削除で死んだ。皮肉なことに、健康データを貯めるためのシステムが、放置されたことで消えかけた。
作り直しの方針を決める段階で、いちばん重要だった設計判断を先に書いておく。
支配的な分岐:「パイプラインの居場所」と「DBの居場所」は分離できない
最初、技術選定を「フロントエンド」「DB」「パイプライン」「AI」の独立した4項目として並べていた。これが設計を曖昧にしていた。
実際にはここに1本の支配的な分岐がある。
DuckDB/Parquet をローカル(Mac Studio)に置くなら、fetch ワーカーもローカルに居なければならない。GitHub Actions は LAN 内の DuckDB に書き込めないから。逆にクラウドDB(Supabase等)を維持するなら、パイプラインはどこで動かしてもいい。
つまり選ぶ軸は「ホームラボ常駐型 vs クラウド常駐型」の二択で、フロントもAIもこの軸に従属する。既に Mac Studio が常駐していて、別プロジェクトで FastAPI+HTMX+PWA のスタックを完成させていたので、ホームラボ常駐型が自然に決まった。
ストレージ二層化
書き込みは Mac Studio 上の SQLite(raw_data_lake + 操作系)。fetch ワーカーは単一なので single-writer 制約は問題にならない。分析は DuckDB が SQLite と Parquet を両方ネイティブに読めるので、ETL複製なしで「ライブSQLite + アーカイブParquet」を横断クエリする。日次で Parquet スナップショットを吐く。
重複排除:50件ハッシュガード → UNIQUE制約
旧実装は「保存前に最新50件のハッシュと比較してスキップ」という苦肉の策で、50件ウィンドウを超える論理重複のリスクがあった。ローカルDBなら payload_hash 列に本物のUNIQUE制約を張るだけで解決する。DBに重複排除を任せる。
YAGNIで切ったもの
旧設計の「v2で直すべき課題リスト」のうち、マルチユーザー化とFK制約はやらないと決めた。実ユーザーは1人(+パートナーは read-only)。いま auth/RLS を組むのは投機的一般化。user_id 列はハードコードのまま残す。FKも append-only の個人データレイクではほぼ無価値。
2. デバイス戦略:「サーバーから読めるか」という単一フィルタ
作り直しにあたって、データソースの棚卸しをした。ここで一つ大きな発見があった。
もともと Samsung Health のデータを Google Fit 経由でサーバーに取り込んでいたのだが、調べ直すと:
Google Fit REST API は2026年末で終了(後継の Health Connect はオンデバイス専用でサーバーから読めない)
Samsung Health にはサーバー側クラウドAPIが存在しない
つまり「headless なサーバーから cron で読める」というフィルタをかけると、Apple Watch も Samsung Galaxy Watch も Galaxy Ring も全部脱落する。HealthKit も Health Connect もオンデバイス専用だからだ。生き残るのは Oura / Withings / Garmin / Whoop / Fitbit / Polar あたり、つまり自前のクラウドAPIを公開している陣営だけ。
この気づきで、デバイス選定の基準が一気に明確になった。「機能が優れているか」ではなく「サーバーから読めるか」。ウェアラブルはサーバーで読めなければ、yuruにとっては存在しないのと同じ。
結論:
Oura Ring Gen3 を継続(v2 API は世代非依存。Gen4/5に買い替えても新しく取れるデータはほぼない。ただし Membership 継続が API アクセスの前提)
Withings は将来の体組成計アップグレードも同じAPI・meastype追加のみで対応可能(後にこれが効いてくる)
日中の連続HR・ストレス・GPSワークアウトが欲しければ別途 Garmin/Whoop を「置換ではなく追加」する。当面は保留
pluggable source 抽象を最初から入れる
デバイスを後で入れ替える手戻りをゼロにするため、raw_data_lake の source/category を自由テキストのまま(enumやFKにしない)にして、フェッチャーを統一インターフェース(fetch() → list[RawRecord] を実装して登録するだけ)で組んだ。これで新しいウェアラブルは「モジュールを1個書いて登録」で入る。
ただし注意点が1つあって、これはコードの手戻りではなくデータの連続性の問題。Oura を Garmin に「置換」すると、睡眠スコアやreadinessの算出方法が変わるので、AI分析の「睡眠スコアの長期推移」が乗り換え日で断層になる。raw レイクはペイロードの違いを吸収するが、時系列分析には縫い目が残る。だから「置換ではなく追加」と腹を決めておくと、この断層が発生しない。
3. 移行:raw-first 設計が救ったもの、救えなかったもの
本稼働前の移行フェーズで、raw-first 設計(生データを全部保存する)の真価と限界が両方はっきり出た。
parsed テーブルの過去データが大幅欠落した
旧 raw から parsed を再生成したら、軒並み件数が足りなかった。
| テーブル | 旧Supabase | 再生成後 |
|---|---|---|
| weight_data | 95 | 55 |
| environmental_logs | 3,518 | 87 |
原因は、旧 raw_data_lake 自体が50件ハッシュガードで間引かれて lossy だったこと。一方、旧の parsed テーブルは fetch 時に都度書かれていて密だった。「parsed は raw から再生成できる」という前提が、移行に関しては成り立たない(旧 raw そのものが欠けているから)。
対処:旧 parsed の Parquet を直接インポート
raw からの再生成だけでなく、migration/exports/ の旧 parsed parquet を新スキーマに列マッピングして直接インポートした。結果:
| テーブル | 直接補完後 | 備考 |
|---|---|---|
| oura_data | 93 | 旧113は日付重複あり、ユニーク日は93で一致(欠落ゼロ) |
| weight_data | 120 | raw由来55 + parquet由来を union して旧95より多く回収 |
| environmental_logs | 3,597 | SwitchBot 15分1,405 + 天気2,192 |
冪等性は COALESCE upsert で担保。何度実行しても既存の体組成値を上書きしない。
SwitchBotの1分データは「時間との勝負」だった
SwitchBotのCO2センサーは、クラウド同期が変化駆動(2〜30分間隔)なので、routine ポーリングでは真の1分粒度が取れない。1分粒度はデバイス本体のバッファ(実効46日)にしか無く、月次のCSVエクスポートでしか取れない。バッファはロールオフして消えるので、これは買えない時間データ。
急いでCSVをエクスポートして検証したら、63,380行・45日19時間・ほぼ完全1分(2分gapが2,487、それ以上は27分/47分が各1のみ)というクリーンなデータだった。CO2が最大1,740ppmまで上がっていて、これが後の相関分析で効いてくる。
教訓: raw を保持していれば、パーサ系のバグや設計変更はデータ損失なしに後から直せる。実際この後、天気のパースミスもOuraの指標も全部 raw からバックフィルで復元した。ただし「raw に入る前」のデータは保存されない。fetch が始まる前や、ロールオフしたバッファは取り返せない。だから新しいデータ源は「気づいたら早く繋いで raw に流し始める、活用は後回し」が最適解になる。
4. 本稼働で踏んだ地雷たち
デプロイ後、日々の運用の中でいくつも不具合が出た。ほぼ全部 raw-first のおかげで過去分ごと直せたが、そのパターンを記録しておく。
DBロック競合(single writer の自己競合)
CSVインポート中に「database is locked」が連発。原因は別アプリとの競合ではなく、同じインポート処理が自分自身と競合していた。1プロセスがraw書き込み用とパース書き込み用で2つのコネクションを開いていた。SQLiteは書き込みが同時に1つしか許されない。
根本修正は「パーサは呼び出し元の接続を受け取り、1プロセス1コネクションに統一」+ PRAGMA busy_timeout=5000 の保険。ライブのingestも同じ構造だったので、CSVだけでなく全体を直した。
Oura vo2_max の casing バグ
vo2_maxエンドポイントが404。原因はパスの大文字小文字ミスで、正しくは vO2_max(Oが大文字)。1文字の違い。ただし直しても、GPS付き屋外ワークアウトを記録しないとOura自身が推定値を生成しないので、当面NULLのまま。これは後に「VO2maxをどう測るか」の議論につながる(結論:週1回、いつもの散歩をアプリのワークアウトとして記録するしかない。ハードウェア追加は不要)。
OpenWeatherMap One Call 4.0 のレスポンス構造ミスマッチ
天気だけダッシュボードが空。SwitchBotは同じ environmental_logs に新鮮に入っているのに天気だけ全空欄。切り分けの結果、接続失敗ではなくパース失敗だった。
One Call 4.0 は {“data”: [{…}]} を返すのに、パーサが payload[“current”] を見ていて常に空dictになっていた。修正 (payload.get(“data”) or [{}])[0] or payload.get(“current”) or {} で data[0] 優先・current フォールバックの両対応に。NULL だった2,174件を raw から全件バックフィル復元。まさに raw-first の効きどころ。
Oura のストレス/回復力/SPO2 が全空 → 全部 Type B(パース)だった
3指標が全日空。調査したら全部パース側のバグだった:
spo2_avg — average.spo2_percentage(逆)→ spo2_percentage.average(正)
resilience_score — score(存在しない)→ level → 整数マップ
stress_score — score(存在しない)→ day_summary → 整数マップ
ここで設計判断が1つ。stress は多くの日で day_summary が null(Oura が分類しない)。「recovery_high - stress_high をスコア代替にする」案は却下した。理由はスケールが混ざって列が壊れるから。day_summary由来の整数(0-100系)に、秒の差分(900等)を混ぜると、同じ列に別スケールが同居してAIが読めなくなる。
代わりに stress_high_sec / recovery_high_sec を別カラムで保持した。「分類できた日はカテゴリスコア、できない日も秒単位の実体は残る」の二段構え。これは Data Maximal と「派生は必要になってから」の方針とも一致する。
categorical スコアの粒度問題
回復力が毎日「60」で固定に見えた。調査したらバグではなく実態(level が本当に毎日 “solid” だった)。ただ粒度が粗いだけなので、contributors(sleep_recovery / daytime_recovery / stress、各0-100の連続値)をカラム化して、ダッシュボードは連続値を表示するようにした。「固定=実態かバグか」を raw で切り分けてから対処するのが正しい順序だった。
5. サプリ管理:モデリングと二重計上との戦い
yuruの唯一の定常的な手動入力が「サプリのスタック変更」。ここが実はAI分析の肝で、変更点を記録すると、AIが「その変更で睡眠/readinessがどう動いたか」を自然実験として前後比較してくれる。
regimen(線)/ override(点)/ annotations(文脈)の3チャネル
最終的に入力系は3つの役割に綺麗に分かれた。
regimen — 恒久的なサプリ計画。dated versioned snapshot。スタックを変えたら「新バージョン」を作る。effective_from が介入の起点として記録される
override — 単日の逸脱。「今日だけ亜鉛を足した」「今日はブレンド飲み忘れた」。regimen本体は不変で、単日差分だけ書く
annotations — 数値に出ない文脈。「発熱」「出張」「機器変更」。点も期間も表現できる
二重計上バグ(full-day を per-unit と誤る)
サプリ成分の集計で daily_intake = Σ(qty × ingredients) としていたので、ingredientsは1単位あたりでなければならない。ところが特製ブレンドドリンクは、成分値が仕様書の「1日量(朝23g+夕23g)」で入っていた。unit_type「回」で1日2回摂取するので:
コード
半量化して修正。同じ罠が omega3 でも見つかった。Nordic Naturals Ultimate Omega のラベルは「Serving Size 2 soft gels: EPA 650mg」で、これは2粒あたり。1粒あたりは EPA 325mg なのに、catalog には旧値の430mgが入っていた。朝2粒飲んでいたので EPA が「430×2=860」で計上されていたのを「325×2=650」に是正。daily_intake は都度計算(YAML直参照)なので、修正すると過去日ぜんぶ即遡及する。
AI下書き:記憶ベース生成の禁止
新サプリのcatalog追加を、商品ページURLかラベル文言を渡すとローカルLLMが成分表を構造化してフォームに下書きする形にした。ただし重要な設計判断:
LLM に「知っている成分を書け」とやると、有名商品はそれらしく書けるが数値がズレるし、マイナー商品は創作する。だから LLM の仕事を「知識の想起」ではなく「目の前のテキストの抽出・整形」に限定する。URLをサーバー側でfetchしてテキストを渡し、「このテキストに書かれている情報のみから」抽出させる。数値換算(N粒あたり→1粒あたり)はサーバー側で確定的に行い、LLMには計算させない。
ホモグリフ事故と栄養素キー正規化
成分キーに Cyrillic の「ококаパウダー」(カタカナ「ココア」のつもりが混入)が紛れていた。字面が同じでも別物として静かに横断集計から漏れる(ホモグリフは見た目が完全一致するので発見が遅れる)。
対策として栄養素キーの正規化機構を作った:
ホモグリフ検査(Cyrillic/Greek拒否)を保存時に強制
正規キーのマスター(nutrient_keys.yaml)+ alias定義
保存時に alias→正規キー自動変換、未知キーは「新規登録?」の確認UI(類似候補も提示)
監査スクリプトで既存キーの類似ペアを検出
これで手入力・AI下書き・編集のどの入口から来ても、栄養素キーが単一の正規化点を通るようになった。
6. AI分析パイプライン:日次・週次と、モデル切替で露呈した構造欠陥
二層のAI分析
日次インサイト — 毎朝、前日1日を分析。要約 / 観察 / 反対仮説 / アクション / 自然実験
週次インサイト — 毎週日曜08:00、直近7日を分析。トレンド / 転換点 / regimen実験の途中経過 / 環境メモ / 反対仮説 / 来週のアクション1つ
スキーマは Pydantic で強制し、contrarian_hypothesis(反対仮説)を必須フィールドにしている。デフォルト解釈への対抗仮説を必ず出させることで、単なる相関の追認を避ける。
プロンプトは config/prompts/*.txt として外部ファイル化した。これが後々効いて、「もっと睡眠に寄せる」「実験判定を厳しめに」といった調整が txt編集だけ・コード変更も再起動も不要になった。
モデル切替(DeepSeek V4 Flash)で一気に噴出した5つの構造欠陥
バックエンドを Qwen3.5-122B から Proxy経由の DeepSeek V4 Flash に本採用で切り替えたら、日次生成が失敗しだした。Discord通知は「vLLMに接続できません」と言っていたが、実際は接続成功していて、パース失敗だった。
ここで焦って対症療法を打たず、調査を挟んだのが正解だった。調査したら、フェンス問題は氷山の一角で、他に致命的な地雷が眠っていた。
フェンス付きJSON — DeepSeek は JSON を json ... で囲む癖がある。生JSONを期待する Pydantic 検証が line1col1 で弾く。Qwen では出なかったので潜在していた
真因はスキーマ強制の無効化 — enumトリックで検証したら、optional プロパティがあると vLLM が強制トークンをサイレントに無視して自由生成にフォールバックしていた。フェンスは症状で、根本はこれ。natural_experiment 等が optional だったのが原因
週次の NameError(時限爆弾) — 過去のコミットで変数を消したのに参照が残っていた。しかも DB insert 後・Discord配信前に落ちる → 再実行は「生成済み」でスキップ → 通知も来ず黙って永久欠落。通知機構すら効かない最悪の構造
max_tokens 切断 — reasoning_content が出力予算を食って途中で切れる
通知の誤誘導 — 原因を問わず「接続失敗」と表示する固定文言
修正方針:
スキーマ全required化(optional → str | None の required + nullable)で強制を復活。これが本丸
パース側サニタイズ(フェンス除去 + {〜}抽出)をバックエンド非依存の保険として多層防御
配信成功 → DB insert の順序反転で「黙って欠落」を構造的に根絶(日次にも横展開)
thinking無効化を Proxy 側ポリシーで注入して切断解消
通知のエラー型分類(接続失敗/パース失敗/検証失敗を区別)+ raw応答先頭200字ログ保存 + 連発抑制
教訓: エラーログから原因が「ほぼ」見えていても、モデル切替のような大きな変更直後は調査を挟む。フェンスだけ直していたら、7/19の週次で確実に落ちる時限爆弾を見逃していた。それと「同じUIパターン/コードパスは同じバグを持つ」——1箇所で見つけたら横断grepする。実際、数値inputの min/step 基準ズレは、catalog→regimen→override→JSプロパティ代入 と4層に渡って潜んでいた。
7. ダッシュボード:引き算とセクション分割
Body Scan で内臓脂肪・部位別・PWV・EDAと指標が増えたが、ダッシュボードに全部載せると煩雑化する。ここで方針を「足し算」ではなく「引き算」に定めた。
新指標は分析側(相関スキャン・月次)に回し、毎朝の画面はむしろ減らす
ダッシュボードのトップは「今日どう動くか」の判断に使う3指標(準備度・睡眠・回復力)だけ大きく
増えた指標は下段/別ページに逃がす
実装はセクション単位のコンポーネントに分割して、SECTIONS リスト1箇所で各セクションのON/OFFと順序を制御できるようにした。「スパークライン気に入らなければ1行で消せる」着脱容易性を最優先。7日トレンドは依存ゼロのインラインSVGスパークライン(グラフライブラリを足すと重いし削除も面倒)。
階層:
最上段 = 今日の判断(3カード + 昨夜のCO2)
中段 = 7日トレンド(スパークライン)
下段 = 体組成・環境の詳細
「昨夜のCO2」を上段に置いたのには根拠がある。相関分析で「就寝時CO2高 → 翌日パフォーマンス低下」が検出されたので、相関で発見した種を、ダッシュボードのトップで日々の意思決定に還元する流れ。1000ppm超は警告色。
8. 相関発見:探索は「表示」ではなく「データに探させる」
「意図もできていない相関を発見したい」という要望が出たとき、これはダッシュボードのレイアウト改善では解けないと判断した。相関発見は人間が数字を眺めて気づく作業ではなく、データに探索させる作業。
相関スキャンの試作
DuckDB で全日次指標を1つの wide テーブルに結合し、全ペアのPearson相関 + ラグ付き相関(-3〜+3日)を計算。|r|>0.4 かつ n>=20 でフィルタ。データ量は最大165日分・数万セルなので、全ペア+ラグ付きでも実行2.5秒。負荷は無視できる。
ここで実装が2つの落とし穴を自力で回避したのが良かった:
偽相関の除外 — 「resilience_score が13種のサプリと一律 r=-0.80」という偽相関を検知して除外。サプリ用量は regimen版が変わる時だけ動く区分定数なので、physiology と相関させると変更タイミングのアーティファクトになる。異なり値<5 のガードで機械的に潰した
機器段差の回避 — 体組成のジャンプ(機器変更)を相関に持ち込まないよう、Body Cardio 期のみで集計
本命の種:CO2 × 翌日パフォーマンス
はっきり出た本命が1つ。就寝時CO2 → 翌日の活動/睡眠/readiness低下(lag +1〜+2、r -0.4〜-0.55)。
複数の独立指標が揃って同じ向き(偶然なら1つだけ出るはず)
時間差(lag +1〜2)が因果の向きと整合
因果メカニズムが既知(高CO2→睡眠の質低下→翌日)
これを検証するのが、相関→介入検証のループ。「2週間、就寝前に換気する期間」を作って annotations に記録し、前後でCO2と翌日readinessを比較する——regimen の自然実験を環境変数でやる。
多重比較の毒にも注意が要る。変数が増えるほど偶然の相関が量産される。だから相関は仮説の種であって結論ではないと明示し、有意水準を厳しめに、n不足の指標は「データ待ち」と正直に言わせ、有望な種は自然実験で確かめる二段構え。
9. Withings Body Scan 移行:想定以上の収穫
Amazon セールで Withings Body Segment(日本市場名、実体は Body Scan)を購入。以前の調査どおり「内臓脂肪(meastype 170)が個人無料アカウントのAPIで取れるかは購入後に実測確認」がまさに検証フェーズになった。
移行は認証まわり何も変わらない(同じアカウントに新デバイスを足すだけ)。yuru側は新meastypeのパース追加だけ。
初回測定の検証で判明したこと:
meastype 170(内臓脂肪 2.6)がAPI制限なく取れた — 購入前のプランティアgating懸念は杞憂。当初構想の「日次プロキシ+年次CT較正」の日次側が追加作業ゼロで稼働開始
API上は modelid=10 “Body Scan” として振る舞い、PWV・EDA も送ってくる — 日本版は機能削減版のはずが、思ったより機能が残っていた(PWV は Body Cardio では削除された経緯があるので嬉しい誤算)
同日2台測定が偶然にも機器差の実測データになった — 体脂肪率 19.8%(Cardio) vs 16.2%(Scan)、3.6ptの差。これを annotations に「機器差であり実際の身体変化ではない」と記録
さらに副産物のバグも見つかった。water_pct 列の実体が kg だったので water_kg にリネーム。先週の週次が「水分率37.0%→35.8%」と語っていたのは、実は kg値を%と誤解していたことが判明。列名がそのままLLMへの意味情報になっているので、リネームだけで誤読が構造的に消える。
10. annotations UI:数値に出ない文脈をAIに渡す
Body Scan 移行で作った annotations.yaml を、手編集でなくUIで入れられるようにした。用途が広い——引っ越し・旅行・発熱・締切・機器変更など、regimen でも override でもない「この日はこういう外的文脈があった」をAIに教えるチャネル。
設計:
スキーマ {date, end_date(任意), category(任意), text}。end_date空=単日、有り=期間
入力は日付+テキストだけで完結、凝りたい時だけ end_date と category を足す
weekly/daily pack への取り込みはオーバーラップ判定(注記開始 ≤ 週末 AND 注記終了 ≥ 週頭)で、期間注記がまたがる全ての週/日に渡る
プロンプトに「注記は数値に現れない客観的文脈。交絡要因・背景として考慮し、原因と結果を取り違えない」を追記
「機器変更タグ」は作らなかった。機器変更はそれ自体がClaude Codeへの改修依頼を伴うので、そのついでにannotationsも書かせればいい。使われないタグは作らない(YAGNI)。
モバイルでの落とし穴も1つ:カテゴリ入力を <input list=datalist> にしたら Android Chrome で候補が出ない(datalistの相性問題)。自由入力はほぼ使わないので素直な <select> に置き換えた。
11. 障害耐性:GX10 熱暴走と「黙って欠落」の根絶
週次インサイトが日曜朝に届かなかった。調査したら、daemonは正常発火・パイプラインも正常で、AI呼び出しの依存先(GX10のvLLM)が熱暴走で落ちていた。7月の長野で122B常駐マシンが発熱したのが夏場に顕在化した。
これは単発事故でなく構造的問題。週次は日曜08:00という一点だけに依存するので、その瞬間にGX10が寝ていれば毎回死ぬ。対策:
リトライ(10分間隔×5回、最大50分待機。GX10の一時的スリープ/再起動をまたげる長さ)
最終失敗時のDiscord通知(Discord送信はvLLMと独立なので、GX10死亡時でも通知は届く)。無言の欠落を無くすのが主目的
リトライ間隔は VLLM_RETRY_INTERVAL 環境変数で調整可能に(テストは5秒、本番は10分)
さらに前述の「DB insert 後・配信前クラッシュで永久欠落」も、配信成功→insertの順序反転で構造的に潰した。これで「黙って欠落」は起きなくなり、失敗しても型分類された通知が来る。
12. 運用モデルと、振り返っての設計原則
完成した運用モデル
自動(放置でOK):
15分毎の4ソース収集
毎朝の日次インサイト(Discord)
毎週日曜08:00の週次振り返り(Discord)
日次Parquetスナップショット
天気の自動backfill
手動(これだけ):
毎朝Discordの前日インサイトを読む
スタック変更時にregimen更新(=AIの自然実験の起点)
月1でSwitchBot CSVインポート(1分CO2)
週1でVO2max用に散歩をワークアウト記録(任意)
すべてUIで完結:今日の調整(override)/ regimen入換 / catalog追加・編集(AI下書き付き)/ annotations。
効いた設計原則
raw-first(生データ全保存) — 全ての本番後デバッグ(天気・Oura各指標)が、raw保持のおかげで過去分ごと復元できた。パーサ系のバグはデータ損失なしに後追いで直せる
単一の真実源(single source of truth) — daily_intake_breakdown() を唯一の源とし daily_intake() はそこから派生。並列実装にすると表示とデータが乖離する
YAGNI徹底 — マルチユーザー、FK、registry.py、月次レポートの早期定常化、機器変更タグ…全部「必要になってから」。特に相関スキャンは「器を先に作らず、種の質を見てから月次化」を貫いた
二層測定アーキテクチャ — 「日次プロキシ+年次較正」(日次スケール + 年次CTで内臓脂肪の真値)。計器ノイズが大きい指標の受け皿
調査 → 方針確定 → 一括修正 — モデル切替の失敗で、対症療法に走らず調査を挟んだことで4つの隠れ地雷を同時に潰せた
同じパターンは同じバグを持つ — 1箇所で見つけたら横断grep。テンプレート属性だけでなくJSプロパティ代入も検索対象に含める
プロンプト外部化 — AIの挙動調整がtxt編集だけで済む。トーン(悲観・楽観の両振れ抑制、疑似科学的病名の禁止)もコード変更なしで直せる
不変性の原則 — regimen履歴・catalog・override は過去参照を壊さないよう「削除しない/item_id不変」。ただし誤操作対策として「直前バージョンのundo」だけは足す(不変性と両立する最小の逆操作)
アンチパターンとして記録しておくこと
full-day値をper-unitとして計上(ブレンドドリンク、omega3)——単位の粒度を最初に確定する
optionalプロパティによるスキーマ強制のサイレント無効化——構造化出力は全required化
insert後・配信前クラッシュによる無言欠落——外部配信は成功してからDB確定
ホモグリフ混入——自由入力キーは正規化点を通す
単一時刻依存の脆弱性——依存先が寝ていると毎回死ぬ。リトライと通知で耐える
おわりに
設計の壁打ちから始まって、4ソース構成 + pluggable + regimenモデルを固め、実装を委譲し、移行時の過去データ欠落・DBロック・casingバグ・二重計上・モデル切替の構造欠陥・熱暴走を順に潰し、本稼働 + ダッシュボード + 相関発見 + 障害耐性まで到達した。
いちばんの収穫は、raw-first と YAGNI という2つの原則が、想定していなかった局面で何度も自分を救ったこと。生データを全部持っていたから、後から気づいたバグを過去分ごと直せた。作らなかった機能があったから、身軽に方向転換できた。
そして「相関で種を見つけ、自然実験で確かめ、ダッシュボードで日々の判断に還元する」というループが回り始めた。CO2×睡眠の相関が、換気介入の検証につながり、ダッシュボードのトップに昇格する——このデータの流れができたのが、このシステムを「ただのトラッカー」から「自分でも気づかない傾向を教えてくれるもの」に変えた。
まだ内臓脂肪もPWVもBody Scan新指標も貯まり始めたばかり。数週間後に相関を再スキャンすれば、意図していない相関がまた出てくるはずだ。それが本番。
このシステムはローカル完結・個人利用。コードは private リポジトリ。健康データは一切外部に出していない(gitにはコードのみ、データはローカルのみ)。