Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: ユーザー辞書データに改行やnull文字が入っていた場合にエラーとする #1522

Merged
merged 11 commits into from
Feb 7, 2025

Conversation

takana-v
Copy link
Member

@takana-v takana-v commented Feb 2, 2025

内容

UserDictWordモデルの作成時に、Surfaceに含まれる改行コードならびにnull文字を削除するようにします。
これにより、不正な形式の辞書CSVファイルが生成されることを防ぎます。

関連 Issue

スクリーンショット・動画など

その他

コンマやダブルクオーテーション等の文字列に関しては、UserDictWordモデルの作成時に全角に変換されるため大丈夫そうです。
また、pronunciationに関しては、カタカナ以外の文字を受け付けないので、こちらも大丈夫そうです。

@takana-v takana-v requested a review from a team as a code owner February 2, 2025 06:59
@takana-v takana-v requested review from Hiroshiba and removed request for a team February 2, 2025 06:59
@takana-v
Copy link
Member Author

takana-v commented Feb 2, 2025

現在テストが落ちていますが、 #1523 がマージされれば通るはずです。

@takana-v takana-v force-pushed the fix/validate-surface branch from f4fbba7 to 7615029 Compare February 2, 2025 17:43
Copy link
Member

@Hiroshiba Hiroshiba left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

確かに CSV が破壊されると怖いですね!!
ほとんどLGTMです!
ちょっとpydanticでわからない点があり、把握しておくとミスを予防できるかもと思ったのでコメントしてみました 🙇

voicevox_engine/user_dict/model.py Outdated Show resolved Hide resolved
@Hiroshiba Hiroshiba requested review from tarepan and Copilot February 3, 2025 14:53

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

Comments suppressed due to low confidence (1)

voicevox_engine/user_dict/model.py:66

  • [nitpick] The method name 'remove_newlines_and_null' could be more descriptive. Consider renaming it to 'sanitize_surface'.
def remove_newlines_and_null(cls, surface: str) -> str:
Copy link
Member

@Hiroshiba Hiroshiba left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

すみませんもう1点だけ!

検証がsurfaceだけなのって理由あったりされますか? 👀
こう、全str有り得そうだよな〜と・・・!!

pydanticに全str、あるいは複数のpropertyに対して同じvalidatorを噛ませる方法があればそういう実装も楽そうなのですが。。

@takana-v
Copy link
Member Author

takana-v commented Feb 4, 2025

検証がsurfaceだけなのって理由あったりされますか?

通常のAPI経由の使用では、surface以外に改行やnull文字が混入しないため、今回はsurfaceのみ実装しました。

pydanticに全str、あるいは複数のpropertyに対して同じvalidatorを噛ませる方法

Annotated patternを使うことで実装できそうです。
ここのブランチに実装例を置いてみました。もしこちらの方が良さそうなら、このブランチに取り込みます。
https://github.com/takana-v/voicevox_engine/tree/fix/validate-surface-annotated


ちなみにですが、CSVのエントリをダブルクォートで囲んでエスケープする方法は使えなさそうでした。
...,"hoge",...のような読みを登録すると、hogeではなくて"hoge"が登録されてしまう)
恐らく、MecabからNJDへ渡す所の処理で、ダブルクォートが考慮されていないようです。(C++知らないので自信無し)
https://github.com/VOICEVOX/open_jtalk/blob/1.11/src/njd/njd.c#L130
https://github.com/VOICEVOX/open_jtalk/blob/1.11/src/njd/njd.c#L62

@Hiroshiba
Copy link
Member

Hiroshiba commented Feb 4, 2025

おーーーありがとうございます!!
とても参考になりました!!!

