はじめに
- ポートフォリオ開発として、英語学習アプリのMVPを試作した
- 最新ニュースを取得し、英語要約・日本語訳・4択クイズを自動生成する
- OpenAI API / FastAPI / React を使って、ローカルで一通り動くところまで実装した
- 今回は完成度よりも、まず小さく作って動かすことを優先した
- コード
作ったもの
- 最新の英語ニュースを1件取得
- 記事内容を英語学習向けに要約
- 日本語訳を生成
- 記事内容をもとに4択クイズを3問生成
- 生成結果をSQLiteへ保存
- ブラウザ画面で教材一覧を表示
- 個別ページで記事・要約・クイズを表示
- 選択肢を選ぶと正誤判定と解説を表示
構成
- Backend
- FastAPI / Pydantic / OpenAI API
- OpenAI Responses API
- Web Search
- Structured Outputs(pydantic)
- Frontend
- React / TypeScript / Vite
- DB
- SQLite
- デプロイ時はNeon(serverless posgreSQL)を予定
- 以前解説記事を出したのでよければご覧ください
- https://qiita.com/ishikawahijiri/items/0eff1488f7af08dbf474
処理の流れ
- OpenAI APIのWeb Searchで最新ニュースを取得
- 取得結果を
Articleモデルとして構造化 -
Articleを入力に、要約・日本語訳・4択クイズを生成 - 生成結果をSQLiteへ保存
- FastAPIから教材一覧・個別教材を取得
- Reactで一覧画面・詳細画面を表示
Pydanticモデル
- OpenAI APIのStructured Outputsで扱いやすいように、記事・クイズ・教材をPydanticモデルとして定義
- 記事取得結果は
Article - クイズ1問は
Quiz - 生成結果は
GeneratedLesson - DB/APIレスポンス用に
LessonResponseを用意
from pydantic import BaseModel, Field
class Article(BaseModel):
title: str
url: str
source: str
published_at: str | None = None
content: str
class Quiz(BaseModel):
question: str
choices: list[str] = Field(min_length=4, max_length=4)
answer_index: int = Field(ge=0, le=3)
explanation: str
class GeneratedLesson(BaseModel):
summary_en: str
translation_ja: str
quizzes: list[Quiz] = Field(min_length=3, max_length=3)
class Lesson(GeneratedLesson):
article: Article
class LessonResponse(Lesson):
id: int
- Structured Outputsを使うことで、LLMの出力をJSON文字列として手動パースせず、そのまま型付きデータとして扱える
OpenAI APIで記事取得
- OpenAI Responses APIのWeb Searchを使って、指定したトピックの最新ニュースを1件取得
- 取得結果は
ArticleモデルとしてStructured Outputsで受け取る - 今回はニュース取得処理を簡略化するため、RSSやスクレイピングは使わずOpenAI側のWeb Searchに寄せた
import os
from dotenv import load_dotenv
from openai import OpenAI
from models import Article, GeneratedLesson, Lesson
load_dotenv()
model = "gpt-5.6"
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("OPENAI_API_KEY is not set")
client = OpenAI(api_key=api_key)
def fetch_article(topic: str) -> Article:
response = client.responses.parse(
model=model,
tools=[
{
"type": "web_search",
}
],
input=f"""
Find one recent English-language news article about {topic}.
Choose an article suitable for an English learner:
- general-interest rather than extremely niche
- enough factual content to make comprehension questions
- avoid opinion pieces
- prefer a reputable source
Return:
- title
- article URL
- source name
- publication date if available
- a concise reconstruction of the factual article content sufficient
for creating an English-learning lesson
Do not invent facts that are not supported by the sources you found.
""",
text_format=Article,
)
return response.output_parsed
def generate_lesson(article: Article) -> Lesson:
response = client.responses.parse(
model=model,
input=f"""
Create an English-learning lesson from the following news article.
Title:
{article.title}
Source:
{article.source}
Article content:
{article.content}
Requirements:
- summary_en: concise English summary
- translation_ja: natural Japanese translation of the important content
- quizzes: exactly 3 multiple-choice comprehension questions
- each quiz must have exactly 4 choices
- answer_index must be 0, 1, 2, or 3
- explanation should briefly explain why the answer is correct
- questions must be answerable from the article
- do not add facts not present in the article
""",
text_format=GeneratedLesson,
)
generated = response.output_parsed
return Lesson(
article=article,
**generated.model_dump(),
)
-
text_format=Articleを指定することで、レスポンスをそのままPydanticモデルとして扱える - 記事取得処理と教材生成処理は分離し、後からRSSや別APIへ差し替えやすい形にした
英語教材を生成
- 取得した
Articleを入力として、英語要約・日本語訳・4択クイズを生成 - 生成部分もStructured Outputsを使い、
GeneratedLessonとして受け取る - 最後に元記事の
Articleと結合してLessonを作る
def generate_lesson(article: Article) -> Lesson:
response = client.responses.parse(
model=model,
input=f"""
Create an English-learning lesson from the following news article.
Title:
{article.title}
Source:
{article.source}
Article content:
{article.content}
Requirements:
- summary_en: concise English summary
- translation_ja: natural Japanese translation of the important content
- quizzes: exactly 3 multiple-choice comprehension questions
- each quiz must have exactly 4 choices
- answer_index must be 0, 1, 2, or 3
- explanation should briefly explain why the answer is correct
- questions must be answerable from the article
- do not add facts not present in the article
""",
text_format=GeneratedLesson,
)
generated = response.output_parsed
return Lesson(
article=article,
**generated.model_dump(),
)
-
GeneratedLessonには生成対象だけを持たせ、元記事情報はLLMに再生成させない -
Lesson作成時にArticleと生成結果を結合する形にした
SQLiteへ保存
- 生成した
LessonはSQLiteへ保存 - 今回はローカルMVPなので、PostgreSQLやORMは使わずPython標準の
sqlite3を使用 - クイズは個別テーブルに分けず、JSON文字列として保存した
import sqlite3
DB_PATH = "app.db"
def get_connection():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
with get_connection() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS lessons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
url TEXT NOT NULL,
source TEXT NOT NULL,
published_at TEXT,
content TEXT NOT NULL,
summary_en TEXT NOT NULL,
translation_ja TEXT NOT NULL,
quizzes_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
- 保存処理では、
Lesson内のクイズをJSONへ変換して保存する
import json
from db import get_connection
from models import Lesson
def save_lesson(lesson: Lesson) -> int:
with get_connection() as conn:
cursor = conn.execute(
"""
INSERT INTO lessons (
title,
url,
source,
published_at,
content,
summary_en,
translation_ja,
quizzes_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
lesson.article.title,
lesson.article.url,
lesson.article.source,
lesson.article.published_at,
lesson.article.content,
lesson.summary_en,
lesson.translation_ja,
json.dumps(
[quiz.model_dump() for quiz in lesson.quizzes],
ensure_ascii=False,
),
),
)
return cursor.lastrowid
- 今回はクイズ単位で検索・更新する要件がないため、JSON保存で十分と判断した
FastAPIで取得APIを作る
- フロントエンドからは、生成済み教材の一覧取得と個別取得だけを行う
- ニュース取得や教材生成はHTTP APIにはせず、別処理として実行する構成にした
from fastapi import APIRouter, HTTPException
from models import LessonResponse
from repository import get_lesson, list_lessons
router = APIRouter(
prefix="/lessons",
tags=["lessons"],
)
@router.get("", response_model=list[LessonResponse])
def get_lessons():
return list_lessons()
@router.get("/{lesson_id}", response_model=LessonResponse)
def get_lesson_by_id(lesson_id: int):
lesson = get_lesson(lesson_id)
if lesson is None:
raise HTTPException(
status_code=404,
detail="Lesson not found",
)
return lesson
-
GET /lessons- 教材一覧を取得
-
GET /lessons/{lesson_id}- 指定した教材を1件取得
- FastAPIのSwagger UIからレスポンスを確認した
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from db import init_db
from routes import router
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://127.0.0.1:5173",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
init_db()
app.include_router(router)
- Vite, Reactの開発サーバーからアクセスするため、CORSも設定した
- CORS
- CORSMiddlewareを追加
- localhost:5173 → FastAPI
- CORS
Reactで表示
- フロントエンドはReact + TypeScript + Viteで実装
- 教材一覧画面と個別画面の2ページ構成
- FastAPIから取得したデータを表示するだけのシンプルな構成にした
一覧画面
-
GET /lessonsから教材一覧を取得 - タイトル、ニュースソース、公開日、要約の一部をカード形式で表示
- カード全体をクリックすると個別ページへ遷移
...
{lessons.map((lesson) => (
<Link
key={lesson.id}
to={`/lessons/${lesson.id}`}
className="lesson-card"
>
<article>
<h2>{lesson.article.title}</h2>
<div className="lesson-meta">
<span>{lesson.article.source}</span>
{lesson.article.published_at && (
<time dateTime={lesson.article.published_at}>
{formatDate(lesson.article.published_at)}
</time>
)}
</div>
<p>{truncate(lesson.summary_en, 140)}</p>
</article>
</Link>
))}
個別画面
-
GET /lessons/{id}から教材を1件取得 - 英語要約を表示
- 日本語訳は折りたたみ表示
- 4択クイズを表示し、選択時に正誤判定と解説を表示
...
<details className="translation-section">
<summary>日本語訳を表示</summary>
<div className="translation-content">
<p>{lesson.translation_ja}</p>
</div>
</details>
...
{lesson.quizzes.map((quiz, quizIndex) => {
const selected = answers[quizIndex];
const answered = selected !== undefined;
const isCorrect = selected === quiz.answer_index;
return (
<article key={quizIndex}>
<h3>{quiz.question}</h3>
{quiz.choices.map((choice, choiceIndex) => (
<label key={choiceIndex}>
<input
type="radio"
name={`quiz-${quizIndex}`}
checked={selected === choiceIndex}
onChange={() =>
setAnswers((prev) => ({
...prev,
[quizIndex]: choiceIndex,
}))
}
/>
{choice}
</label>
))}
{answered && (
<div>
<strong>
{isCorrect ? "Correct" : "Incorrect"}
</strong>
<p>{quiz.explanation}</p>
</div>
)}
</article>
);
})}
- プロトタイプということで、回答履歴の保存などは実装せず今回はフロントエンド内だけで正誤判定・表示する形にした
ハマったところ
HttpUrlをStructured Outputsで使えなかった
- 最初は記事URLを
HttpUrlで定義していた - しかし、OpenAI APIへStructured Outputs用のJSON Schemaとして渡した際にエラーになった
class Article(BaseModel):
title: str
url: HttpUrl
source: str
published_at: str | None = None
content: str
- エラー内容は以下
Invalid schema for response_format 'Article':
In context=('properties', 'url'), 'uri' is not a valid format.
-
HttpUrlが生成するJSON Schemaのformat: uriが対応していなかったため、今回は単純にstrへ変更した
class Article(BaseModel):
title: str
url: str // here!
source: str
published_at: str | None = None
content: str
- 今回はMVPなので、URLの厳密な検証よりも実装を進めることを優先した
CORS
- Reactの開発サーバー(
localhost:5173)からFastAPIへアクセスした際にCORSエラーが発生 - FastAPI側に
CORSMiddlewareを追加して対応した
...
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://127.0.0.1:5173",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
...
完成画面
- (デザインがダサくてすいません)
一覧画面
- 教材タイトル
- ニュースソース
- 公開日
- 要約の一部
- カードクリックで個別ページへ遷移
個別画面
- 記事タイトル
- 英語要約
- 折りたたみ式の日本語訳
- 4択クイズ
- 正誤判定
- 解説表示
- 今回はローカルで動作するMVPまで実装した
- UIは最低限だが、ニュース取得から教材生成・保存・表示・クイズ回答まで一連の流れを確認できる形にした
- 各クイズ
今後
- 今回はローカルで動作するMVPまで実装した
- 今後は以下を追加・改善したい
- UIの改善
- PostgreSQLへの移行
- Cloud Runへのデプロイ
- Firebase Hostingでフロントエンドを公開
- Cloud Schedulerによる定期的な教材生成
- 認証機能
- 過去の記事でFastAPI x ReactでのJWT認証の実装をしました
- https://qiita.com/ishikawahijiri/items/8baaad1dd151507e149a
- IaC / CI/CD
まとめ
- OpenAI APIのWeb SearchとStructured Outputsを使って、ニュース記事から英語学習教材を生成するアプリを試作した
- FastAPI / SQLite / Reactまでつなぎ、ローカルで一連の動作を確認できた
- 今回は完成度よりも、まずMVPとして最後まで形にすることを優先した
- 次回はデプロイまで進めて、実際にアクセスできる形にしたい


