① イントロ
英語で日記を書くと、AIが文法的な誤りを直してくれて、かつ、同じ意味の「気の利いた英語」を教えてくれる。そんなアプリがあったら、便利だと思いませんか?
最近は ローカルLLM(Local LLM) の進歩が目覚ましく、高性能なモデルが手元のPCでも動くようになりました。API料金を気にすることなく、自分専用の「英語の先生」を構築できる環境が整っています。
今回は、以下の技術を組み合わせて、ローカルで完結する「AI英語日記アプリ」を作成しました。
- FastAPI : 高速なPython Webフレームワーク
- htmx : 最小限のJavaScriptで動的なUIを実現
- LangGraph : LLMのワークフローを柔軟に制御
-
Ollama : ローカル環境でLLMを実行
「自分で作ったツールで英語を学ぶ」という、エンジニアならではの英語学習スタイルをぜひ体験してみてください。
【出力画面イメージ】ローカルLLMなので、出力には少し、時間が掛かります(笑)

② アーキテクトの概要
本アプリの全体像はシンプルですが、それぞれの技術が強力な役割を担っています。
システム構成図
- フロントエンド(htmx) : 日記のテキストを非同期で送信し、結果を表示します。
- バックエンド(FastAPI) : リクエストを受け取り、LangGraph のワークフローを叩きます。
- AI ワークフロー(LangGraph) : 「添削 → 洗練 → アドバイス」という一連の処理を制御します。
- Local LLM(Ollama) : 実際の解析処理をローカル PC 上で行います。
LangGraph によるマルチステップ処理
ただ LLM に「直して」と頼むのではなく、今回は LangGraph を使って以下の 3 つのステップ(ノード)を定義しました。
- checker(添削) : 文法的な誤りを見つけ、修正案とその理由を提示します。
- polisher(洗練) : 同じ意味でより自然、あるいは「気の利いた」表現を 3 パターン提案します。
- summary(アドバイス) : 添削結果に基づき、ユーザーを励ます一言アドバイスを 日本語 で生成します。
これらの処理をグラフとして定義することで、将来的に「さらに専門的な添削ステップを追加する」といった拡張も容易になります。
③ 開発環境の設定
Python のパッケージ管理には、爆速で有名な uv を使用します。
1. ライブラリのインストール
以下のコマンドでプロジェクトを初期化し、必要なライブラリをインストールします。
# プロジェクト初期化
uv init AI-Diary
cd AI-Diary
# ライブラリの追加
uv add fastapi uvicorn jinja2 python-multipart langchain-ollama langgraph
2. Ollama の準備
ローカル LLM を動かすためのエンジン、 Ollama を公式サイトからインストールしておいてください。今回は軽量ながら非常に賢い Llama 3.2 (3B) を使用します。
# モデルのダウンロード
ollama pull llama3.2:3b
3.ディレクトリ構造
最終的なフォルダ構成は以下の通りです。VSCodeなどで以下の構造になっているか確認してください。
diary-ai-app/
├── .venv/ # 仮想環境
├── templates/ # HTMLテンプレート
│ ├── index.html # メイン画面
│ └── result_snippet.html # AI回答用のパーツ
├── main.py # バックエンド(LangGraph + FastAPI)
├── pyproject.toml # プロジェクト設定
└── uv.lock # 固定された依存関係
④ バックエンド(main.py)
バックエンドでは、 LangGraph を使って AI の思考プロセスを定義し、それを FastAPI で公開します。
LangGraph の定義
まず、AI が保持する状態(State)と、各ステップの処理を定義します。詳細は最後に記載しているmain.pyを、ご覧ください。
from typing import TypedDict, List
from langchain_ollama import ChatOllama
from langgraph.graph import StateGraph, END
# --- 状態の定義 ---
class DiaryState(TypedDict):
input_text: str
check_result: str
polish_results: List[str]
final_advice: str
llm = ChatOllama(model="llama3.2:3b", temperature=0)
# --- 各ノード(ステップ)の定義 ---
def checker_node(state: DiaryState):
prompt = f"Check errors in: {state['input_text']}"
response = llm.invoke(prompt)
return {"check_result": response.content}
# (同様に polisher_node, summary_node を定義...)
# --- グラフの構築 ---
workflow = StateGraph(DiaryState)
workflow.add_node("checker", checker_node)
workflow.add_node("polisher", polisher_node)
workflow.add_node("summary", summary_node)
workflow.set_entry_point("checker")
workflow.add_edge("checker", "polisher")
workflow.add_edge("polisher", "summary")
workflow.add_edge("summary", END)
app_graph = workflow.compile()
FastAPI エンドポイント
次に、フロントエンドからのリクエストを受け付けるエンドポイントを作成します。 htmx を使う場合、ページ全体ではなく「HTML の一部」を返すのがコツです。
@app.post("/analyze")
async def analyze(request: Request, diary_text: str = Form(...)):
# LangGraph を実行
initial_state = {"input_text": diary_text}
result = app_graph.invoke(initial_state)
# 結果を表示する HTML の断片を返す
return templates.TemplateResponse("result_snippet.html", {
"request": request,
"result": result
})
⑤ フロントエンド
フロントエンドでは、 htmx を活用して「ページ遷移なし」のサクサクした操作感を実現します。テンプレートファイルは以下の 2 つを用意しました。
1. index.html(メイン画面)
アプリの基盤となる画面です。 htmx の属性を HTML 要素に書き込むだけで、JavaScript を書かずに非同期通信が実装できます。
<!-- formタグにhtmxの設定を記述 -->
<form hx-post="/analyze" hx-target="#result-area" hx-indicator="#loading">
<textarea name="diary_text" placeholder="Write your English diary here..."></textarea>
<button type="submit">添削する</button>
</form>
<!-- 結果が表示されるエリア -->
<div id="result-area"></div>
<!-- ローディング表示 -->
<div id="loading" class="htmx-indicator">AI is thinking...</div>
- hx-post : 送信先のパスを指定します。
- hx-target : 返ってきた HTML をどこに流し込むかを指定します(今回は #result-area)。
- hx-indicator : 通信中に表示する要素を指定します。
2. result_snippet.html(結果表示パーツ)
AI の解析結果を表示するための「断片(スニペット)」です。FastAPI からこの部分の HTML だけが返され、 index.html の特定の場所にバッチリはめ込まれます。
<div class="space-y-6">
<section>
<h2 class="text-red-500">Grammar Check</h2>
<div>{{ result.check_result }}</div>
</section>
<section>
<h2 class="text-green-600">Better Expressions</h2>
<div>{{ result.polish_results[0] }}</div>
</section>
<section class="bg-blue-50">
<h2>ワンポイントアドバイス</h2>
<p>{{ result.final_advice }}</p>
</section>
</div>
Jinja2 テンプレートエンジンを使って、 LangGraph から返ってきた解析結果を動的に埋め込んでいます。
このように、 LangGraph を使うことで「ロジック」と「Web インターフェース」を綺麗に分離できます。
⑥ まとめ
今回は、 Ollama によるローカル LLM と、 FastAPI + htmx + LangGraph を組み合わせた英語日記アプリを紹介しました。
この構成の大きなメリットは、以下の 3 点です。
- プライバシーとコスト : ローカルで動くため、日記の内容が外部に送信される心配がなく、API 料金も一切かかりません。
- 柔軟なプロセス設計 : LangGraph を使うことで、添削のロジックを段階に分けて細かく制御できます。
- シンプルな画面開発 : htmx のおかげで、最小限の工数でインタラクティブな UI を構築できます。
今後は、日記の履歴を保存する機能を追加したり、特定の試験(TOEIC や 英検など)に特化した添削ステップを組み込んだりすることで、さらに実用的なツールに進化させることができそうです。
「自分専用の AI アシスタント」を育てる感覚で、ぜひいろいろなカスタマイズを楽しんでみてください!
最後に、3つのファイル(main.py, index.html, result_snippet.html)を全文、載せておきます。参考にしてくださいね。
from fastapi import FastAPI, Request, Form
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from typing import TypedDict, List
from langchain_ollama import ChatOllama
from langgraph.graph import StateGraph, END
app = FastAPI()
templates = Jinja2Templates(directory="templates")
# --- LangGraph 準備 ---
class DiaryState(TypedDict):
input_text: str
check_result: str
polish_results: List[str]
final_advice: str
llm = ChatOllama(model="llama3.2:3b", temperature=0)
def checker_node(state: DiaryState):
"""ポイントを絞って短く指摘させる"""
prompt = f"""
Check the following English diary for grammar errors.
Keep your response brief and use the following format:
- **Error**: [The error]
- **Correction**: [The corrected text]
- **Why**: [Short explanation]
Diary: {state['input_text']}
"""
response = llm.invoke(prompt)
return {"check_result": response.content}
def polisher_node(state: DiaryState):
"""3つだけに絞り、余計な解説を省かせる"""
prompt = f"""
Provide exactly 3 natural rephrased versions of the diary below.
Do not include any introductory or concluding remarks. Just the list.
Diary: {state['input_text']}
"""
response = llm.invoke(prompt)
return {"polish_results": [response.content]}
def summary_node(state: DiaryState):
"""日本語で出力することを強調し、文字数も制限する"""
# 'in Japanese characters (Hiragana/Kanji)' と具体的に指定するのがコツです
prompt = f"""
Based on the check result, provide one brief encouraging tip for the user.
IMPORTANT: You MUST write in Japanese (using Kanji, Hiragana, and Katakana).
Do not use Romaji. Keep it under 50 characters.
Check Result: {state['check_result']}
"""
response = llm.invoke(prompt)
return {"final_advice": response.content}
workflow = StateGraph(DiaryState)
workflow.add_node("checker", checker_node)
workflow.add_node("polisher", polisher_node)
workflow.add_node("summary", summary_node)
workflow.set_entry_point("checker")
workflow.add_edge("checker", "polisher")
workflow.add_edge("polisher", "summary")
workflow.add_edge("summary", END)
app_graph = workflow.compile()
# --- FastAPI エンドポイント ---
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
"""初期画面を表示"""
return templates.TemplateResponse("index.html", {"request": request})
@app.post("/analyze", response_class=HTMLResponse)
async def analyze(request: Request, diary_text: str = Form(...)):
"""htmxからのリクエストを受けてAIが解析"""
# LangGraphを実行
initial_state = {"input_text": diary_text}
result = app_graph.invoke(initial_state)
# 結果のHTML断片だけを返す
return templates.TemplateResponse("result_snippet.html", {
"request": request,
"result": result
})
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>AI English Diary Mentor</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 p-8">
<div class="max-w-2xl mx-auto bg-white p-6 rounded-xl shadow-md">
<h1 class="text-2xl font-bold mb-4 text-blue-600">English Diary Mentor</h1>
<form hx-post="/analyze" hx-target="#result-area" hx-indicator="#loading">
<textarea name="diary_text"
class="w-full h-32 p-3 border rounded-lg focus:ring-2 focus:ring-blue-400 outline-none"
placeholder="Write your English diary here..."></textarea>
<button type="submit" class="mt-4 bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition">
添削する
</button>
</form>
<div id="loading" class="htmx-indicator mt-4 text-blue-500 font-bold italic">
AI is thinking... (Ollama is working hard!)
</div>
<div id="result-area" class="mt-8">
</div>
</div>
</body>
</html>
<div class="space-y-6 animate-fade-in">
<section>
<h2 class="text-lg font-bold text-red-500 border-b pb-1">Node A: Grammar Check</h2>
<div class="mt-2 text-gray-700 whitespace-pre-wrap">{{ result.check_result }}</div>
</section>
<section>
<h2 class="text-lg font-bold text-green-600 border-b pb-1">Node B: Better Expressions</h2>
<div class="mt-2 text-gray-700 whitespace-pre-wrap">{{ result.polish_results[0] }}</div>
</section>
<section class="bg-blue-50 p-4 rounded-lg">
<h2 class="text-md font-bold text-blue-800">Node C: ワンポイントアドバイス</h2>
<p class="mt-1 text-blue-900">{{ result.final_advice }}</p>
</section>
</div>

