1
0

データサイエンティストが良く使うツールで韓国語数字を勉強する用アプリを作った

Last updated at Posted at 2024-09-09

私は日本語を勉強中ですが、趣味として韓国語の勉強もしています。

言語学習者にとって、最初の難関のひとつが「数字」です。そこで、数字を韓国語で自在に話せるようになりたいという思いから、練習用のアプリを作成しました。

1. はじめに

このアプリでは、指定した桁数のランダムな数字を自動生成し、その数字に対応する韓国語の表記と音声を表示・再生します。

korean_num_ui_recording.gif

使用方法:

  1. 数字の桁数を入力し、TRY ITをクリックすると、ランダムな数字が生成されます。
  2. 生成された数字を見ながら、韓国語で表記を書いたり、発音してみましょう。
  3. 発音を確認したい場合は、再生ボタンをクリックして音声を再生し、正しい発音を確認します。
  4. 表記を確認したい場合は、Answerボタンをクリックします。
  5. もう一度練習するには、再度TRY ITをクリックします。

2. 使用したツール

アプリを作成する際に使用したツールは以下の通りです。

  • バックエンド:FastAPI
    FastAPIを使用することで、ルータごとのテストが容易になり、将来的にデータベースの接続が必要になった際も簡単に拡張できます。

  • フロントエンド:Streamlit
    短時間でUIを作成するために、データサイエンスの分野でよく使われているstreamlitを選びました。また、Streamlit Cloudを使用すれば無料で簡単にデプロイできる点も魅力です。

  • コンテナ:Docker
    アプリのポータビリティを確保するため、コンテナを使用しています。

  • パッケージ管理:Poetry
    poetryを使うことで、コンテナ内でのパッケージのインストールや依存関係の管理が簡単になります。

3. アプリの構成と機能

画像1.png

このアプリは、以下の2つの部分で構成されています:

部分 機能
FastAPIサーバー 数字の生成処理を行うAPIルーター
Streamlitサーバー ユーザーがアプリを操作するインターフェース、処理結果の表示

それぞれの部分はコンテナに載せて実行する予定です。

また、APIサーバーには以下の機能を実現するルーターが必要とされています。

ルーター名 リクエスト レスポンス
数字作成 桁数(integer 数字(integer
韓国語表記生成 数字(integer 韓国語表記(string
音声生成 数字(integer 生成した音声ファイルのパス

UI側では、次の機能が必要と予想されています。

  1. TRY ITボタン:数字の生成、および韓国語表記や音声の生成を開始するボタンです。次の練習を始める際にも、このボタンを使用します。
  2. Answerボタン:韓国語の表記を表示するボタンです。
  3. 音声再生:数字の韓国語音声を再生するためのUIコンポーネントです。
  4. 桁入力フォーム:ユーザーが練習したい数字の桁数を指定するための入力フォームです。

これから、上記の機能を実現するためのコードについて説明していきます。

4. コードの説明

リポジトリ内のディレクトリ構成は以下の通りです。

korean_num/
 ├ resources/       # スクリーンショットなど、アプリ本体とは直接関係のないファイル
 ├ src/
 │ └ __init__.py
 │ └ app.py        # APIサーバーのエントリーポイント
 │  └ client.py    # APIを呼び出すモジュール
 │  └ config.py      # 環境設定や定数を管理
 │  └ router.py    # ルーティング処理を定義
 │  └ schemas.py  # APIのリクエスト/レスポンス用のスキーマ
 │  └ utils.py   # 数字生成や韓国語表記生成のユーティリティ関数
 │  └ main.py     # ユーザーインターフェースの主要な処理
 └ tests/            # テストコード

詳細なコードは、以下のリンクで確認できます:

API

上記の機能を実現するため、3つのルーターは主にrouter.pyに実装されています。

ルーター①:数字作成

指定された桁数に基づいてランダムな数字を生成する関数はutils.pyに記載されています。以下がそのコードです:

def generate_number(digit: int) -> int:
    current_number = random.randint(10 ** (digit - 1), 10**digit - 1)
    return current_number

ルーター:

@routers.get("/get_number", response_model=CreateNumResponseSchema)
def generate_num(digit: int = 4):
    if digit > 5:
        raise HTTPException(
            status_code=403, detail="not available number (must be smaller than 100k)"
        )
    current_number = generate_number(digit)
    print(current_number)
    return CreateNumResponseSchema(current_number=current_number)

レスポンス用のスキーマ(CreateNumResponseSchema)は、以下のようにschemas.py内で定義されています。

class CreateNumResponseSchema(BaseModel):
    current_number: int

ルーター②:韓国語表記生成

ルーター①で生成された数字を韓国語表記に変換するためのルーターを作成します。

まず、韓国語表記に変換するための変数を以下のようにutils.py内で定義します。

def number_to_korean(number):
    units = ["", "", "", "", "", "", "", "", "", ""]
    tens = ["", "", "", "", ""]

    if number == 0:
        return ""

    if number < 10:
        return units[number]

    # Split the number into individual digits
    digits = list(map(int, str(number)))
    length = len(digits)
    result = ""

    for i, digit in enumerate(digits):
        if digit != 0:
            # Add the unit and tens part
            result += units[digit] + tens[length - 1 - i]

    # Remove any leading "일" before "십", "백", "천"
    result = result.replace("일십", "").replace("일백", "").replace("일천", "")

    return result

簡単に説明すると、

  • 21を入力すると、이십일が生成されます
  • 2100を入力すると、이천일ではなく、이천백が生成されるようにします

ルーター②は以下のように実装します。

@routers.get("/display_knum", response_model=KoreanNumResponseSchema)
def display_knum(input_number: int):
    if input_number > 10**5:
        raise HTTPException(
            status_code=403, detail="not available number (must be smaller than 100k)"
        )
    number_display = number_to_korean(input_number)
    return {"display_knum": number_display}

レスポンス用のスキーマ(KoreanNumResponseSchema)は、以下のようにschemas.py内で定義されています。

class KoreanNumResponseSchema(BaseModel):
    display_knum: str

ルーター③:数字の韓国語音声を生成

数字の韓国語音声を生成するためのルーターを作成します。

まず、以下の関数をutils.py内に定義します:


def create_audio(create_audio_params: CreateAudioSchema) -> str:
    """Create an audio file (.mp3) for the input korean word. Based on Naver TTS API."""

    # Create the directory if not exist:
    if not create_audio_params.output_path.exists():
        create_audio_params.output_path.mkdir(parents=True, exist_ok=True)
        
    audio_filename = create_audio_params.output_path / "temp.mp3"

    # Generate the audio file (.mp3):
    text = str(create_audio_params.input_number)
    tts = NaverTTS(text)
    tts.save(audio_filename)
    return str(audio_filename)

関数が受け取る変数のスキーマ(CreateAudioSchema)は、以下のようにschemas.py内で定義しています:

class CreateAudioSchema(BaseModel):
    input_number: int
    output_path: Path = conf.data_path

confについては後ほど説明しますが、ここでは事前に定義した音声の出力場所を指定する変数として使用しています。

音声の生成には、NaverTTSというパッケージを使っています。
このツールは完全に無料で利用でき、非常に優秀です。

NaverTTS GitHubリポジトリ

以下のようにルーターを作成します。

@routers.post("/play_audios")
def play_audio(create_audio_request: CreateAudioSchema):
    audio_filename = create_audio(create_audio_request)
    return audio_filename

API呼び出し用モジュール

必須ではありませんが、UIから作成したルーターを呼び出す際に使用するモジュールをclient.pyとして作成します。

class Client:
    def __init__(self, conf=conf):
        self.backend_url = conf.backend_url

    def get(self, path: str, params: dict = {}):
        url = f"{self.backend_url}/{self.clean_path(path)}"
        with requests.get(url, params=params) as r:
            return self._get_json_result(url, r)

    def post(self, path: str, json: dict = {}):
        url = f"{self.backend_url}/{self.clean_path(path)}"
        with requests.post(url, json=json) as r:
            return self._get_json_result(url, r)

    def clean_path(self, path: str):
        if path.startswith("http"):
            raise Exception(
                f"Wrong path {path}, use with api path directly (no http://xxx..xxx)."
            )
        if path.startswith("/"):
            path = path[1:]
        return path

    def _get_json_result(self, url: str, r: Response, print_auth_error=True):
        if r.status_code > 400 and r.status_code < 500:
            if print_auth_error:
                print(
                    f"Unauthorized call. Check your PAT token {r.text} - {r.url} - {url}"
                )
        try:
            return r.json()
        except Exception as e:
            print(
                f"API CALL ERROR - can't read json. status: {r.status_code} {r.text} - URL: {url} - {e}"
            )
            raise e

これにより、各エンドポイントからのレスポンスは自動的に整形され、UIでAPIを使用する際のコードが簡潔になります。

UIの作成

次に、上記のAPIエンドポイントを呼び出すためのUI画面を作成します(main.py)。

streamlitを使用して、効率的にUIを作成していきましょう。

streamlitの特性として、操作を実行するたびに画面が自動で更新されます。これを防止するため、ルーターのレスポンスはst.session_stateに保存します。

例えば、TRY ITボタンをクリックして数字を生成した場合、その次にAnswerボタンをクリックして韓国語表記を確認します。
しかし、Answerをクリックすると画面が再度更新され、先ほど生成した数字が消えてしまいます。

このような問題を避けるため、状態をst.session_stateに保存して管理します。

コードの構成は以下の通りです。

# Initialize the client
db = Client()

# Function to generate a random number based on a specified digit
def get_number(digit):
    response = db.get("get_number/", params={"digit": digit})
    return response["current_number"]


# Function to generate the Korean number for display
def get_display_knum(number):
    response = db.get("display_knum/", params={"input_number": number})
    return response["display_knum"]


# Function to generate audio
def get_audio_path(number):
    response = db.post("play_audios", json={"input_number": number})
    return response.strip('"')

# Create a text input box
digit = st.number_input("자리", min_value=1, max_value=5, value=4)

if "action1" not in st.session_state:
    st.session_state.action1 = False

# Display button for generating number, Korean word, and audio
if st.button("TRY IT"):
    st.session_state.action1 = True
    # Generate a random number based on a specified digit (e.g., for digit=2, generating 21. )
    st.session_state.number = get_number(digit)

    # Generate the Korean number for display (e.g., 이십일)
    st.session_state.display_knum = get_display_knum(st.session_state.number)

    # Generate the audio (.mp3 file)
    st.session_state.audio_path = get_audio_path(st.session_state.number)

# Check if the TRY IT button was pressed
# Keep the random number showing on the page
if st.session_state.action1:
    # Display the generated random number on UI (e.g., 21)
    st.title(f"{st.session_state.number}")
    # Allow the user to play the audio
    st.audio(st.session_state.audio_path)
    # Display the Korean word of the generated random number on UI (e.g., 21)
    if st.button("Answer"):
        st.write(f"{st.session_state.display_knum}")

環境変数を格納するモジュール

アプリの実行に必要な共通の変数を定義しておきます(config.py)。
ここでは、環境変数を管理するためにpydantic_settings.BaseSettingsを使用します。

DIR_PATH = Path(__file__).resolve().parent.parent
HOST = os.getenv("HOST", "localhost")
class Config(BaseSettings):
    root_path: Path = DIR_PATH
    data_path: Path = DIR_PATH / "src" / "data"
    backend_url: str = f"http://{HOST}:80"

DIR_PATHはルートディレクトリのパスを指しています。

注目すべき点として、backend_urlにはFastAPIサーバーのURLが設定されています(ここではポート80を使用しています)。

APIのURL表記には、以下のコードで示される2つのパターンがあります。

HOST = os.getenv("HOST", "localhost")

実際にDockerコンテナーを立ち上げる際、ホスト名は後ほど紹介するdocker-composeで環境変数として設定しているため、FastAPIのDockerサーバー名を使用しています。一方で、コンテナを起動せずにアプリを立ち上げる場合、APIのURLはlocalhostに設定します(これにより、テストが簡単になります)。

また、data_pathは先ほど説明したルーター③(音声生成ルーター)で使用していた音声ファイルの出力先を指します。UIで音声を再生するときも、このパスを参照するように設定すれば良いでしょう。

5. アプリの起動

いよいよアプリ本体が完成しました。最後に、作動を確認するためにコンテナを使ってアプリを立ち上げてみます。

docker-composeは以下のように作成します。

version: '3.8'

services:
  api-server:
    build: 
      context: .
      dockerfile: Dockerfile
    tty: true
    volumes:
      - ./src/data:/app/src/data  # mount the audio output
    ports:
      - "80:80"
    environment:
      - HOST=api-server

  streamlit:
    build: 
      context: .
      dockerfile: DockerfileStreamlit
    tty: true
    volumes:
      - ./src/data:/app/src/data  # mount the audio output
    ports:
      - "8501:8501" 
    environment:
      - HOST=api-server

docker-composeでは、以下の2つのDockerfileを使用しています。それぞれ、①FastAPIサーバー用(Dockerfile)と、②Streamlitサーバー用(DockerfileStreamlit)です。

基本的な流れは共通しており、作業フォルダの指定→コード本体のコピー→poetryでパッケージのインストール→ポートの指定→サーバーの立ち上げ、という手順になります。

poetryの詳細な説明はここでは省略しますが、以下に各サーバーで必要なパッケージをまとめています。

# pyproject.toml一部
[tool.poetry.dependencies]
python = "^3.11"
pydantic-settings = "^2.3.3"
pydantic = "^2.7.4"

[tool.poetry.group.fastapi.dependencies]
fastapi = "^0.111.0"
navertts = "^0.3.1"

[tool.poetry.group.streamlit.dependencies]
streamlit = "^1.35.0"
requests = "^2.32.3"

FastAPIサーバー用のDockerfileDockerfile)は、以下のように定義しています。

FROM python:3.11-slim

# Configure the working directory
WORKDIR /app
RUN mkdir /app/src

# Copy the necessary files
COPY src/app.py /app/src/app.py
COPY src/config.py /app/src/config.py
COPY src/routers.py /app/src/routers.py
COPY src/schemas.py /app/src/schemas.py
COPY src/utils.py /app/src/utils.py

# Configure poetry-related files and install the dependencies 
RUN pip install poetry
COPY pyproject.toml poetry.lock /app/
RUN poetry config virtualenvs.in-project true
RUN poetry install --with fastapi

# Expose port 80
EXPOSE 80

# Launch the api server at port 80
CMD ["poetry", "run",  "uvicorn", "src.app:app", "--host", "0.0.0.0", "--port", "80", "--reload"]

Streamlitサーバー用のDockerfileDockerfileStreamlit)は、以下のように定義しています。

FROM python:3.11-slim

# Configure the working directory
WORKDIR /app
RUN mkdir /app/src

# Copy the necessary files
COPY src/client.py /app/src/client.py
COPY src/main.py /app/src/main.py
COPY src/config.py /app/src/config.py

# Configure poetry-related files and install the dependencies 
RUN pip install poetry
COPY pyproject.toml poetry.lock /app/
RUN poetry config virtualenvs.in-project true
RUN poetry install --with streamlit

# Expose port 
EXPOSE 8501

# Launch the api server at port 8501
CMD ["poetry", "run",  "streamlit", "run", "src/main.py"]

最後に、以下のコマンドでアプリを実行します。

docker-compose up --build

streamlitはポート8501を使用しているため、ブラウザでlocalhost:8501にアクセスすると、アプリが動作していることを確認できるはずです!

6. おわりに

韓国語の数字を覚えるためのツールを作成しました。

ただし、Streamlitでのデプロイにはファイル構成を公式サイトのフォーマット通りにしなければならないようです。今回は残念ながらデプロイは行いませんでしたが、将来的にはAWSなどを使ってデプロイする予定です。

1
0
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
0