0
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?

Claudeが予定を捏造しなくなった日 ― Google Calendar APIでground truthをVaultに注入する

0
Posted at

前作「会話ログをスキルに変える仕組み」で触れた Vault 自動 ingest の続きです。今回は、Claudeが日付範囲を「期間拘束」と読み誤って架空のスケジュールを生成していた問題を、Google Calendar API でカレンダーの実体を直接注入することで潰した話を書きます。

daily brief の中で Claude が「Breach実習 6/11〜」というメモを「その週は丸ごと拘束中」と解釈し、「6/11以降は身動きが取れないため〇〇を前倒しすべき」と提案し続けていました。ノートの散文に書かれた日付を、Claude が「その期間ずっと予定でふさがっている」と読んでいたのが原因です。(vault-auto-ingest.sh のコメントに (2026-06-11 Breach実習)対策 と残っています。)

困りごと:散文の日付範囲 ≠ カレンダー上の拘束

Vault(Obsidian)に蓄積されたノートには「〇〇実習 M/D〜M/D」のように期間が散文で書かれています。Claude はこれを読んで daily brief を作りますが、「開始点と終了点の2つの日付」を「その間すべて拘束中」と解釈する誤読が頻発していました。

根本的な問題は、Claude が参照できる「いつ何があるか」の情報が、ノートの散文しかなかったことです。カレンダーアプリの実データ(タイトル・開始時刻・終了時刻)は、Vault には一切存在しませんでした。

「ノートに日付が書いてある = その期間ずっと拘束中」という推論は、LLMが自然言語から文脈を補完しようとする結果です。情報が曖昧なほどLLMは補完しようとし、補完した内容が現実と乖離する。正しい予定情報を明示的に与えるしかありません。

解法:Google Calendar API で「正典スケジュール」を生成する

答えはシンプルです。毎朝 Google Calendar API から今後21日分の予定を取得して Markdown に変換し、それを daily brief のプロンプトに「このファイルを唯一の正典スケジュールとして読め、ノートの散文より優先しろ」と明示注入する。

パイプラインは3段構成です。

  1. gcal-snapshot.py ― Google Calendar API でイベント取得・Markdown変換
  2. vault-auto-ingest.sh ― last-known-good温存ロジックで _calendar-snapshot.md に保存
  3. claude -p による daily brief 生成 ― CAL_SNAPSHOT_NOTE をプロンプトに注入

gcal-snapshot.py:ADC認証・全カレンダー自動列挙・重複dedup

認証には gcloud ADC(Application Default Credentials) を使います。OAuth Desktop App の credentials.json とトークンファイルを持ち歩く方式ではなく、gcloud auth application-default login --scopes=https://www.googleapis.com/auth/calendar.readonly を一度実行するだけで google.auth.default() がそのまま動く。TCC(macOS プライバシー保護)も通らず、launchd から無人実行できるのが決め手でした。

スクリプト全体の骨格はこうなっています。

#!/usr/bin/env python3
"""
Google カレンダー実体スナップショット生成(headless / launchd 可)。
- 認証: gcloud ADC(calendar.readonly スコープ)。MCP/TCC 不要 → 無人実行可。
- アカウント配下の全カレンダーを自動列挙(新規追加も自動で拾う)。
- ノイズ除外: SNS投稿管理カレンダー・日本の祝日。
- 重複dedup: (タイトル+開始時刻) が同じイベントは1件に畳む。
- 出力: stdout に Markdown 本文(イベント1件以上で正常終了0、0件/失敗は非ゼロ)。
"""
import sys
from datetime import datetime, timedelta, timezone

JST = timezone(timedelta(hours=9))
HORIZON_DAYS = 21

# カレンダーID(部分一致)でのブロックリスト
BLOCK_CAL_SUBSTR = (
    "9ea30ef811a373ec8fe120f0633630c8685127f65fee2cb122c7b625ffdbd954",  # SNS投稿管理(日次)
    "24d111d02338025b6a82ef07f6971ce30b3e0624c6620ee73d9f88c123adc47f",  # SNS投稿管理(管理用)
    "holiday@group.v.calendar.google.com",                                # 日本の祝日
)

def get_service():
    import google.auth
    import googleapiclient.discovery as disc
    creds, _ = google.auth.default(
        scopes=["https://www.googleapis.com/auth/calendar.readonly"]
    )
    return disc.build("calendar", "v3", credentials=creds, cache_discovery=False)

全カレンダーを calendarList().list() で自動列挙してから、ブロックリストに部分一致するIDをスキップします。新しいカレンダーを追加しても自動的に対象になり、管理コストがゼロです。

イベント取得と重複dedup はこう動きます。

timed, allday, seen = [], [], set()
for c in cals:
    cid = c.get("id", "")
    if any(s in cid for s in BLOCK_CAL_SUBSTR):
        continue
    try:
        events = svc.events().list(
            calendarId=cid, timeMin=tmin, timeMax=tmax,
            singleEvents=True, orderBy="startTime", maxResults=250,
        ).execute().get("items", [])
    except Exception:
        continue  # 個別カレンダー失敗は無視(他で補う)
    for ev in events:
        if ev.get("status") == "cancelled":
            continue
        title = (ev.get("summary") or "(無題)").strip()
        st, en = ev.get("start", {}), ev.get("end", {})
        if "dateTime" in st:  # 時間ブロック予定
            s = datetime.fromisoformat(st["dateTime"]).astimezone(JST)
            key = (title, s.isoformat())
            if key in seen:
                continue          # ← dedup: (タイトル+開始時刻) で重複排除
            seen.add(key)
            # ...行フォーマット
        elif "date" in st:        # 終日(締切・期間)
            s = datetime.fromisoformat(st["date"])
            e = datetime.fromisoformat(en["date"])
            span = e - s
            if span.days <= 1:
                label = f"{s.month}/{s.day}"
            else:
                last = e - timedelta(days=1)  # 終日endは翌日0時=排他
                label = f"{s.month}/{s.day}-{last.month}/{last.day}"

dedup が必要な理由: 複数のカレンダー(個人用・大学用・共有カレンダー)に同じ予定が登録されていると、APIは同一イベントを重複して返します。(タイトル, 開始時刻) をキーにした seen セットで、同一イベントを1件に畳みます。

出力は • 7/15(火) 10:00-12:00 〇〇実習 @〇〇大学 形式の Markdown。時間ブロックと終日(締切・期間)を別セクションに分けることで、Claude が「今日の拘束時間」と「締切日」を混同しないようにしています。

スクリプトは「イベント0件または失敗 → 非ゼロ終了」の設計で、呼び出し側が成否を判定できます。

vault-auto-ingest.sh:last-known-good温存ロジック

gcal-snapshot.py を呼ぶ側の bash がこの部分です(vault-auto-ingest.sh の step 2.45)。

# 2.45 カレンダー実体スナップショット(ground truth)
CAL_SNAPSHOT="$VAULT/wiki/_calendar-snapshot.md"
CAL_LASTGOOD="$CAL_SNAPSHOT.lastgood"
CAL_TMP="$(mktemp)"
CAL_SRC=""

# (1) 第一候補: Google Calendar API
run_to 90 "$PY3" "~/.claude/scripts/gcal-snapshot.py" > "$CAL_TMP" 2>/dev/null \
  && /usr/bin/grep -q "•" "$CAL_TMP" 2>/dev/null && CAL_SRC="gcal-api"

# (2) フォールバック: icalBuddy(ローカル Apple Calendar)
if [ -z "$CAL_SRC" ]; then
  ICALBUDDY=""
  for c in /opt/homebrew/bin/icalBuddy /usr/local/bin/icalBuddy; do
    [ -x "$c" ] && ICALBUDDY="$c" && break
  done
  if [ -n "$ICALBUDDY" ]; then
    {
      echo "# カレンダー実体スナップショット(今後21日・自動生成)"
      echo "_generated $(date '+%F %T') by vault-auto-ingest.sh / icalBuddy(fallback)_"
      run_to 60 "$ICALBUDDY" -n -nc -iep "title,datetime,location" \
        -po "datetime,title" eventsToday+21
    } > "$CAL_TMP" 2>/dev/null
    /usr/bin/grep -q "•" "$CAL_TMP" 2>/dev/null && CAL_SRC="icalbuddy"
  fi
fi

if [ -n "$CAL_SRC" ]; then
  cp "$CAL_TMP" "$CAL_SNAPSHOT"
  cp "$CAL_SNAPSHOT" "$CAL_LASTGOOD"           # ← 成功したら last-good を更新
elif [ -s "$CAL_LASTGOOD" ]; then
  # 取得失敗: 前回goodを温存し stale 印で上書き
  {
    echo "# カレンダー実体スナップショット(今後21日・自動生成)"
    echo "_generated $(date '+%F %T') by vault-auto-ingest.sh_"
    echo "⚠️ 本日カレンダー取得失敗。以下は前回取得成功時点の値(stale)。"
    tail -n +4 "$CAL_LASTGOOD"
  } > "$CAL_SNAPSHOT"
else
  { echo "# カレンダー実体スナップショット(今後21日・自動生成)"
    echo "(取得失敗・前回値なし)"; } > "$CAL_SNAPSHOT"
fi

3段フォールバックの設計意図:

優先度 ソース 条件
Google Calendar API (gcal-snapshot.py / ADC) 常に試みる
icalBuddy(Apple Calendar ローカル) API失敗時。対話実行時のみ権限あり
last-known-good(前回成功値をstale印で温存) 両方失敗時

重要なのは「失敗してもスナップショットを空で潰さない」点です。ADC認証が期限切れで API が落ちても、前回の正しいスナップショットを stale 印付きで残します。Claude は ⚠️ stale の印があれば「これは古い情報」と判断するようにプロンプトで指示してあります。

daily briefプロンプトへの注入

カレンダースナップショットを取得した後、以下の文字列を CAL_SNAPSHOT_NOTE として組み立てます。

CAL_SNAPSHOT_NOTE="予定・締切は ${CAL_SNAPSHOT}(カレンダー実体・時刻付き)を唯一の正典スケジュールとして読み、ノートの散文より優先しろ。冒頭に⚠️staleとあれば取得日時点の値である点を断れ。"

これを daily brief 生成の claude -p プロンプトに直接埋め込みます(抜粋)。

cd "$VAULT" && run_to 900 "$CLAUDE" -p \
"...【厳守】ノートに無い情報を推測で足すな。特に予定の拘束時間・所要日数・
『日中が消える』等の時間コストは、実際の予定記述が無い限り書くな。
ノート中の『M/D〜M/D』は開始と終了の点であって全期間拘束ではない——
拘束日数や所要時間を捏造するな。日付・時間に関する主張は確定情報と推測を
明示的に分けろ。${CAL_SNAPSHOT_NOTE}
出力は $VAULT/wiki/today-brief.md に毎回上書き..." \
  --dangerously-skip-permissions >> "$LOG" 2>&1

「ノートの散文より優先しろ」という言葉が効いています。これを書く前は、Claude は Vault の記事を総合的に参照して予定を「補完」しようとしていました。正典を1ファイルに固定して明示的に優先させることで、補完を抑制できました。

「唯一の正典」という言葉をプロンプトに明示するのが効果の核心です。「参考にしろ」では Claude は他の情報源と統合しようとします。「これだけを信じろ」と言い切ることで、散文からの誤推論が止まります。

launchd:4スロット自己回復型

com.shun.vault-auto-ingest.plist は4つの時刻スロットで発火します。

<key>StartCalendarInterval</key>
<array>
  <dict><key>Hour</key><integer>4</integer><key>Minute</key><integer>55</integer></dict>
  <dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>20</integer></dict>
  <dict><key>Hour</key><integer>10</integer><key>Minute</key><integer>45</integer></dict>
  <dict><key>Hour</key><integer>12</integer><key>Minute</key><integer>15</integer></dict>
</array>

4:55 に起動してスリープ中だった場合、MAC が目覚めた 8:20 に再起動します。bash 側に DONE_MARKER があり、「本日分が成功済みなら即終了(exit 0)」と書いてあるため、4スロットすべてが発火しても実行は1回だけです。

「成功するまで自動再試行、成功後は空振り」が基本思想で、ADC認証の一時的な失敗・スリープによる凍結・ネット未接続などのどれが起きても翌スロットで自己回復します。

踏んだ落とし穴

  • OAuth Desktop App を最初に試みたが launchd から動かなかった → credentials.json + token.json のパスが launchd の最小環境では通らない。gcloud ADC(gcloud auth application-default login)に切り替えたら即解決。ただし ADC の calendar.readonly スコープは --scopes フラグで明示する必要がある。

  • icalBuddy フォールバックが launchd から権限なしで静かに0件を返す → 対話実行なら Apple Calendar への TCC 権限があるが、launchd からは付与されていない。grep -q "•" で内容を確認してから採用する判定が不可欠。空のファイルをスナップショットとして上書きするとカレンダーが「予定なし」に見える。

  • 終日イベントの end.date は排他(翌日0時) → Google Calendar API の仕様。1日のイベントは start.date: 2026-07-15, end.date: 2026-07-16。表示ラベルでは end - timedelta(days=1) して最終日を出す。これを知らないと「7/15〜7/16の2日間」と誤表示される。

  • 複数カレンダーに同一イベントが登録されていると重複する → 大学の時間割カレンダーと個人カレンダーに同じ授業が入っているケースがあった。(タイトル, 開始時刻)seen セットでdedup するまで同一授業が2〜3行出力されていた。

  • ADC トークンは90日でリフレッシュが必要な場合があるgcloud auth application-default login は長期間経つと再認証が必要。スクリプトが AUTH_ERROR を stderr に吐いたら再認証のサイン。last-good 温存のおかげでサービスは止まらないが、_calendar-snapshot.md⚠️ stale が出続けたら疑う。

まとめ

  • Claude が日付範囲を「期間拘束」と読み誤るのは、正確なスケジュール情報がなくてノートの散文を補完しようとするから
  • gcal-snapshot.pygcloud ADC + google.auth.default() で headless・launchd 対応。全カレンダー自動列挙・ブロックリスト・重複dedup を込み
  • 取得失敗時は last-known-good を stale 印で温存し、スナップショットを空で潰さない
  • daily brief プロンプトに「唯一の正典スケジュール・ノートの散文より優先」と明示注入するのが効果の核心
  • launchd は 4スロット自己回復型(done マーカーで二重実行を防ぎ、失敗時は次スロットが再試行)

次回は、このパイプラインから派生して止めたくなった stop hook 暴走 ―― コスト急騰の原因をトランスクリプトで特定して修正した話を書きます。


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!

0
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
0
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?