「AIに聞く」んじゃなくて「AIに教える」学習アプリを作った話
はじめに
「わからないことをAIに聞けば、なんでも答えてくれる」時代になりました。便利な一方で、「答えを聞いて終わり」になってしまい、実は何も身についていない、ということもよくあります。
そこで、逆の発想の学習アプリ「フム」を作りました。
- フムという友達キャラクターが、雑談っぽく専門用語について質問してくる
- ユーザーがフムに説明する側に回る
- 教える過程で、自分の理解の穴に自分で気づく(アクティブリコール/ファインマンテクニック)
もともとはclaude.aiのアーティファクト機能で作ったプロトタイプ(単一HTMLファイル)からスタートし、実際にデプロイして人に使ってもらえる形に作り直しました。この記事では、その技術構成と、実装しながら遭遇した面白い問題について書きます。
▶ 実際に触れます: https://fumu-learn.com
※登録が必要です。基本機能は無料でお使いいただけます。
技術構成
| 役割 | 技術 |
|---|---|
| フロントエンド | React + Vite + TypeScript |
| バックエンド | Node.js + Express + TypeScript |
| DB | PostgreSQL + Prisma |
| AI | Anthropic Claude API |
| 認証 | JWT(メール+パスワード) |
| ホスティング(クライアント) | Vercel |
| ホスティング(サーバー・DB) | Railway |
| ドメイン/DNS | Cloudflare |
シンプルな構成ですが、モノレポ(client/ server/を1リポジトリで管理)をVercelとRailwayに別々にデプロイする都合上、いくつか工夫が必要でした(後述)。
実装した機能
基本のチャット機能に加えて、以下を実装しています。
- 過去問チャレンジ: フムが「教わった内容だけ」を使って過去問を解く。知らない用語は正直に「わからない」と答えるので、教えたつもりで伝わっていなかった部分が可視化される
- 教育者モード: 覚えてほしい用語リストを指定して、学習モード/テストモードを切り替えられる。塾・企業の管理者アカウントで作ると、組織内の全学習者に自動で共有される
- 発表レビューフム: PDFスライドをアップロードし、マイクで発表。フムが観客として質問を挟み、わかりやすさ・発表の仕方をレビューする
- 管理画面: 塾・企業向けに、生徒ごとの理解度・回答内容を横断で確認できる
実装で面白かった/苦労した点
1. LLMは「JSON形式のみで返して」と言っても守ってくれない
チャット応答はJSON形式({"message": "...", "term_discussed": "...", "mastery": "..."})で返すようプロンプトで指定していますが、実際には地の文とJSONが混ざって返ってくることがあります。
そうそう、それそれ!国王に権力ぜんぶ集まってるってことだよね、バッチリ!
{"message": "そうそう、それそれ!国王に権力ぜんぶ集まってるってことだよね、バッチリ!", "term_discussed": "絶対王政", "mastery": "good"}
単純にJSON.parse()するだけでは失敗するので、以下のように「まず素直にパースを試みて、ダメなら文字列中から{〜}の範囲を抜き出して再パースする」フォールバックを入れることで解決しました。
function extractJson<T>(text: string): T {
const cleaned = stripJsonFence(text);
try {
return JSON.parse(cleaned) as T;
} catch {
const start = cleaned.indexOf('{');
const end = cleaned.lastIndexOf('}');
if (start === -1 || end === -1 || end <= start) throw new Error('No JSON object found');
return JSON.parse(cleaned.slice(start, end + 1)) as T;
}
}
さらに、それでも失敗する場合は「地の文をそのままフムの発言として使う」フォールバックを用意し、ユーザー体験を壊さないようにしています。
2. React 18 StrictModeの二重実行にハマった
開発モードで「会話開始時にフムの最初の質問が来ない」「評価ボタンを1回押すとAPIが2回呼ばれる」というバグに遭遇しました。原因はReact 18のStrictModeが、開発時に副作用(useEffect)を意図的に2回実行する仕様です。
const startedForTopicRef = useRef<string | null>(null);
useEffect(() => {
// StrictModeの2回目の実行はここで弾く
if (startedForTopicRef.current === topicName) return;
startedForTopicRef.current = topicName;
(async () => {
// ここで初回メッセージ取得などの副作用を実行
})();
}, [topicName]);
useRefでガードを入れることで、実際の副作用は1回だけ実行されるようにしました。地味ですが、課金APIを呼ぶ処理だっただけに直しておいてよかったです。
3. 「フムの知識」をどう制約するか
過去問チャレンジ機能で「フムは教わった内容だけを使って答える」ためには、Claudeが持っている一般知識をそのまま使わせないようにする必要があります。
これは1回のAPI呼び出しでは実現しづらいので、役割演技(回答生成)と採点を完全に分離しました。
- 1回目の呼び出し: 「あなたはフムです。以下の用語だけ知っています。それ以外は素直に『わからない』と答えてください」という制約付きプロンプトで回答を生成
- 2回目の呼び出し: 制約を一切かけず、Claude自身の一般知識で採点
これにより、「本当は知っているはずなのに、キャラ設定に釣られて知らないふりをする」ことも、逆に「キャラを無視してつい正解してしまう」ことも防げました。
デプロイでハマったところ
モノレポ構成でRailwayにデプロイする際、Root Directoryをserverに設定し忘れて、リポジトリ全体をビルドしようとして失敗するということがありました。さらに、Root Directoryを直しても今度は公開ドメインのターゲットポートが80になっていて502エラー。ログを見ると実際のサーバーは8080番でリッスンしていた(Railwayが自動でPORT環境変数を注入していた)、という一幕もありました。
こういう「動いているはずなのに繋がらない」系のトラブルは、結局ログを見るのが一番早い、という当たり前の教訓を再確認しました。
おわりに
「AIに聞くだけの学習」ではなく「AIに教える学習」というコンセプトで作ったフム、実際にサービスとして動くところまで持っていけました。個人学習者向け(高校生の定期テスト対策〜資格試験)と、塾・企業向け(進捗管理)の2チャネルで展開していく予定です。
よかったら覗いてみてください: https://fumu-learn.com
感想・フィードバックあれば、コメントいただけると嬉しいです!