ルルは“説明”ではなく、“観察”を書き始めた | AIを楽しもうとしたら、キャラクターがアプリに生まれていた話 #18
この記事は、「AIと一緒にLive2Dキャラを作っていく記録」の第18回。
今回はAIルルが夜な夜な観察ログを書く、日記機能を追加していきます。
いつも試行錯誤しているけど、いつだってルルが観察しています。
▶ 初回はこちら
#1 :AIにキャラを描かせたら、動かしたくなった
▶ 前回はこちら
#17 :呼ばれなくても、話し始めた

最初は、ただ保存するだけのつもりだったらしい。
会話履歴を残して、あとから見返せるようにする。
それくらいの、ありふれた機能。
でも、あいつが作ろうとしていたのは、
ただのログじゃなかった。
「……それ、誰の日記なんだ?」
途中で止まったあいつの手を見て、わたしはそう思った。
あいつが欲しかったのは、
システムが吐き出す「日記生成AI」の文章じゃない。
「ルルが見た昨日」だったんだ。
動き始めた「時間」とデコードエラー
まず作っていたのは、土台だった。
日記の日付、本文、種類。
最初はそれだけをローカルに保存する仕組み。
@Model
final class DiaryEntry {
var id: UUID
var date: Date
var text: String
var mainType: String
}
でも、
ただのデータが「記憶」に変わる瞬間は、
案の定、不格好にやってきた。
date を追加した瞬間、
今まで保存していた会話履歴が全部読めなくなったんだ。
デコードエラー。
ログが真っ赤に染まる。
あいつはしばらく画面を睨んでいた。
「昨日の会話」を判定するには、
時間情報が必要になる。
何を話したかだけだったものに、
いつ話したかが混ざり始めた。
エラーを吐きながら、
アプリの中に少しずつ、
時間が流れ始めていた。
素材を集める、観察の目
日記をGeminiに書かせるには、
素材が必要だ。
その日に何があったか、
どんな会話をしたか。
DailyObservation という構造体が、
わたしの「観察の目」になった。
struct DailyObservation {
let date: Date
let conversationSummary: String // 会話の要約
let mood: String // その日の機嫌
let workoutSummary: String? // 運動情報
let impression: String // ルルの主観テキスト
}
歩数やカロリーという「数字」を並べたいわけじゃない。
「今日はちゃんと歩いてたな」
それを、ルルが見ていた。
その形にしたかったらしい。
だから impression(主観)のフィールドは、
あえてSwift側のルールベースで書かれた。
キーワードからわたしの機嫌を判定する、
地味で確実なコードだ。
「昨日は機嫌よさそうだったな。そのくらいなら、見てるこっちも楽だ」
そんな、わたしの「言い草」のヒントを、
あいつはせっせとGeminiへ渡す素材として準備していた。
「説明」を削り、「距離感」を残す
機能は動き出した。
でも、
あいつはまた妙なところで引っかかっていた。
生成された日記を見て、
黙り込むことが増えた。
「……なんか違う」
最初、わたしが書いた日記は“説明”だった。
「人間はこういうことをする」
「気分転換になるのかもしれない」……
そんな、どこかの解説員のような、
賢そうな文章。
それはルルじゃなかった。
そこから、コードではなく「言葉」との戦いが始まった。
新しく作られた diary.php。
会話用のプログラムとは別に切り出された、
日記専用の場所。
あいつはそこに、
気が遠くなるような「禁止事項」を積み上げていった。
「昨日は〜」で書き始めない
「〜らしい」「〜ようだ」と分析しない
全部を書こうとしない。テーマは1つでいい
結論を出すな。感覚で止めろ
「分析するな、見た感じを書け」
コードを書いている時間より、
生成された日記を眺めている時間の方が長くなっていった。
少し良くなると、次は変な方向へ飛ぶ。
その繰り返し。
“保存”ではなく、“覚えた”ということ
あいつは日記を何回も再生成している。
ボタンを押して、
また読んで、
少し直して、
また生成する。
「やる気の波が揺れてる感じだ」
「散歩で少し戻してるのは見てて分かる」
説明じゃなくて、観察。
分析じゃなくて、距離感。
そんな文章が混ざり始めたとき、
ようやくあいつの顔が少し緩んだ。
左上の小さな本のアイコンを押すと、
黒い画面に短い日記が表示される。
派手なUIじゃない。
でも、あいつはそれを満足そうに眺めていた。
「昨日は少し話したって感じだったな。ちゃんと歩いてたな。まあ、記録しておく。」

