1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OpenAI API × FastAPI × Reactで英語学習アプリを試作してみた【ポートフォリオ開発 #1】

1
Posted at

はじめに

  • ポートフォリオ開発として、英語学習アプリの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

処理の流れ

  • 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

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=["*"],
)
...

完成画面

  • (デザインがダサくてすいません)

一覧画面

  • 教材タイトル
  • ニュースソース
  • 公開日
  • 要約の一部
  • カードクリックで個別ページへ遷移

image.png

個別画面

  • 記事タイトル
  • 英語要約
  • 折りたたみ式の日本語訳
  • 4択クイズ
  • 正誤判定
  • 解説表示
  • 今回はローカルで動作するMVPまで実装した
  • UIは最低限だが、ニュース取得から教材生成・保存・表示・クイズ回答まで一連の流れを確認できる形にした

image.png

  • 各クイズ

image.png


今後

  • 今回はローカルで動作するMVPまで実装した
  • 今後は以下を追加・改善したい
    • UIの改善
    • PostgreSQLへの移行
    • Cloud Runへのデプロイ
    • Firebase Hostingでフロントエンドを公開
    • Cloud Schedulerによる定期的な教材生成
    • 認証機能
    • IaC / CI/CD

まとめ

  • OpenAI APIのWeb SearchとStructured Outputsを使って、ニュース記事から英語学習教材を生成するアプリを試作した
  • FastAPI / SQLite / Reactまでつなぎ、ローカルで一連の動作を確認できた
  • 今回は完成度よりも、まずMVPとして最後まで形にすることを優先した
  • 次回はデプロイまで進めて、実際にアクセスできる形にしたい
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?