ちょっとメンテナンス性考えて整理してみました!

  • Modelのプロパティに代入する時に変換するのは一般的に危ない
    • setしてgetすると別の値になってる(=setに副作用があってしまう感じ)
    • なのでpydanticのvalidatorでデータを置き換えるのはなるべく避けたい。すでに実装済みだけど。
  • Modelでバリデーションを行うのはまあギリギリ許せる
    • エラーを出すのはsetterの役割かどうかかなり怪しいけど、これはまあ許容できる
    • 一番良いのは「バリデーションしたあとの値用の型をつくり、バリデーション済みの値を代入する」流れ
    • これをまっとうにやろうとすると、API受取用のModelと、それをバリデーションした物を格納するデータ構造が必要でちょっと大変なので、一緒にしちゃってもまあ問題ないかなぁ。
  • 変換は本当やめた方がいいはず
    • あまり利点がない
    • エラーがいいはず

ということで、少なくとも今回はデータ変換ではなく検証のみにするのはどうでしょう・・・!!
CsvSafeStrのようなものを作り、コンマや\nなどに対してバリデーションだけを行うAnnotatedな型?とするとか・・・!
(あとコメントドキュメントで「NOTE: CSV形式で保存するため」などと書いておけば、将来不要になった時に判断しやすい・・・かも。)

@takana-v さんが書いてくださったブランチのコードでほとんどできてると認識してます!(注文が多くてすみません。。。)


個人用メモ:

  • Modelはバリデーションのみにして、データ変換は別関数と型を用意するというissueを作る

@takana-v takana-v changed the title ユーザー辞書のSurfaceの検証を厳密化 fix: ユーザー辞書データに改行やnull文字が入っていた場合にエラーとする Feb 4, 2025
@takana-v
Copy link
Member Author

takana-v commented Feb 4, 2025

変換ではなく検証のみを行うように変更しました。


Modelのプロパティに代入する時に変換するのは一般的に危ない

Pydanticは、「入力をチェックする」ものではなく、「最終的な出力の型を保証する」という考え方のようです。
色々ドキュメントを読んだ感じ、Pydanticの思想的には、ModelのValidatorでデータ変換してもいいのかな…?と思います。

In Pydantic, the term "validation" refers to the process of instantiating a model (or other type) that adheres to specified types and constraints. Pydantic guarantees the types and constraints of the output, not the input data.
Pydanticでは、「validation」という用語は、指定された型と制約に忠実なモデル(または他の型)をインスタンス化するプロセスを指します。Pydanticは入力データではなく、出力の型と制約を保証します。
https://docs.pydantic.dev/latest/concepts/models/

入力チェックをModelの外に切り出すのであれば、Pydanticではなくdataclassを使うべきなんですかね...?
事前にデータ変換を行い、dataclassに格納するというアプローチはこんな感じになるかなと思います。

  • WordPropertyからUserDictWord (dataclass)に格納する前に、型チェックやデータ変換を行う
  • SaveFormatUserDictWordからUserDictWord (dataclass)に格納する前に、型チェックやデータ変換を行う
  • UserDictWord (dataclass)からSaveFormatUserDictWordに格納する前に、データ変換を行う

一方Pydanticに乗っかるのであれば、こんな感じにデータ変換をすることになるかなと思います。

  • UserDictWordは、surfacepronunciationaccent_typeword_typepriorityを入力とする
  • 内部で検証を行い、cost等の追加フィールドを作成する(SkipJsonSchema@computed_fieldを使う?)
  • jsonへの出力の際は、@model_serializerdump_json()のexcludeで制御する?

ちょっとユーザー辞書周りのデータ変換フローがややこしい感じがしたので一旦整理してみました。
一部の項目では事前に変換・チェックしている一方、Modelで変換・チェックしている項目もある感じです。

辞書登録時

辞書読み込み時

  • read_dict() 内でdict[str, SaveFormatUserDictWord]として読み込み
  • strがUUIDであることを検証
  • 0.12以前のマイグレーションをしてcostからpriorityに変換し、SaveFormatUserDictWordUserDictWordに変換

@Hiroshiba
Copy link
Member

いったんコメントお答えまで!

Pydanticは、「入力をチェックする」ものではなく、「最終的な出力の型を保証する」という考え方のようです。
色々ドキュメントを読んだ感じ、Pydanticの思想的には、ModelのValidatorでデータ変換してもいいのかな…?と思います。

