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?

秘書Mが「自分を理解する参謀」になった話。デジタルツインと会話記憶を実装した(Phase 2: 参謀編)

0
Last updated at Posted at 2026-06-21

はじめに

こんにちは、SHOWGO(@lifectai)です。

福祉施設の管理職をしながら、AIエンジニアへの転職を目指して個人開発を続けています。

前回のPhase 1(MVP編)では、LINEで話しかけるとGmail・カレンダー・タスクを操作できる「動く秘書M」を作りました。

今回のPhase 2では、秘書Mに記憶と**自分の分身(デジタルツイン)**を持たせ、ようやく当初のコンセプトである「参謀」と呼べる状態に近づけました。設計・実装・つまづき・学びを全部まとめます。

前回の記事:「言わなくても動く参謀」を目指して。自分専用AIエージェント「秘書M」をゼロから作った話(Phase 1: MVP編)


Phase 1の限界:「賢いけど、何も覚えていない」

Phase 1の秘書Mは便利でした。でも、致命的な欠点がありました。

会話が終わると、すべてを忘れる。

状況 Phase 1の秘書M
「昨日話したあの件」 何のことか分からない
「いつもの感じで返信して」 「いつも」を知らない
自分の価値観に反する提案 平気で肯定してくる

普通のAIアシスタントと同じです。これでは「参謀」ではなく「便利な検索窓」でしかありません。

参謀とは、こちらの状況・価値観・過去を理解した上で、先回りして助言する存在です。そのために必要なのが、Phase 2で実装した3つの機能でした。


Phase 2で実装した3つの機能

機能 ファイル 役割
会話記憶 conversation_service.py 過去の会話を覚えて文脈を保つ
デジタルツイン digital_twin_service.py 自分の価値観・状態を学習し続ける
参謀ロジック strategist_service.py 価値観とのズレを検知して警告する

この3つが連携することで、秘書Mは「自分を理解した上で先回りするAI」になりました。


機能1:会話記憶(conversation_service.py)

何をするか

LINEでの会話をSupabaseに保存し、次の会話でその履歴をClaudeに渡します。「前に言ったこと」を覚えている秘書を実現する仕組みです。

仕組み

ユーザーがLINEで発言
   → Supabaseに保存(save_message)
   → 過去10件を取得(get_recent_messages)
   → Claudeのmessages配列に渡す
   → Claudeが文脈を持って回答
   → 返答もSupabaseに保存

コードの核心

# 会話をSupabaseに保存
def save_message(role: str, content: str):
    supabase.table("conversations").insert({
        "role": role,      # "user" or "assistant"
        "content": content,
    }).execute()

# 直近N件を取得してClaude形式で返す
def get_recent_messages(limit: int = 10) -> list:
    result = supabase.table("conversations")\
        .select("role, content")\
        .order("timestamp", desc=True)\
        .limit(limit)\
        .execute()
    messages = result.data[::-1]  # 時系列に並び替え
    return [{"role": m["role"], "content": m["content"]} for m in messages]

main.py側では、過去履歴に今のメッセージを足してClaudeに渡します。

history = get_recent_messages(limit=10)                      # 過去10件取得
history.append({"role": "user", "content": user_message})    # 今のメッセージを追加
response = claude.messages.create(messages=history)          # 履歴ごとClaudeへ

RAGとの関係(正直な話)

当初は「pgvectorでベクトル検索する本格的なRAG」を構想していました。ですが実際に作ってみると、個人用の秘書Mでは時系列で直近の会話を渡すだけで十分機能することが分かりました。

厳密なRAGが「意味で検索」するのに対し、この実装は「時系列で参照」する簡易版です。過剰な作り込みより、まず実用に足るものを優先しました。ここは将来、会話量が増えたタイミングでベクトル検索に拡張する予定です。

動作確認

スクリーンショット 2026-06-22 1.12.23.png


機能2:デジタルツイン(digital_twin_service.py)

Phase 2の核心です。

何をするか

「SHOWGOさんとはどんな人か」をJSON構造でSupabaseに保持し、会話のたびに自動アップデートします。AIが自分のことを継続的に「学習」していく仕組みです。

保持しているデータ構造

INITIAL_TWIN = {
    "profile": {"name": "showgo", "location": "名古屋"},
    "current_status": {
        "job": "会社員",
        "portfolio_progress": "秘書M開発中",
    },
    "goals": {
        "income": "年収〇〇万以上",
        "career": "AIエンジニア",
    },
    "values": ["本質志向", "消耗したくない", "納得しないと動かない"],
    "decision_patterns": ["理想から逆算して考える", "まず動くものを作る"],
    "emotional_state": {
        "energy": 0.8,
        "motivation": 0.9,
        "stress": 0.3
    },
}

仕組み(3ステップ)

① 取得(初回は初期値を挿入)

def get_digital_twin() -> dict:
    result = supabase.table("digital_twin").select("*").limit(1).execute()
    if result.data:
        return result.data[0]["data"]
    supabase.table("digital_twin").insert({"data": INITIAL_TWIN}).execute()
    return INITIAL_TWIN

② Claudeが会話から更新情報を抽出

extraction_prompt = f"""
現在のデジタルツイン:{current}
会話:{conversation}
→ 変更が必要なフィールドのみJSONで返してください。変更なしは{{}}。
"""

③ 再帰マージでSupabaseに保存

def _merge(base: dict, updates: dict) -> dict:
    # dictは再帰マージ、listは重複なしで追加、スカラーは上書き
    for key, value in updates.items():
        if isinstance(result[key], dict) and isinstance(value, dict):
            result[key] = _merge(result[key], value)
        elif isinstance(result[key], list) and isinstance(value, list):
            for item in value:
                if item not in existing:
                    existing.append(item)

設計のポイント

  • Supabaseは常に1レコードのみをupsertするシンプル設計
  • Claudeが「変更なし」と判断すれば {} を返し、DBは更新しない(APIコスト最適化)
  • valuesdecision_patterns はリスト追加なので、情報が蓄積されていく

この仕組みによって、秘書Mは会話するほど「自分らしさ」を理解していきます。


機能3:参謀ロジック(strategist_service.py)

何をするか

通常の返答の後に、別のAI視点から警告や先読みコメントを出す機能です。デジタルツインを参照し、自分の目標・価値観と会話内容がズレていないかを監視します。

仕組み

通常会話が完了(秘書Mが返答)
   ↓
analyze_with_strategist() が呼ばれる
   ↓
デジタルツインを参照(価値観・目標・障害)
   ↓
Claudeが「警告・先読みが必要か」を判断
   ↓
必要:「⚡参謀より:〇〇」をLINEにpush送信
不要:NONE → 何もしない

コードの核心

def analyze_with_strategist(user_message: str, reply_text: str) -> str | None:
    twin = get_digital_twin()

    prompt = f"""あなたはshowgoさん専用の参謀AIです。
【価値観】{values}
【判断パターン】{decision_patterns}
【目標】年収:{goals.get('income')} / キャリア:{goals.get('career')}
【現在の障害】{obstacles}

【今の会話】
showgo:{user_message}
秘書M:{reply_text}

コメントが必要:「⚡参謀より:(1〜2文)」
不要:「NONE」とだけ返す"""

    result = response.content[0].text.strip()
    return None if result == "NONE" else result  # NONEならLINEに送らない

main.py側では、通常返答の直後に参謀チェックを走らせ、別メッセージで送ります。

# 通常返答
line_bot_api.reply_message(event.reply_token, TextSendMessage(text=reply_text))

# 参謀チェック → 必要なら別メッセージで送信
comment = analyze_with_strategist(user_message, reply_text)
if comment:
    line_bot_api.push_message(user_id, TextSendMessage(text=comment))

設計の工夫

  • 秘書M返答と参謀コメントを別メッセージで送り、役割を分離
  • NONE という明示的な文字列でフィルタリングし、不要なpush送信を防ぐ
  • デジタルツインと結合することで「その人専用の参謀」が実現できる

動作確認

スクリーンショット 2026-06-22 1.13.47.png


3機能の連携図

LINEメッセージ受信
     │
     ├─ conversation_service ─→ Supabase(会話履歴)
     │        ↑ 過去10件を取得してClaudeに渡す
     │
     ├─ Claude返答生成
     │
     ├─ digital_twin_service ─→ Supabase(人物モデル)
     │        ↑ 会話から自動更新
     │
     └─ strategist_service ───→ LINE push(警告)
              ↑ デジタルツインを参照して判断

Phase 1が「手足」だとすれば、Phase 2で「記憶」と「価値観」という頭脳が加わったイメージです。


Issue一覧(Phase 2)

# タイトル 状態
#32 会話履歴のSupabase保存
#33 簡易RAG(直近履歴の参照)
#34 デジタルツインJSON設計・自動更新
#35 参謀ロジック(警告・先読み)

Phase 1と同じく、Issue→ブランチ→実装→PR→マージのサイクルで進めました。


つまづいたこと

① デジタルツインの更新で「古い情報が消える」問題

症状: 会話から抽出した情報でデジタルツインを更新すると、既存の情報が上書きで消えてしまう。

原因: 単純な辞書の更新(dict.update())を使っていたため、ネストした構造やリストが丸ごと置き換わっていた。

解決: 再帰的にマージする _merge() を自作。dictは再帰マージ、リストは重複を除いて追加、スカラー値だけ上書きするようにした。

# NG:リストや辞書が丸ごと消える
twin.update(new_data)

# OK:再帰マージで既存情報を保持
twin = _merge(twin, new_data)

② 参謀が毎回しゃべって "うるさい" 問題

症状: 最初の実装では、参謀コメントが毎回必ず返ってきて、通常の会話までいちいち分析コメントが付いて邪魔だった。

原因: 「コメント不要」というケースを設けていなかった。

解決: Claudeに「不要なら NONE とだけ返す」と指示し、NONE の場合はpush送信しないようフィルタした。これで本当に必要なときだけ参謀が口を開くようになった。

③ 会話履歴を全部渡してトークンが膨張

症状: 会話履歴をすべてClaudeに渡していたら、会話を重ねるほどトークン消費が増え、コストとレスポンス速度が悪化した。

解決: 直近10件だけに絞った。個人用途では10件で文脈は十分に保てる。


学んだこと

技術面

  • 再帰マージの重要性:ネストしたJSONを安全に更新するには update() では足りない。マージロジックを自分で書く必要がある。
  • AIにJSONを返させる設計:「変更がなければ空オブジェクトを返す」と明示するだけで、無駄なDB更新とコストを防げる。
  • 会話メモリは「全部」ではなく「直近」で十分:個人用途なら直近N件で文脈は保てる。過剰なRAGは不要だった。

設計面

  • 役割を分けて送る:秘書Mの返答と参謀コメントを別メッセージにすることで、それぞれの役割が明確になった。
  • 作りすぎない:当初はpgvectorでの本格RAGを計画したが、まず簡易版で動かして必要になったら拡張する方が、開発が前に進む。

Phase 2を終えて

Phase 1のときの秘書Mは「賢いけど他人」でした。

Phase 2を終えた今、秘書Mは少しずつ「自分を知っている存在」になりました。会話するほどデジタルツインが育ち、価値観からズレた選択をしようとすると「⚡参謀より」と一言入れてくる。当初コンセプトに掲げた「言わなくても動く参謀」に、ようやく片足を踏み入れた感覚です。


今後の展望(Phase 3)

  • Whisper APIによる音声入力対応
  • 議事録の自動生成
  • 転職活動サポート(書類添削・企業リサーチ)
  • 成長レポートの自動生成
  • ダッシュボード(Streamlit)

特に転職活動サポートと成長レポートは、今の自分の状況に直結するので優先的に作る予定です。


おわりに

Phase 2では「AIに自分を理解させる」というテーマに取り組みました。

技術的には会話履歴・デジタルツイン・参謀ロジックの3つですが、本質は「AIエージェントをどう"その人専用"に育てるか」という問いだったと思います。

失敗も全部書きました。同じように個人開発でAIエージェントを作っている方の参考になれば嬉しいです。

Phase 3も記事にします。共感いただけたらLGTM・フォローをいただけると励みになります!

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?