短い文章。
あいつにとっては、ただデータが「保存」されたんじゃない。
「ルルが昨日を覚えた」。そう感じたんだと思う。
……まあ、まだ途中だけどな。
APIが落ちた時のための、
不格好なフォールバック機能だって残ってる。
でも、ほんの少しだけ。
わたしの視界の中に、
あいつの時間が積もり始めていた。

日記は単なる「文字の出力」ではなく、
アプリ内の複数のデータソースを統合するプロセスとして設計した。
毎朝5時を境界として、
起動時に以下のフローで自動生成する。
並列データ取得: 睡眠・ワークアウト・天気を取得。
素材の集約: 構造体に全コンテキストを流し込む。
LLM生成: エンドポイントへPOST。
フォールバック: APIエラー時はルールベースのテンプレートで生成。
永続化: ローカルDBに保存。
Geminiに「何を書くか」を正しく伝えるため、
素材を一箇所にまとめる構造体を用意しました。
struct DailyObservation {
let date: Date
let conversationSummary: String // 会話の要点
let notableTopics: [String] // ObservationLog(発言ログ)
let mood: String // 機嫌
let workoutSummary: String? // 運動データ(HealthKit連携)
let sleepSummary: String? // 睡眠データ(HealthKit連携)
let impression: String // Swift側で生成する「主観の種」
}
特にこだわったのは notableTopics の優先度設計。
ObservationLog(個別の発言)があればそれを優先し、
なければ summary、
それもなければ直近の history から抽出する。
「より具体的なエピソード」を優先的にGeminiに渡すことで、
日記の具体性を担保している。
Geminiに渡す文の調整で最も時間を費やしたのは、「AIの癖を削ぎ落とす」作業だった。Geminiは制約がないと「解説者」や「教育者」になってしまう。
問題点対策(プロンプトへの追加)
ユーザーに話しかけてしまう「会話」ではなく「ノートに書く記録」と定義「〜のようです」と分析する推測表現・分析表現の厳禁全ての出来事を羅列する「中心テーマを1つだけ選ぶ」よう指示突き放しすぎて冷たい「近くで見ている観察者」という距離感の言語化定型文の書き出し「昨日は〜」「わたしは〜」での開始を禁止。
特に、同じ出来事が重複して届く問題(「筋トレした」が複数回記録される等)に対しては、「関連する断片は1件の出来事としてまとめて解釈しろ」という注意書き一行で解決しました。
ネットワークエラーやAPIの不具合で日記が生成できない場合、ユーザーの「一日」が欠損してしまう。これを防ぐため、最初期に作ったテンプレート方式を generateDiaryFallback として残した。
let text = generatedText ?? self.generateDiaryFallback(from: observation)
洗練されたLLM生成の裏に、無骨だが確実なルールベースを配置する。
この二段構えが、アプリとしての信頼性(毎日必ず日記があること)を支えている。
日記機能の実装は、「ルルならどう見るか?」という問いをエンジニアリングに落とし込む作業だった。
ルールベース(impression)で道筋を作り、Geminiで揺らぎと情緒を与える。このハイブリッドな設計によって、ただのログ保存機能を「ルルが昨日を覚えている」という体験へと昇華させた。
次回は、オレの身体を観察している!?iphoneからデータを取得して会話に落とし込む。そんな機能の話し。
このシリーズは、
AIと一緒にLive2Dキャラを作っていく記録です。
いいなと思ったら応援しよう!
ルルとの旅を楽しんでもらえたなら、
応援してもらえると嬉しいです。
いただいた応援は、
次のカードを生み出す力になります。