自分もドキュメント読んでみた感じ、そういう雰囲気を感じました!
ただまあ入力と出力の型を別にできない以上、結構微妙な気がしました。
mora_countがわかりやすくて、入力はint|Noneで出力はintだけど、入力側に合わせないといけないはず・・・。

だとしたらMoraCountクラスや型を用意して、そのインスタンスや値を作る関数(ファクトリ関数)を置くのが良いかなぁ・・・みたいな!

検証をdataclassで行うかPydanticで行うか

@computed_field

すでに値としてmora_countを持ってしまっていて、辞書のimportで使ってるので、今からcomputedにするのは難しいかも。。

UserDictWord (dataclass)

仰ってる感じになると思います!

CsvSafeStr型のようなものを作ってUserDictWord (dataclass)の下に持たせれば、変換し忘れを防止できるかもです。
明示的になる一方で、ほとんど同じデータ構造への変換コードが必要になるので冗長感はあるかもです。
(まあデータ変換は大事で、レイヤードアーキテクチャ等ではわざと冗長にするほどなのでダメではないはず。)


ちょっとユーザー辞書周りのデータ変換フローがややこしい感じがしたので一旦整理してみました。

おーーーー!!! ありがとうございます!!

  1. WordPropertyフィールド (pydantic) → WordProperty (dataclass)
  2. WordProperty (dataclass) → UserDictWord (pydantic)
  3. UserDictWord (pydantic) ←→ SaveFormatUserDictWord (dataclass)

って感じですかね!
2は代わりに WordProperty (dataclass) → SaveFormatUserDictWord (dataclass) の方がきれいな気もする・・・・・・・?


一旦、やるやらは置いといて一番良さそうなのはこれ!というのを考えてみました!!

  • バリデーションしたあとの値が入る型を全種用意する
    • csv用文字列型、カタカナのみ用文字列型、全角のみ文字列型
    • 一旦pydanticでもOK
  • ↑をModelに適用
    • UserDictWordに適用するのがこのPR
    • たぶんadd_word APIのsurfaceとかにも付けるべき
  • ↑↑をdataclassにも適用
    • これで「保存されるものは確実にそのバリデーションが施されてる」状態にできる
    • バリデーションをpydanticでやってると、ここでちょっと迷う(なるべくpydanticに依存したくない)
    • まあ値1つ&バリデーションだけという制約でなら、pydantic利用しても良いかも
  • mora_countのデータ変換の場所を移す
    • UserDictWord (pydantic) → SaveFormatUserDictWord (dataclass) のときに計算する
    • accent_typemora_countでvalidationするところもここに移す
  • convert_to_zenkakuも移す↑と同じとこに移す
    • pydantic UserDictWordのsurfaceが全角のみ型じゃなくなるけど、まあしゃーなし・・・・・・!!
  • WordPropertySaveFormatUserDictWordの変換を直通にする
    • UserDictWordを介さなくても良くなってるはずなので

(ただの感想です:なるほどこれがリファクタリングなんだなぁと感じました。)

Copy link
Member

@Hiroshiba Hiroshiba left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR変更のレビューコメントです!かなり良さそう!!!

voicevox_engine/user_dict/model.py Outdated Show resolved Hide resolved
Comment on lines 91 to 95
surface: Annotated[
str,
AfterValidator(convert_to_zenkaku),
AfterValidator(check_newlines_and_null),
] = Field(description="表層形")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(ただのコメントです)

CsvSafeStr使ったほうが良さそう?
と思ったけど、これは,とか使えるのが正しいのか〜〜〜。

voicevox_engine/user_dict/model.py Show resolved Hide resolved
test/unit/user_dict/test_user_dict_model.py Outdated Show resolved Hide resolved
@takana-v takana-v requested a review from Copilot February 6, 2025 12:33
Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

voicevox_engine/user_dict/model.py Outdated Show resolved Hide resolved
@takana-v
Copy link
Member Author

takana-v commented Feb 6, 2025

指摘があった点の変更を行いました。


ただまあ入力と出力の型を別にできない以上、結構微妙な気がしました。
mora_countがわかりやすくて、入力はint|Noneで出力はintだけど、入力側に合わせないといけないはず・・・。

