はじめに
おっさんになると、仕事の予定は放っておいても増えるのに、遊びの予定は「いつかやる」のまま残りがちです。
仕事やPC作業は何年後でも続けられるかもしれない。一方で、身体を使う趣味やアクティビティは、体力、回復力、季節によって実行の難しさが変わります。
明確な「限界年齢」があるわけではありません。それでも、先送りするほど始めにくくなる遊びはある。いわば、体力の賞味期限を意識し始める時期です。
そこで、Google Takeout、X/Twitterの公式アーカイブ、Amazon・楽天の購買履歴をローカルで統合し、過去の関心や生活の偏りから「今のうちに試したい遊び」を提案するPersonal Twin型エージェントを作りました。
予定の空き時間へタスクを詰めるスケジューラではありません。
過去の自分を材料にして、忘れていた趣味や、今の習慣から一歩だけ外側にあるアクティビティを出してもらう仕組みです。
解決したかったのは、次の問題です。
- 仕事や画面内活動のログが多く、遊びの候補が埋もれる
- 「いつかやりたい」が予定に変わらない
- 一人で完結する活動へ偏りやすい
- 検索、投稿、予定、購買が別サービスに分散している
- LLMへ生の個人データを渡したくない
- AIの提案理由を追跡できるようにしたい
- 遊びを増やしすぎず、体力やリスクに合わせて選びたい
そこで、ログを共通形式へ正規化し、観測された関心と本人が明示した価値観を分けて扱う構成にしました。単に「活動量を増やす」のではなく、何を試し、何を見送り、誰と行うかまで候補にします。
この記事では、現在動いているMVPの実装を紹介します。コアの順位付けはLLMではなく、説明可能性を優先したルールベースです。LLMは将来的な候補拡張や文章化に限定して使う想定です。
中年の「いつかやる」を回収したかった
過去ログには、以前から気になっていた趣味、買った道具、調べた場所が残っています。
ところが、普段のTodoリストを開くと、締切のある用事が先に並びます。遊びには締切がないため、緊急ではないものとして後ろへ送られます。
しかし、仕事の締切とは別に、遊びにも「機会の窓」があります。
- 季節が合う時期
- 身体が動く時期
- 一緒に行ける友人がいる時期
- すでに始めた習慣から次へ進みやすい時期
この窓を、過去ログから見つけられないかと考えました。
最初の提案は少し大胆すぎた
最初に試したとき、エージェントは過去ログに多く残っていたスノーボードへの関心から、バックカントリーを候補に出しました。
提案としては面白い。しかし、自分にとってはいきなり難度が高い。中年の危機を感じたからといって、急に山へ突っ込めばよいわけではありません。
そこで、候補をそのまま採用するのではなく、すでに関心があり、今の習慣からも接続しやすいオフトレのジャンプ練習を選びました。
AIの初期提案: バックカントリー
人間の判断: 面白いが、準備とリスクが大きい
選び直した行動: オフトレのジャンプ練習
このとき分かったのは、AIに正解を決めてもらう必要はないということです。
自分だけで考えると、過去にやってきたことの延長へ寄りやすい。AIが少し外側の候補を出し、人間が自分の体力、経験、リスク許容度に合わせて選び直す。その往復に価値がありました。
別のレビューでは、一人で完結する活動が多いことに対して、「次のアクティビティは友人と一緒に行う」「まず友人へ軽く連絡する」という候補も出ました。
運動の回数だけを最適化するなら、一人で予定を入れれば済みます。しかし生活全体を見るなら、同じ活動でも誰かと行うことで、人間関係の維持にもつながります。
提案されたのは、派手な人生改革ではありませんでした。
少し難しすぎる遊びは、自分に合う一歩へ縮める
一人の活動が続いたら、次は友人を誘う
疲れている週は、新しい予定を増やさない
このくらいの提案なら、生活を乗っ取られずに使えます。
こうした提案を出すには、単なるタスク件数だけでなく、関心、予定、購買、本人の価値観を分けて扱う必要がありました。
全体構成
年間候補の生成と週次レビューでは、参照するデータ経路が異なります。
┌─> 集計CSV / Markdown ─> 年間候補の採点
Google Takeout ─┐ │
X全アーカイブ ─┼─> 正規化 ┤
Amazon購入履歴 ┤ │
楽天購入履歴 ──┘ └─> SQLite + FTS5 ─> 検索 / 週次レビュー
本人の価値観JSON ─────────────────────────> 年間候補の採点
年間候補 ─> Markdown / JSON ─> dry-run ─> Todoist
主要ファイルは以下です。
personal_twin_rag/
├── purchase_normalizer.py
├── build_index.py
├── life_window_goals.py
├── life_balance_review.py
├── push_life_window_goals_to_todoist.py
├── life_window_profile.json
├── life_bias_rules.json
├── data/
│ ├── normalized/
│ └── personal_memory.sqlite
└── output/
1. 購買履歴を共通イベントへ正規化する
Amazonと楽天ではCSVの列名が異なります。入力時に候補列を定義し、購入日、商品名、価格、店舗を共通形式へ変換します。
FIELD_CANDIDATES = {
"date": ["date", "order_date", "注文日", "購入日"],
"title": ["title", "product_name", "商品名", "品名"],
"price": ["price", "total", "amount", "金額", "価格"],
"shop": ["shop", "merchant", "store", "店舗", "ショップ"],
}
商品名はキーワードで生活テーマへ分類します。
THEME_KEYWORDS = {
"outdoor": ["アウトドア", "防水", "ヘルメット"],
"health": ["トレーニング", "ストレッチ", "プロテイン"],
"software": ["キーボード", "SSD", "Python", "PC"],
"travel": ["旅行", "スーツケース", "モバイルバッテリー"],
}
def infer_theme(text: str) -> str:
haystack = (text or "").lower()
for theme, keywords in THEME_KEYWORDS.items():
if any(keyword.lower() in haystack for keyword in keywords):
return theme
return "other_purchase"
生の価格は残さず、価格帯へ丸めます。
def price_band(price: float | None) -> str:
if price is None:
return "unknown"
if price < 3_000:
return "low"
if price < 20_000:
return "medium"
return "high"
最終的な共通イベントは次の形です。
event = {
"date": normalized_date,
"source": "amazon_purchase",
"theme": infer_theme(product_title),
"title": sanitized_title,
"summary": summary_for_theme,
"price_band": price_band(price),
"repeat_type": "repeat" if purchased_more_than_once else "single",
"privacy_level": "private_summary",
}
正規化を実行します。
python3 personal_twin_rag/purchase_normalizer.py
出力はAmazonと楽天で共通の purchase_events.csv になります。
2. SQLiteとFTS5を個人メモリにする
初期版ではベクトルDBを必須にせず、SQLiteとFTS5を採用しました。
理由は次の通りです。
- SQLで根拠を確認できる
- 単一ファイルでバックアップできる
- 日付や情報源で絞り込みやすい
- 全文検索だけなら追加インフラが不要
- 誤提案時に参照データを監査しやすい
スキーマは単純です。
CREATE TABLE memory (
id INTEGER PRIMARY KEY,
timestamp TEXT,
source TEXT NOT NULL,
source_path TEXT,
url TEXT,
theme TEXT,
title TEXT,
text TEXT NOT NULL,
tags TEXT,
privacy_level TEXT DEFAULT 'normal'
);
CREATE VIRTUAL TABLE memory_fts USING fts5(
title,
text,
tags,
source,
theme,
content='memory',
content_rowid='id',
tokenize='unicode61'
);
Xの投稿、正規化済み購買イベント、Google Takeout由来の集計タイムライン、ローカルのMarkdownを同じテーブルへ投入します。
def insert_memory(
conn,
*,
timestamp: str,
source: str,
source_path: str,
theme: str,
title: str,
text: str,
tags: str = "",
privacy_level: str = "normal",
) -> None:
conn.execute(
"""
INSERT INTO memory(
timestamp, source, source_path, theme,
title, text, tags, privacy_level
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
timestamp, source, source_path, theme,
title, text, tags, privacy_level,
),
)
インデックスを再構築します。
python3 personal_twin_rag/build_index.py
3. ログと価値観を別の入力にする
この仕組みで最も重要な設計です。
投稿数、検索数、購入回数が多いテーマを、そのまま本人の優先順位にすると、過去に時間を使った活動ばかりが上位になります。
そこで、本人が明示した価値観を別JSONへ保存します。
{
"priority_notes": [
"時間制約のある経験を先送りしない",
"すでに始めた習慣から隣接する一歩を選ぶ",
"負荷と回復を両方評価する"
],
"interests": [
"outdoor",
"health",
"learning"
]
}
役割を分けると次のようになります。
| 入力 | 意味 |
|---|---|
| Google・X・購買ログ | 過去に時間やお金を使った対象 |
| Calendar・Tasks | 予定・約束・実行の痕跡 |
| 価値観JSON | これから守りたい方向 |
| バイアスルール | 偏ったときの補正方法 |
ログは「これまで何をしてきたか」という観測値、価値観JSONは「これから何を大切にしたいか」という目的関数です。
AIの提案が合わなかった場合は、候補だけを削除せず、なぜ合わなかったかを価値観や制約へ戻します。
バックカントリーの例なら、単に禁止ワードへ入れるのではなく、次のように一般化します。
新規性だけで順位を上げない
すでに始めている習慣からの隣接行動を優先する
準備コストと安全面を評価する
撤退条件を説明できない候補は上位へ出さない
一方、アクティビティが一人で完結し続けている場合は、関係維持の補正ルールを使います。
{
"name": "relationship_preservation_bias",
"description": "一人で完結する活動が続き、人と会う記録が少ない",
"correction": "次の活動を友人と行うか、まず軽く連絡する候補を出す"
}
4. 候補を構造化して採点する
現行MVPでは、候補テンプレートをPythonで定義し、集計データで順位付けします。
from dataclasses import dataclass, field
@dataclass
class GoalCandidate:
title: str
category: str
themes: list[str]
why_now: str
expected_value: str
counter_scenario: str
minimum_experiment: str
retreat_condition: str
output_artifact: str
age_window: str
body_cost: str
regret_if_missed: str
compound_effect: str
user_priority_boost: float = 0.0
score: float = 0.0
evidence: list[str] = field(default_factory=list)
採点式は説明可能性を優先しています。
score =
過去ログとのテーマ一致
+ 時間・季節による緊急度
+ 見逃した場合の後悔コスト
+ 継続した場合の複利
+ 本人が明示した優先度
- 身体・準備コスト
実装では項目ごとの点数を関数に分けています。
def score_candidates(candidates, evidence):
for candidate in candidates:
candidate.score = round(
theme_score(candidate, evidence)
+ time_urgency(candidate)
+ qualitative_points(candidate),
1,
)
attach_evidence(candidate, evidence)
return sorted(candidates, key=lambda c: c.score, reverse=True)
候補にはスコアだけでなく、必ず以下を持たせます。
- 今やる理由
- 期待値
- 反対シナリオ
- 最小実験
- 撤退条件
- 根拠となったテーマや記録
採用数も最大3件に制限します。
A枠: 時間制約があり、見逃したくないもの
B枠: 継続すると複利が効くもの
C枠: 既存資産を成果物へ変えるもの
5. 週次レビューで「追加しないこと」を決める
年間候補とは別に、SQLiteから対象週のイベントを集計します。
SCREEN_THEMES = {
"software",
"research",
"productivity",
}
LIFE_THEMES = {
"outdoor",
"health",
"travel",
"social",
}
画面内活動が多く、身体経験や社交の記録が少ない場合は、補正行動を一つだけ返します。
def suggested_action(status, social_count, life_count, viewing_count):
if social_count == 0 and status in {"red", "yellow"}:
return "人に軽く連絡する"
if life_count == 0:
return "短時間の運動予定を入れる"
if viewing_count >= 5:
return "外へ出る予定を1件入れる"
return "既存の生活予定を確認する"
同時に Do Not Add を出し、偏りが強い週は新しい分析や開発タスクを増やさないようにします。
提案エージェントは、何もしなければタスク増殖装置になります。「やること」と同じ構造で「今週はやらないこと」を出す必要がありました。
週次レビューを生成します。
python3 personal_twin_rag/life_balance_review.py \
--date 2026-06-19 \
--output personal_twin_rag/output/life_balance_weekly.md
6. MarkdownとJSONを境界にする
年間候補は、レビュー用Markdownと機械処理用JSONの両方へ出力します。
生年月日は記事やリポジトリへハードコードせず、環境変数から渡します。
export BIRTH_YEAR="<your-birth-year>"
export BIRTH_DATE="<YYYY-MM-DD>"
python3 personal_twin_rag/life_window_goals.py \
--birth-year "$BIRTH_YEAR" \
--birth-date "$BIRTH_DATE" \
--year 2026 \
--limit 6
外部サービスへ書き込む前にdry-runを挟みます。
python3 personal_twin_rag/push_life_window_goals_to_todoist.py --dry-run
内容を確認してから登録します。
python3 personal_twin_rag/push_life_window_goals_to_todoist.py
Todoist側では既存タイトルを取得し、同名タスクの重複登録を避けています。
7. プライバシー境界
「全データを素材にする」ことと、「全データをLLMへ渡す」ことは分けています。
現在の方針は次の通りです。
- 生データはローカルに置く
- 外部サービス固有のIDは提案処理へ渡さない
- 注文番号、住所、電話番号、メール、支払い方法を除外する
- 購入金額は価格帯へ丸める
- X本文は必要に応じてマスク済みテキストを使う
- LLMへ渡す場合も、統合CSV、メタデータ、要約イベントを優先する
- 外部サービスへの書き込みは人間確認後に行う
個人データを扱うエージェントでは、モデル選定より先にデータ境界を決める必要があります。
実装して分かったこと
1. ログの量と、本当にやりたいことは一致しない
最も記録が多い活動を勧めるだけでは、レコメンドではなく頻度集計です。仕事のログが多いからといって、空いた時間にも仕事を勧められては困ります。観測値と目的関数を分ける必要があります。
2. LLMより正規化が難しい
サービスごとに列名、日付形式、文字コード、プライバシーレベルが異なります。モデルを呼ぶ処理より、入力を同じ意味へ揃える処理の方が実装量は多くなりました。
3. 誤提案をルールへ戻す必要がある
提案が外れた場合、個別候補を禁止するだけでは別の形で再発します。
誤提案
↓
原因を分類
↓
価値観・安全制約・採点ルールを修正
↓
候補全体を再採点
バックカントリーからジャンプ練習へ選び直したのは、このループの最初の例でした。AIの提案を却下して終わるのではなく、「なぜ違ったか」を次回以降の判断へ残します。
4. 遊びにも「何を追加しないか」が必要
趣味の提案でも、候補を増やし続けると新しい義務になります。疲れている週は予定を増やさない、危険度が高い候補は縮める、といった負の提案と上限設定が必要です。
5. 最初からベクトルDBは不要だった
日付、情報源、テーマで絞り込み、全文検索する段階ではSQLiteとFTS5で十分でした。意味検索が必要になった箇所だけ、後から埋め込み検索を追加する方針です。
思わぬ副作用:価値観を聞くようになった
このロジックを作る過程で、技術とは別の発見がありました。
候補を採点するには、自分が何を重視しているかを言葉にする必要があります。
新しい経験を増やしたいのか
安全に長く続けたいのか
一人の自由時間を守りたいのか
友人と共有する時間を増やしたいのか
最初から明確な答えを持っていたわけではありません。AIの提案に対して「これは違う」「こちらならやりたい」と選び直すうちに、自分が重きを置いている価値観が見えてきました。
バックカントリーではなくジャンプ練習を選んだのも、単に難度を下げたからではありません。新規性だけを求めるより、今ある習慣から安全に成長を続ける方を重視していた、と後から分かりました。
この考え方は、人と話すときにも使えます。
同じ選択肢を前にしても、相手が重視するものが違えば、合理的な結論も変わります。
自分の目的関数: 継続性、成長、身体経験
相手の目的関数: 安全、家族との時間、費用
目的関数が違えば、同じ候補の順位も変わる
以前なら「この案が一番よい」と説明を始めていた場面で、先に「今回は何を一番重視していますか」と聞くようになりました。
価値観が分かれば、相手を説得することより、その人の基準に合う選択肢を一緒に探せます。結果として、意見が違っても対立になりにくくなりました。
もちろん、他人を勝手に分析してスコア化するという話ではありません。自分のモデルを作ったことで、「相手にも自分とは別の目的関数がある」と意識できるようになった、という副作用です。
現在の制約
- 候補テンプレートはPython内で定義している
- テーマ分類はキーワードベースである
- ログに残らない行動は検出できない
- ログが少ないことを、行動不足と断定することはできない
- 年齢や体力による機会の窓には個人差がある
- 提案の有効性を評価するフィードバックログはまだ少ない
今後は、採用・却下・実行・負担・満足度を記録し、候補の重みを更新する予定です。
まとめ
今回のMVPでは、中年の「いつかやる」を今の予定へ変えるため、次の構成でPersonal Twin型の行動提案を実装しました。
- Google Takeout、X、Amazon、楽天のデータをローカルで正規化
- SQLiteとFTS5へ統合
- ログと本人の価値観を別入力として管理
- 構造化した候補を説明可能なルールで採点
- 週次レビューで生活の偏りと
Do Not Addを生成 - Markdown、JSON、dry-runを境界にTodoistへ反映
AIに人生の正解を決めてもらうのではなく、散らばった履歴を判断材料へ変え、「動けるうちに何を試すか」を選びやすくする仕組みです。
ときにはバックカントリーのような少し大胆な候補を出してもらい、ジャンプ練習のような自分に合う一歩へ縮める。ときには「次は一人ではなく、友人とやってみる」という方向を出してもらう。その程度の距離感が、今のところ一番使いやすいと感じています。
中年の危機をAIに解決してもらうことはできません。ただ、「いつか」と言いながら忘れていた遊びを拾い直し、予定へ変えることはできます。
技術的な中心はLLM呼び出しではありません。個人データの正規化、価値観との分離、説明可能な採点、安全な書き込み境界です。