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


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

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

# 1. はじめに

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

![korean_num_ui_recording.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2393515/81dfd8b9-2b2c-e745-6fae-f8121580796f.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2393515/e88791f2-2f80-6c4a-d263-19406ed0ca53.png)

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

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

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

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

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

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

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

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

# 4. コードの説明

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

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

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

https://github.com/phlin0424/korean_num



## API

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

### ルーター①：数字作成

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


```python
def generate_number(digit: int) -> int:
    current_number = random.randint(10 ** (digit - 1), 10**digit - 1)
    return current_number
```
ルーター：
```python
@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`内で定義されています。


```python
class CreateNumResponseSchema(BaseModel):
    current_number: int
```



### ルーター②：韓国語表記生成

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

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


```python
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を入力すると、`이천일`ではなく、`이천백`が生成されるようにします

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

```python
@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`内で定義されています。

```python
class KoreanNumResponseSchema(BaseModel):
    display_knum: str
```


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

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

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


```python

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`内で定義しています：


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

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

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

[NaverTTS GitHubリポジトリ](https://github.com/scottgigante/NaverTTS)

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


```python 
@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`として作成します。


```python
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`に保存して管理します。

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


```python
# 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`を使用します。


```python
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つのパターンがあります。



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

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

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


# 5. アプリの起動

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

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


```docker
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`サーバー用の`Dockerfile`（`Dockerfile`）は、以下のように定義しています。

```
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`サーバー用の`Dockerfile`（`DockerfileStreamlit`）は、以下のように定義しています。

```
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"]
```


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

```shell
docker-compose up --build
```


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



# 6. おわりに

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

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