これに関しては、beforeバリデータを使うことで、解決できるようです。
ただ、int型の引数を期待しているように見えるのにデフォルト値がNone、みたいな感じになりますが...
image

コード例
from typing import Any
from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator


class UserDictWord(BaseModel):
    pronunciation: str  # 本来はきちんと検証する
    mora_count: int = Field(default=None, validate_default=True)
    priority: int = Field(default=None, validate_default=True)
    cost: int = Field(default=None, validate_default=True)

    @field_validator("mora_count", mode="before")
    @classmethod
    def check_mora_count(cls, value: Any, info: ValidationInfo) -> Any:
        if value is None:
            # info.dataから検証済のフィールドを参照できる
            # (先に定義されたフィールドから順に検証される)
            value = len(info.data["pronunciation"])  # 仮の処理
        return value

    @model_validator(mode="before")
    @classmethod
    def check_priority_and_cost(cls, data: Any) -> Any:
        if isinstance(data, dict):
            if "priority" in data and "cost" not in data:
                if isinstance(data["priority"], int):
                    data["cost"] = data["priority"] * 1000  # 仮の処理
            if "cost" in data and "priority" not in data:
                if isinstance(data["cost"], int):
                    data["priority"] = data["cost"] // 1000  # 仮の処理
        return data


if __name__ == "__main__":
    print(UserDictWord(pronunciation="テスト", cost=5000))
    # > pronunciation='テスト' mora_count=3 priority=5 cost=5000

    print(UserDictWord(pronunciation="テスト", priority=5))
    # > pronunciation='テスト' mora_count=3 priority=5 cost=5000

    print(UserDictWord(pronunciation="テスト", mora_count=3, priority=6, cost=6000))
    # > pronunciation='テスト' mora_count=3 priority=6 cost=6000

    try:
        print(UserDictWord(pronunciation="テスト"))
    except Exception as e:
        print(e)
    # > 2 validation error for UserDictWord (priority, costがintじゃないエラー)

@Hiroshiba
Copy link
Member

Hiroshiba commented Feb 7, 2025

これに関しては、beforeバリデータを使うことで、解決できるようです。
ただ、int型の引数を期待しているように見えるのにデフォルト値がNone、みたいな感じになりますが...

なるほどです・・・!
Noneに意味を持たせるのは怖いので、値を自動的に決定する用のシンボルを作りたいかも・・・?
まあでも変換関数をかませた後構造体に渡した方がすっきりする気もしますね!

Pydanticの欠点を一言で表すと、コンストラクタに渡す型とフィールドの型を別々にできない点だなーと感じました!
例えば普通のクラスならこういう感じで良さそうなので。

class UserDictWord:
  def __init__(self, pronunciation: str, mora_count: int | "auto")
    self.pronunciation = pronunciation
    self.mora_count = mora_count if mora_count != "auto" else count(pronunciation)

でも提案の通りbefore使ってvalue: Any受け取って変換したvalueを返す方がわかりやすそう
もしよかったらプルリクエストに挑戦してみていただけると・・・!!!
(個人的には内部用の型SaveFormatUserDictWordに変換する時に置き換える形の方が分かりやすそうだけど、今のコードからだと一旦beforeにするのもアリ!)

Copy link
Member

@Hiroshiba Hiroshiba left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!!!!!

かなり整理できて課題点も見えてきて良かったです! ありがとうございました!!

add_user_dict_wordでCsvSafeStrを使いたい件、TODOコメントだけ足させていただこうと思います!!

@Hiroshiba Hiroshiba changed the title fix: ユーザー辞書データに改行やnull文字が入っていた場合にエラーとする feat!: ユーザー辞書データに改行やnull文字が入っていた場合にエラーとする Feb 7, 2025
@Hiroshiba Hiroshiba enabled auto-merge February 7, 2025 01:59
@Hiroshiba Hiroshiba added this pull request to the merge queue Feb 7, 2025
Merged via the queue into VOICEVOX:master with commit ab6f180 Feb 7, 2025
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants