diff --git a/.agents/skills/python-coding-style/SKILL.md b/.agents/skills/python-coding-style/SKILL.md new file mode 100644 index 00000000..909522ad --- /dev/null +++ b/.agents/skills/python-coding-style/SKILL.md @@ -0,0 +1,17 @@ +--- +name: python-coding-style +description: Pythonコードを作成・修正するときに使用。 +--- + +# 全般 +* できるだけ型ヒントを付ける。 + * できるだけ汎用的な型ヒントをつける。たとえばlistでもsetでも良いならば、`Collection`や`Iterable`を使う。 + * 特に理由がない限り、`object`や`Any`は避ける。 +* docstring は Google スタイルで記述する。 +* ログメッセージやコメントは日本語で記述する +* 戻り値をtupleで返そうとする場合は、`NamedTuple`, `dataclass`, pydantic modelの使用を検討して、本当にtupleが適切かどうかを判断する。 +* モジュールレベルの定数、クラス属性、インスタンス属性などには直後に docstring として記述する。VSCodeのtooltipに表示させるため。 +* dictから値を取得する際、必須なキーならばブラケット記法を使う。キーが必須がどうか分からない場合は、必須とみなす。 +* match文が利用できる箇所では、if文よりもmatch文を使用する。 + * 必要ならば `annofabapi.util.type_util.assert_noreturn` を使用して、match文の網羅性を保証する。 + diff --git a/.agents/skills/test-writing/SKILL.md b/.agents/skills/test-writing/SKILL.md new file mode 100644 index 00000000..f2fc2c02 --- /dev/null +++ b/.agents/skills/test-writing/SKILL.md @@ -0,0 +1,25 @@ +--- +name: test-writing +description: Pythonのテストコードを作成・修正するときに使用。 +--- + +## テスト方針 +* `DummyApi`、`DummyService`、`FakeClient` のような自前の疑似実装は原則追加しない。 +* `monkeypatch` や `mock` は、外部API、ネットワーク、時刻、環境変数、ファイルシステム外の副作用など、テストで制御しないと不安定になる境界に限定して使用する。 +* 関数呼び出しの詳細に強く結びついたテストは、原則として追加しないでください。 +* `mock.assert_called_once_with(...)` のような配線確認だけのテストは、明確な回帰防止上の理由がある場合に限って追加してください。 +* テストを書くときは「そのテストが将来の設計変更を不必要に妨げないか」を確認してください。 +* ただし、外部API連携、ジョブ投入、通知送信、永続化など、副作用の境界にある処理では、呼び出し確認テストを許可します。 +* `argparse`などのCLIパーサーで制御している部分はテストを作成しないでください。たとえば必須引数が指定されない場合のエラーなどです。これはライブラリの責務だからです。 + + +## テストの実行方法 +* `uv run pytest` を使う。 + +## テストコードの具体的なスタイル +* テストファイルのディレクトリ構成やファイル名は、`annofabcli/`配下のプロダクションコードと対応させる。 +* 例外確認には `pytest.raises` を使用する。 +* `pytest.raises` ではエラーメッセージ文字列の確認は行わない。 +* 一時ディレクトリが必要な場合は `tmp_path` fixture を使う。 +* WebAPI にアクセスするテストには `pytest.mark.access_webapi` を付与する。CLI テストは、引数上はローカル処理に見えても共通ログイン処理を通る場合があるので注意する。 + diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..68a3201d --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,24 @@ +# 承認ポリシー。 +# "never" は、Codex がコマンド実行や権限昇格のたびに確認せず進む設定です。 +approval_policy = "never" + +# sandbox の種類。 +# "workspace-write" は、workspace 内と許可された追加パスへの書き込みを許可します。 +sandbox_mode = "workspace-write" + +[shell_environment_policy.set] +# uv のキャッシュ先を /tmp 配下に固定し、ユーザー名依存のパスを避けます。 +UV_CACHE_DIR = "/tmp/uv-cache" + +[sandbox_workspace_write] +# workspace-write sandbox 内で外向きネットワーク通信を許可します。 +network_access = true + +# workspace に加えて書き込みを許可する追加ディレクトリ一覧です。 +# 今回は uv キャッシュと一時ファイル用途を想定して /tmp を許可しています。 +writable_roots = [ + "/tmp", +] + +[features] +codex_hooks = true diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4d08d16e..6503337a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,40 +1,3 @@ # copilot-instructions.md -## プロジェクトの目的 -AnnofabのWebAPIのPythonクライアントライブラリです。 - -## 開発でよく使うコマンド -* コードのフォーマット: `make format` -* Lintの実行: `make lint` -* テストの実行: `make test` -* ドキュメントの実行: `make docs` - -## 技術スタック -* Python 3.9 以上 -* テストフレームワーク: Pytest v8 以上 - -## ディレクトリ構造概要 - -* `annofabcli/**`: アプリケーションのソースコード -* `tests/**`: テストコード - * `tests/data/**`: テストコードが参照するリソース -* `docs/*`: ドキュメント - -## コーディングスタイル - -### Python -* dictから値を取得する際、必須なキーならばブラケット記法を使う。キーが必須がどうか分からない場合は、必須とみなす。 -* できるだけ`os.path`でなく`pathlib.Path`を使う(Lint`flake8-use-pathlib`に従う) -* Noneの判定、空文字列の判定、長さが0のコレクションの判定は、falsyとして判定するのでなく、`if a is not None:`のように判定内容を明記してください。 -* 型ヒントを`dict`にする場合、`dict[str, Any]`のようにキーと値の型を指定する。 - -### テストコード -* Errorの確認は、`pytest.raises`を使用する。エラーメッセージの確認は行わない。 -* 一時ディレクトリを使用する場合は、`tmp_path` fixtureを利用する。 - - -## 作業の進め方 -* コードの修正が完了したら`make format`を実行してフォーマットを行い、その後`make lint`を実行してLintエラーがないことを確認する。 - -## レビュー -* PRレビューの際は、日本語でレビューを行う +`AGENTS.md`や`.agents/skills/`以下のファイルを参照してください。 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..a7553ec1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ + +## 想定する実行環境 +* AIによる指摘や応答は日本語で書く。 + +## コードの修正方針 +* 原則、破壊的変更を行って修正してください。コードをシンプルにするためです。 +* 何かを判断する際、コードの修正量は無視してください。AIが修正するので、そこは問題になりません。 +* リンターにより行数が長すぎるなどのエラーが発生した場合、必要ならばそのエラーを無視してください。読みやすさやシンプルさを優先してください。 + +## Coding Agent による作業の進め方 +1. コードを修正する。関連するテストコードやドキュメントも修正する。 +2. 自分自身でレビューする +3. `make format`, `make lint`を実行する。 +4. 関連するテストコードを実行する。 +5. Git にコミットする。ただしmainブランチで作業している場合は、pull requestを作成する。 diff --git a/annofabapi/util/annotation_specs.py b/annofabapi/util/annotation_specs.py index 252434cd..c13eeb00 100644 --- a/annofabapi/util/annotation_specs.py +++ b/annofabapi/util/annotation_specs.py @@ -1,11 +1,49 @@ -from typing import Any, Literal +from typing import Any, Literal, TypedDict, cast import more_itertools from annofabapi.models import Lang +from annofabapi.pydantic_models.additional_data_definition_type import AdditionalDataDefinitionType -def get_english_message(internationalization_message: dict[str, Any]) -> str: +class InternationalizationMessageItem(TypedDict): + """多言語メッセージの1要素です。""" + + lang: str + message: str + + +class InternationalizationMessage(TypedDict): + """多言語メッセージです。""" + + messages: list[InternationalizationMessageItem] + + +class AttributeChoice(TypedDict): + """属性の選択肢です。""" + + choice_id: str + name: InternationalizationMessage + + +class AttributeDefinition(TypedDict): + """アノテーション仕様上の属性定義です。""" + + additional_data_definition_id: str + name: InternationalizationMessage + type: AdditionalDataDefinitionType + choices: list[AttributeChoice] | None + + +class LabelDefinition(TypedDict): + """アノテーション仕様上のラベル定義です。""" + + label_id: str + label_name: InternationalizationMessage + additional_data_definitions: list[str] + + +def get_english_message(internationalization_message: InternationalizationMessage | dict[str, Any]) -> str: """ `InternationalizationMessage`クラスの値から、英語メッセージを取得します。 英語メッセージが見つからない場合は ``ValueError`` をスローします。 @@ -22,7 +60,7 @@ def get_english_message(internationalization_message: dict[str, Any]) -> str: Raises: ValueError: 英語メッセージが見つからない場合 """ - messages: list[dict[str, str]] = internationalization_message["messages"] + messages = cast(list[InternationalizationMessageItem], internationalization_message["messages"]) result = more_itertools.first_true(messages, pred=lambda e: e["lang"] == Lang.EN_US.value) if result is not None: return result["message"] @@ -36,7 +74,7 @@ def get_english_message(internationalization_message: dict[str, Any]) -> str: """ -def get_message_with_lang(internationalization_message: dict[str, Any], lang: Lang | STR_LANG) -> str | None: +def get_message_with_lang(internationalization_message: InternationalizationMessage | dict[str, Any], lang: Lang | STR_LANG) -> str | None: """ `InternationalizationMessage`クラスの値から、指定した ``lang`` に対応するメッセージを取得します。 @@ -48,7 +86,7 @@ def get_message_with_lang(internationalization_message: dict[str, Any], lang: La 指定した言語に対応するメッセージ。見つからない場合はNoneを返します。 """ - messages: list[dict[str, str]] = internationalization_message["messages"] + messages = cast(list[InternationalizationMessageItem], internationalization_message["messages"]) if isinstance(lang, Lang): str_lang = lang.value else: @@ -60,7 +98,7 @@ def get_message_with_lang(internationalization_message: dict[str, Any], lang: La return None -def get_choice(choices: list[dict[str, Any]], *, choice_id: str | None = None, choice_name: str | None = None) -> dict[str, Any]: +def get_choice(choices: list[AttributeChoice], *, choice_id: str | None = None, choice_name: str | None = None) -> AttributeChoice: """ 選択肢情報を取得します。 @@ -90,12 +128,12 @@ def get_choice(choices: list[dict[str, Any]], *, choice_id: str | None = None, c def get_attribute( - additionals: list[dict[str, Any]], + additionals: list[AttributeDefinition], *, attribute_id: str | None = None, attribute_name: str | None = None, - label: dict[str, Any] | None = None, -) -> dict[str, Any]: + label: LabelDefinition | None = None, +) -> AttributeDefinition: """ 属性情報を取得します。 @@ -133,7 +171,7 @@ def get_attribute( return result[0] -def get_label(labels: list[dict[str, Any]], *, label_id: str | None = None, label_name: str | None = None) -> dict[str, Any]: +def get_label(labels: list[LabelDefinition], *, label_id: str | None = None, label_name: str | None = None) -> LabelDefinition: """ ラベル情報を取得します。 @@ -172,12 +210,12 @@ class AnnotationSpecsAccessor: def __init__(self, annotation_specs: dict[str, Any]) -> None: self.annotation_specs = annotation_specs - self.labels = annotation_specs["labels"] - self.additionals = annotation_specs["additionals"] + self.labels: list[LabelDefinition] = annotation_specs["labels"] + self.additionals: list[AttributeDefinition] = annotation_specs["additionals"] def get_attribute( - self, *, attribute_id: str | None = None, attribute_name: str | None = None, label: dict[str, Any] | None = None - ) -> dict[str, Any]: + self, *, attribute_id: str | None = None, attribute_name: str | None = None, label: LabelDefinition | None = None + ) -> AttributeDefinition: """ 属性情報を取得します。 @@ -192,7 +230,7 @@ def get_attribute( """ return get_attribute(self.additionals, attribute_id=attribute_id, attribute_name=attribute_name, label=label) - def get_label(self, *, label_id: str | None = None, label_name: str | None = None) -> dict[str, Any]: + def get_label(self, *, label_id: str | None = None, label_name: str | None = None) -> LabelDefinition: """ ラベル情報を取得します。 diff --git a/annofabapi/util/attribute_restrictions.py b/annofabapi/util/attribute_restrictions.py index 6b7e79f8..e146a912 100644 --- a/annofabapi/util/attribute_restrictions.py +++ b/annofabapi/util/attribute_restrictions.py @@ -3,61 +3,82 @@ 以下のサンプルコードのように属性名で制約情報を出力できます。 -Examples: - .. code-block:: python - - >>> import annofabapi - >>> from annofabapi.util.attribute_restrictions import AttributeFactory - >>> service = annofabapi.build() - >>> annotation_specs, _ = service.api.get_annotation_specs("prj1", query_params={"v": "3"}) - >>> fac = AttributeFactory(annotation_specs) - - # 「'occluded'チェックボックスがONならば、'note'テキストボックスは空ではない」という制約 - >>> premise_restriction = fac.checkbox(attribute_name="occluded").checked() - >>> conclusion_restriction = fac.string_textbox(attribute_name="note").is_not_empty() - >>> restriction = premise_restriction.imply(conclusion_restriction) - >>> restriction.to_dict() - { - "additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", - "condition": { - "_type": "Imply", - "premise": { - "additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", - "condition": {"_type": "Equals", "value": "true"}, - }, - "condition": {"_type": "NotEquals", "value": ""}, +Example: + >>> import annofabapi + >>> from annofabapi.util.attribute_restrictions import AttributeFactory + >>> service = annofabapi.build() + >>> annotation_specs, _ = service.api.get_annotation_specs("prj1", query_params={"v": "3"}) + >>> fac = AttributeFactory(annotation_specs) + + >>> premise_restriction = fac.checkbox(attribute_name="occluded").checked() + >>> conclusion_restriction = fac.string_textbox(attribute_name="note").is_not_empty() + >>> restriction = premise_restriction.imply(conclusion_restriction) + >>> restriction.to_dict() + { + "additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", + "condition": { + "_type": "Imply", + "premise": { + "additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", + "condition": {"_type": "Equals", "value": "true"}, }, - } - - # 「'occluded'チェックボックスがONならば、'car_kind'セレクトボックス(またはラジオボタン)は選択肢'general_car'を選択しない」という制約 - >>> premise_restriction = fac.checkbox(attribute_name="occluded").checked() - >>> conclusion_restriction = fac.selection(attribute_name="car_kind").not_has_choice(choice_name="general_car") - >>> restriction = premise_restriction.imply(conclusion_restriction) - >>> restriction.to_dict() - { - "additional_data_definition_id": "cbb0155f-1631-48e1-8fc3-43c5f254b6f2", - "condition": { - "_type": "Imply", - "premise": { - "additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", - "condition": {"_type": "Equals", "value": "true"}, - }, - "condition": {"_type": "Equals", "value": "7512ee39-8073-4e24-9b8c-93d99b76b7d2"}, - }, - } + "condition": {"_type": "NotEquals", "value": ""}, + }, + } """ from abc import ABC, abstractmethod from collections.abc import Collection -from typing import Any - -from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, get_choice +from enum import Enum +from typing import Any, NoReturn, cast + +from pydantic import BaseModel, ConfigDict, Field, field_serializer, model_validator + +from annofabapi.pydantic_models.additional_data_definition_type import AdditionalDataDefinitionType +from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, AttributeChoice, AttributeDefinition, get_choice, get_english_message +from annofabapi.util.type_util import assert_noreturn + + +class RestrictionAstType(str, Enum): + """属性制約ASTの種別です。""" + + CHECKED = "checked" + """チェックボックス属性がチェックされていることを表すAST種別です。""" + UNCHECKED = "unchecked" + """チェックボックス属性がチェックされていないことを表すAST種別です。""" + IS_EMPTY = "is_empty" + """属性値が空であることを表すAST種別です。""" + IS_NOT_EMPTY = "is_not_empty" + """属性値が空でないことを表すAST種別です。""" + EQUALS_STRING = "equals_string" + """文字列属性またはtracking属性が指定文字列と一致することを表すAST種別です。""" + NOT_EQUALS_STRING = "not_equals_string" + """文字列属性またはtracking属性が指定文字列と一致しないことを表すAST種別です。""" + MATCHES_STRING = "matches_string" + """文字列属性が指定した正規表現に一致することを表すAST種別です。""" + NOT_MATCHES_STRING = "not_matches_string" + """文字列属性が指定した正規表現に一致しないことを表すAST種別です。""" + EQUALS_INTEGER = "equals_integer" + """整数属性が指定した整数値と一致することを表すAST種別です。""" + NOT_EQUALS_INTEGER = "not_equals_integer" + """整数属性が指定した整数値と一致しないことを表すAST種別です。""" + HAS_CHOICE = "has_choice" + """選択属性で指定した選択肢が選ばれていることを表すAST種別です。""" + NOT_HAS_CHOICE = "not_has_choice" + """選択属性で指定した選択肢が選ばれていないことを表すAST種別です。""" + HAS_LABEL = "has_label" + """リンク属性が指定したラベル群のいずれかを指すことを表すAST種別です。""" + CAN_INPUT = "can_input" + """属性が編集可能かどうかを表すAST種別です。""" + IMPLY = "imply" + """前提を満たす場合に結論を要求する含意制約を表すAST種別です。""" + + def __str__(self) -> str: + return self.value class Restriction(ABC): - """ - 属性の制約を表すクラス。 - """ + """属性の制約を表すクラス。""" def __init__(self, attribute_id: str) -> None: self.attribute_id = attribute_id @@ -65,13 +86,72 @@ def __init__(self, attribute_id: str) -> None: def to_dict(self) -> dict[str, Any]: """ アノテーション仕様の`restrictions`に格納できるdictを出力します。 + + Returns: + `restrictions` に格納できる辞書形式の制約情報です。 """ return {"additional_data_definition_id": self.attribute_id, "condition": self._to_dict_only_condition()} + @classmethod + def from_dict(cls, obj: dict[str, Any]) -> "Restriction": + """ + dictからRestrictionオブジェクトを復元します。 + + Args: + obj: `restrictions` の1要素を表す辞書です。 + + Returns: + 復元した `Restriction` オブジェクトです。 + + """ + return _from_restriction_dict(obj) + + def to_ast(self, annotation_specs: dict[str, Any]) -> "RestrictionAst": + """ + Restrictionオブジェクトを、LLMやCLIで扱いやすい意味ベースのASTへ変換します。 + + Args: + annotation_specs: アノテーション仕様(v3)の情報です。 + + Returns: + 名前ベースで表現された `RestrictionAst` です。 + """ + accessor = AnnotationSpecsAccessor(annotation_specs) + return _restriction_to_ast(self, accessor=accessor) + + def to_human_readable(self, annotation_specs: dict[str, Any]) -> str: + """ + Restrictionオブジェクトを、人にとって読みやすい文字列表現へ変換します。 + + Args: + annotation_specs: アノテーション仕様(v3)の情報です。 + + Returns: + CLIなどで表示しやすい文字列表現です。 + """ + return self.to_ast(annotation_specs).to_human_readable() + + @classmethod + def from_ast(cls, ast: "RestrictionAst", annotation_specs: dict[str, Any]) -> "Restriction": + """ + 意味ベースのASTからRestrictionオブジェクトを復元します。 + + Args: + ast: 復元元の `RestrictionAst` です。 + annotation_specs: アノテーション仕様(v3)の情報です。 + + Returns: + 復元した `Restriction` オブジェクトです。 + """ + return ast.to_restriction(annotation_specs) + @abstractmethod def _to_dict_only_condition(self) -> dict[str, Any]: """ 制約の条件部分のみdictで出力します。 + + Returns: + 制約の条件部分のみを表す辞書です。 """ def imply(self, conclusion_restriction: "Restriction") -> "Restriction": @@ -80,11 +160,11 @@ def imply(self, conclusion_restriction: "Restriction") -> "Restriction": class Imply(Restriction): """ - 「AならB」という制約を表すクラス + 「AならB」という制約を表すクラス。 Args: - premise_restriction: 前提となる制約 - conclusion_restriction: 最終的に満たしたい制約 + premise_restriction: 前提となる制約です。 + conclusion_restriction: 最終的に満たしたい制約です。 """ def __init__(self, premise_restriction: Restriction, conclusion_restriction: Restriction) -> None: @@ -175,6 +255,10 @@ def __init__(self, accessor: AnnotationSpecsAccessor, *, attribute_id: str | Non if self._is_valid_attribute_type() is False: raise ValueError(f"属性の種類が'{self.attribute['type']}'である属性は、クラス'{self.__class__.__name__}'では扱えません。") + def enabled(self) -> Restriction: + """属性値を入力できるという制約""" + return CanInput(self.attribute_id, enable=True) + def disabled(self) -> Restriction: """属性値を入力できないという制約""" return CanInput(self.attribute_id, enable=False) @@ -278,23 +362,23 @@ def _is_valid_attribute_type(self) -> bool: def has_choice(self, *, choice_id: str | None = None, choice_name: str | None = None) -> Restriction: """引数`choice_id`または`choice_name`に一致する選択肢が選択されているという制約""" - choices = self.attribute["choices"] + choices = cast(list[AttributeChoice], self.attribute["choices"]) choice = get_choice(choices, choice_id=choice_id, choice_name=choice_name) return Equals(self.attribute_id, choice["choice_id"]) def not_has_choice(self, *, choice_id: str | None = None, choice_name: str | None = None) -> Restriction: """引数`choice_id`または`choice_name`に一致する選択肢が選択されていないという制約""" - choices = self.attribute["choices"] + choices = cast(list[AttributeChoice], self.attribute["choices"]) choice = get_choice(choices, choice_id=choice_id, choice_name=choice_name) return NotEquals(self.attribute_id, choice["choice_id"]) class AttributeFactory: """ - 属性を生成するためのFactoryクラス + 属性を生成するためのFactoryクラス。 - Attributes: - annotation_specs: アノテーション仕様(v3)の情報 + Args: + annotation_specs: アノテーション仕様(v3)の情報です。 """ def __init__(self, annotation_specs: dict[str, Any]) -> None: @@ -317,3 +401,787 @@ def tracking_id(self, *, attribute_id: str | None = None, attribute_name: str | def selection(self, *, attribute_id: str | None = None, attribute_name: str | None = None) -> Selection: return Selection(self.accessor, attribute_id=attribute_id, attribute_name=attribute_name) + + def create( + self, + attribute_type: AdditionalDataDefinitionType, + *, + attribute_id: str | None = None, + attribute_name: str | None = None, + ) -> Attribute: + """ + 属性の種類に応じて対応する高水準属性オブジェクトを生成します。 + + Args: + attribute_type: 属性の種類です。 + attribute_id: 属性IDです。 + attribute_name: 属性名(英語)です。 + + Returns: + 対応する高水準属性オブジェクトです。 + """ + match attribute_type: + case "flag": + return self.checkbox(attribute_id=attribute_id, attribute_name=attribute_name) + case "text" | "comment": + return self.string_textbox(attribute_id=attribute_id, attribute_name=attribute_name) + case "integer": + return self.integer_textbox(attribute_id=attribute_id, attribute_name=attribute_name) + case "link": + return self.annotation_link(attribute_id=attribute_id, attribute_name=attribute_name) + case "tracking": + return self.tracking_id(attribute_id=attribute_id, attribute_name=attribute_name) + case "choice" | "select": + return self.selection(attribute_id=attribute_id, attribute_name=attribute_name) + case _: + raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") + + +class RestrictionAst(BaseModel): + """ + LLMやCLI向けの意味ベースな属性制約ASTを表すクラス。 + + `type` に応じて必要なフィールドが変わります。例えば `checked` では + `attribute_name` を使い、`imply` では `premise` と `conclusion` を使います。 + + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + type: RestrictionAstType = Field(description="ASTノードの種類です。") + attribute_name: str | None = Field(default=None, description="対象属性の名前です。") + value: str | int | None = Field(default=None, description="文字列や整数の比較値です。") + choice_name: str | None = Field(default=None, description="選択系属性で利用する選択肢名です。") + enable: bool | None = Field(default=None, description="`can_input` ノードで使う真偽値です。") + label_names: list[str] | None = Field(default=None, description="`has_label` ノードで使うラベル名の一覧です。") + premise: "RestrictionAst | None" = Field(default=None, description="`imply` ノードの前提です。") + conclusion: "RestrictionAst | None" = Field(default=None, description="`imply` ノードの結論です。") + + @classmethod + def _get_required_fields(cls, ast_type: RestrictionAstType) -> set[str]: + """ + AST種別ごとに必須なフィールド名を返します。 + + Args: + ast_type: ASTノードの種類です。 + + Returns: + AST種別に対応する必須フィールド名です。 + """ + match ast_type: + case RestrictionAstType.CHECKED | RestrictionAstType.UNCHECKED | RestrictionAstType.IS_EMPTY | RestrictionAstType.IS_NOT_EMPTY: + return {"attribute_name"} + case ( + RestrictionAstType.EQUALS_STRING + | RestrictionAstType.NOT_EQUALS_STRING + | RestrictionAstType.MATCHES_STRING + | RestrictionAstType.NOT_MATCHES_STRING + | RestrictionAstType.EQUALS_INTEGER + | RestrictionAstType.NOT_EQUALS_INTEGER + ): + return {"attribute_name", "value"} + case RestrictionAstType.HAS_CHOICE | RestrictionAstType.NOT_HAS_CHOICE: + return {"attribute_name", "choice_name"} + case RestrictionAstType.HAS_LABEL: + return {"attribute_name", "label_names"} + case RestrictionAstType.CAN_INPUT: + return {"attribute_name", "enable"} + case RestrictionAstType.IMPLY: + return {"premise", "conclusion"} + case _ as never: + assert_noreturn(never) + + @model_validator(mode="after") + def validate_restriction_ast(self) -> "RestrictionAst": # noqa: PLR0912 + """ + `RestrictionAst` の構造がAST種別に整合しているか検証します。 + + Raises: + ValueError: AST種別に対して必須フィールドが不足している場合、または型が不正な場合 + """ + required_fields = self._get_required_fields(self.type) + actual_fields = { + field_name + for field_name, value in ( + ("attribute_name", self.attribute_name), + ("value", self.value), + ("choice_name", self.choice_name), + ("enable", self.enable), + ("label_names", self.label_names), + ("premise", self.premise), + ("conclusion", self.conclusion), + ) + if value is not None + } + if actual_fields != required_fields: + raise ValueError(f"AST種別'{self.type}'のフィールドが不正です。 :: required={sorted(required_fields)}, actual={sorted(actual_fields)}") + + match self.type: + case ( + RestrictionAstType.EQUALS_STRING + | RestrictionAstType.NOT_EQUALS_STRING + | RestrictionAstType.MATCHES_STRING + | RestrictionAstType.NOT_MATCHES_STRING + ): + if not isinstance(self.value, str): + raise ValueError(f"AST種別'{self.type}'の'value'は文字列である必要があります。") + case RestrictionAstType.EQUALS_INTEGER | RestrictionAstType.NOT_EQUALS_INTEGER: + if not isinstance(self.value, int): + raise ValueError(f"AST種別'{self.type}'の'value'は整数である必要があります。") + case RestrictionAstType.HAS_CHOICE | RestrictionAstType.NOT_HAS_CHOICE: + if not isinstance(self.choice_name, str): + raise ValueError(f"AST種別'{self.type}'の'choice_name'は文字列である必要があります。") + case RestrictionAstType.HAS_LABEL: + if not isinstance(self.label_names, list) or any(not isinstance(label_name, str) for label_name in self.label_names): + raise ValueError("AST種別'has_label'の'label_names'は文字列のリストである必要があります。") + case RestrictionAstType.CAN_INPUT: + if not isinstance(self.enable, bool): + raise ValueError("AST種別'can_input'の'enable'は真偽値である必要があります。") + case ( + RestrictionAstType.CHECKED + | RestrictionAstType.UNCHECKED + | RestrictionAstType.IS_EMPTY + | RestrictionAstType.IS_NOT_EMPTY + | RestrictionAstType.IMPLY + ): + pass + case _ as never: + assert_noreturn(never) + + return self + + def to_restriction(self, annotation_specs: dict[str, Any]) -> Restriction: + """ + ASTをRestrictionオブジェクトへコンパイルします。 + + Args: + annotation_specs: アノテーション仕様(v3)の情報です。 + + Returns: + コンパイル後の `Restriction` オブジェクトです。 + """ + fac = AttributeFactory(annotation_specs) + return _ast_to_restriction(self, fac=fac) + + def to_human_readable(self) -> str: # noqa: PLR0915 + """ + ASTを人間向けの読みやすい文字列へ変換します。 + + Returns: + CLIなどで表示しやすい文字列表現です。 + + Raises: + ValueError: 未知のAST種別が指定された場合 + """ + + def to_human_readable_text(ast: RestrictionAst, *, attribute_name: str) -> str: # noqa: PLR0912 + """ + `imply` 以外のASTを人間向けの読みやすい文字列へ変換します。 + + Args: + ast: 変換対象のASTです。 + attribute_name: `repr()` 済みの属性名です。 + + Returns: + 人間向けの読みやすい文字列です。 + """ + match ast.type: + case RestrictionAstType.CHECKED: + text = f"{attribute_name} is checked" + case RestrictionAstType.UNCHECKED: + text = f"{attribute_name} is unchecked" + case RestrictionAstType.IS_EMPTY: + text = f"{attribute_name} is empty" + case RestrictionAstType.IS_NOT_EMPTY: + text = f"{attribute_name} is not empty" + case RestrictionAstType.EQUALS_STRING | RestrictionAstType.EQUALS_INTEGER: + text = f"{attribute_name} is " + repr(ast.value) + case RestrictionAstType.NOT_EQUALS_STRING | RestrictionAstType.NOT_EQUALS_INTEGER: + text = f"{attribute_name} is not " + repr(ast.value) + case RestrictionAstType.MATCHES_STRING: + text = f"{attribute_name} matches " + repr(ast.value) + case RestrictionAstType.NOT_MATCHES_STRING: + text = f"{attribute_name} does not match " + repr(ast.value) + case RestrictionAstType.HAS_CHOICE: + text = f"{attribute_name} is " + repr(ast.choice_name) + case RestrictionAstType.NOT_HAS_CHOICE: + text = f"{attribute_name} is not " + repr(ast.choice_name) + case RestrictionAstType.HAS_LABEL: + assert ast.label_names is not None + text = f"{attribute_name} has labels {', '.join(repr(label_name) for label_name in ast.label_names)}" + case RestrictionAstType.CAN_INPUT: + assert ast.enable is not None + text = f"{attribute_name} can be edited" if ast.enable else f"{attribute_name} is read-only" + case RestrictionAstType.IMPLY: + raise AssertionError("`imply`は事前に処理されるため、ここには到達しません。") + case _ as never: + assert_noreturn(never) + return text + + def flatten_imply_conditions(ast: RestrictionAst) -> tuple[list[RestrictionAst], RestrictionAst]: + """ + 右側にネストした `imply` を条件列と結論へ分解します。 + + Args: + ast: `imply` 種別のASTです。 + + Returns: + 条件ASTの一覧と最終的な結論ASTです。 + """ + assert ast.premise is not None + assert ast.conclusion is not None + + conditions = [ast.premise] + conclusion = ast.conclusion + while conclusion.type == RestrictionAstType.IMPLY: + assert conclusion.premise is not None + assert conclusion.conclusion is not None + conditions.append(conclusion.premise) + conclusion = conclusion.conclusion + return conditions, conclusion + + def to_human_condition_text(ast: RestrictionAst) -> str: + """ + 条件節で使う人間向け文字列表現へ変換します。 + + Args: + ast: 変換対象のASTです。 + + Returns: + 条件節で使いやすい文字列表現です。 + """ + if ast.type == RestrictionAstType.IMPLY: + return f"({ast.to_human_readable()})" + return ast.to_human_readable() + + def imply_to_human_readable(ast: RestrictionAst) -> str: + """ + `imply` AST を自然文スタイルの文字列へ変換します。 + + 右側にネストした `imply` は条件を畳み込んで、 + `If A and B, C.` のような形へ変換します。 + + Args: + ast: `imply` 種別のASTです。 + + Returns: + 自然文スタイルの文字列表現です。 + """ + conditions, conclusion = flatten_imply_conditions(ast) + conditions_text = " and ".join(to_human_condition_text(condition) for condition in conditions) + return f"If {conditions_text}, {conclusion.to_human_readable()}." + + if self.type == RestrictionAstType.IMPLY: + return imply_to_human_readable(self) + + assert self.attribute_name is not None + attribute_name = repr(self.attribute_name) + return to_human_readable_text(self, attribute_name=attribute_name) + + +RestrictionAst.model_rebuild() + + +class AttributeRestrictionCatalogItem(BaseModel): + """ + LLMへ渡す属性制約カタログの1要素を表すモデル。 + + Args: + attribute_name: 属性名です。 + attribute_type: 属性種類です。 + allowed_ast_types: その属性で利用できるAST種別の一覧です。 + choice_names: 選択系属性で利用できる選択肢名の一覧です。 + label_names: リンク属性で利用できるラベル名の一覧です。 + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + attribute_name: str = Field(description="アノテーション仕様に定義された属性名です。LLMはこの名前を使って属性を参照します。") + attribute_type: AdditionalDataDefinitionType = Field( + description="アノテーション仕様上の属性種類です。例えば flag、text、integer、tracking、link、choice、select などです。" + ) + allowed_ast_types: list[RestrictionAstType] = Field( + description="この属性で利用できる意味ベースAST種別の一覧です。LLMはこの一覧に含まれないAST種別を使ってはいけません。" + ) + choice_names: list[str] | None = Field( + default=None, + description="choice/select 属性で利用できる選択肢名の一覧です。それ以外の属性では null です。", + ) + label_names: list[str] | None = Field( + default=None, + description="link 属性で利用できるラベル名の一覧です。それ以外の属性では null です。", + ) + + @field_serializer("attribute_type") + def serialize_attribute_type(self, attribute_type: AdditionalDataDefinitionType) -> str: + return attribute_type.value + + @field_serializer("allowed_ast_types") + def serialize_allowed_ast_types(self, allowed_ast_types: list[RestrictionAstType]) -> list[str]: + return [ast_type.value for ast_type in allowed_ast_types] + + +def get_attribute_restriction_catalog(annotation_specs: dict[str, Any]) -> list[AttributeRestrictionCatalogItem]: + """ + 属性制約ASTを組み立てるための属性カタログを返します。 + + Args: + annotation_specs: アノテーション仕様(v3)の情報です。 + + Returns: + LLMへのプロンプトや入力候補生成に使いやすい属性カタログです。 + """ + + def get_allowed_ast_types(attribute_type: AdditionalDataDefinitionType) -> list[RestrictionAstType]: + """ + 属性種類ごとに利用可能なAST種別を返します。 + + Args: + attribute_type: アノテーション仕様上の属性種類です。 + + Returns: + 指定した属性種類で利用可能なAST種別の一覧です。 + """ + match attribute_type: + case "flag": + return [RestrictionAstType.CAN_INPUT, RestrictionAstType.CHECKED, RestrictionAstType.UNCHECKED] + case "text" | "comment": + return [ + RestrictionAstType.CAN_INPUT, + RestrictionAstType.IS_EMPTY, + RestrictionAstType.IS_NOT_EMPTY, + RestrictionAstType.EQUALS_STRING, + RestrictionAstType.NOT_EQUALS_STRING, + RestrictionAstType.MATCHES_STRING, + RestrictionAstType.NOT_MATCHES_STRING, + ] + case "integer": + return [ + RestrictionAstType.CAN_INPUT, + RestrictionAstType.IS_EMPTY, + RestrictionAstType.IS_NOT_EMPTY, + RestrictionAstType.EQUALS_INTEGER, + RestrictionAstType.NOT_EQUALS_INTEGER, + ] + case "link": + return [ + RestrictionAstType.CAN_INPUT, + RestrictionAstType.IS_EMPTY, + RestrictionAstType.IS_NOT_EMPTY, + RestrictionAstType.HAS_LABEL, + ] + case "tracking": + return [ + RestrictionAstType.CAN_INPUT, + RestrictionAstType.IS_EMPTY, + RestrictionAstType.IS_NOT_EMPTY, + RestrictionAstType.EQUALS_STRING, + RestrictionAstType.NOT_EQUALS_STRING, + ] + case "choice" | "select": + return [ + RestrictionAstType.CAN_INPUT, + RestrictionAstType.IS_EMPTY, + RestrictionAstType.IS_NOT_EMPTY, + RestrictionAstType.HAS_CHOICE, + RestrictionAstType.NOT_HAS_CHOICE, + ] + case _: + raise ValueError(f"未対応の属性種類です。 :: attribute_type='{attribute_type}'") + + accessor = AnnotationSpecsAccessor(annotation_specs) + all_label_names: list[str] | None = None + catalog: list[AttributeRestrictionCatalogItem] = [] + for attribute in accessor.additionals: + attribute_type = attribute["type"] + choice_names = None + label_names = None + match attribute_type: + case "choice" | "select": + choice_names = [get_english_message(choice["name"]) for choice in cast(list[AttributeChoice], attribute["choices"])] + case "link": + if all_label_names is None: + all_label_names = [get_english_message(label["label_name"]) for label in accessor.labels] + label_names = all_label_names + item = AttributeRestrictionCatalogItem( + attribute_name=get_english_message(attribute["name"]), + attribute_type=attribute_type, + allowed_ast_types=get_allowed_ast_types(attribute_type), + choice_names=choice_names, + label_names=label_names, + ) + catalog.append(item) + return catalog + + +def _from_restriction_dict(obj: dict[str, Any]) -> Restriction: + """ + API向けの制約辞書から `Restriction` を復元します。 + + Args: + obj: APIの `restrictions` 要素を表す辞書です。 + Returns: + 復元した `Restriction` オブジェクトです。 + """ + attribute_id = obj["additional_data_definition_id"] + condition = obj["condition"] + return _from_condition_dict(attribute_id=attribute_id, condition=condition) + + +def _from_condition_dict(*, attribute_id: str, condition: dict[str, Any]) -> Restriction: + """ + 条件部分の辞書から `Restriction` を復元します。 + + Args: + attribute_id: 対象属性のIDです。 + condition: 条件部分のみを表す辞書です。 + Returns: + 復元した `Restriction` オブジェクトです。 + """ + condition_type = condition["_type"] + restriction: Restriction + match condition_type: + case "Imply": + premise_restriction = _from_restriction_dict(condition["premise"]) + conclusion_restriction = _from_condition_dict(attribute_id=attribute_id, condition=condition["condition"]) + restriction = Imply(premise_restriction=premise_restriction, conclusion_restriction=conclusion_restriction) + case "CanInput": + restriction = CanInput(attribute_id, enable=condition["enable"]) + case "Equals": + restriction = Equals(attribute_id, value=condition["value"]) + case "NotEquals": + restriction = NotEquals(attribute_id, value=condition["value"]) + case "Matches": + restriction = Matches(attribute_id, value=condition["value"]) + case "NotMatches": + restriction = NotMatches(attribute_id, value=condition["value"]) + case "HasLabel": + restriction = HasLabel(attribute_id, label_ids=condition["labels"]) + case _: + raise ValueError(f"未知の制約種別です。 :: _type='{condition_type}'") + return restriction + + +def _ast_to_restriction(ast: RestrictionAst, *, fac: AttributeFactory) -> Restriction: # noqa: PLR0915 + """ + 意味ベースのASTを `Restriction` オブジェクトへコンパイルします。 + + Args: + ast: 変換元のASTです。 + fac: 属性生成と妥当性検証に使う `AttributeFactory` です。 + + Returns: + 変換後の `Restriction` オブジェクトです。 + + Raises: + ValueError: AST種別が未知の場合、または属性型に対して利用できないASTが指定された場合 + """ + + def raise_invalid_ast(*, attribute: AttributeDefinition, ast: RestrictionAst) -> NoReturn: + """ + 属性型に対して不正なAST種別が指定されたことを表す例外を送出します。 + + Args: + attribute: アノテーション仕様上の属性定義です。 + ast: 不正だったASTです。 + + Raises: + ValueError: 常に送出されます。 + """ + attribute_name = get_english_message(attribute["name"]) + raise ValueError(f"属性'{attribute_name}'(type='{attribute['type']}')ではAST種別'{ast.type}'を利用できません。") + + def attribute_with_empty_check(*, attribute: AttributeDefinition) -> EmptyCheckMixin: + """ + 空判定をサポートする属性オブジェクトを取得します。 + + Args: + attribute: アノテーション仕様上の属性定義です。 + + Returns: + `is_empty()` / `is_not_empty()` を持つ属性オブジェクトです。 + + Raises: + ValueError: 指定した属性で空判定を利用できない場合 + """ + attribute_obj = fac.create(attribute["type"], attribute_id=attribute["additional_data_definition_id"]) + if not isinstance(attribute_obj, EmptyCheckMixin): + _raise_invalid_restriction( + attribute=attribute, + condition={"_type": "EmptyCheck"}, + detail="空判定はこの属性種類では利用できません。", + ) + assert isinstance(attribute_obj, EmptyCheckMixin) + return attribute_obj + + def ast_string_equality_to_restriction( + *, + ast: RestrictionAst, + attribute: AttributeDefinition, + attribute_type: AdditionalDataDefinitionType, + ) -> Restriction: + assert isinstance(ast.value, str) + attribute_obj: StringTextbox | TrackingId + match attribute_type: + case "text" | "comment": + attribute_obj = fac.string_textbox(attribute_name=ast.attribute_name) + case "tracking": + attribute_obj = fac.tracking_id(attribute_name=ast.attribute_name) + case _: + raise_invalid_ast(attribute=attribute, ast=ast) + + match ast.type: + case RestrictionAstType.EQUALS_STRING: + return attribute_obj.equals(ast.value) + case RestrictionAstType.NOT_EQUALS_STRING: + return attribute_obj.not_equals(ast.value) + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + + def ast_string_match_to_restriction( + *, + ast: RestrictionAst, + attribute: AttributeDefinition, + attribute_type: AdditionalDataDefinitionType, + ) -> Restriction: + assert isinstance(ast.value, str) + if attribute_type not in {"text", "comment"}: + raise_invalid_ast(attribute=attribute, ast=ast) + + attribute_obj = fac.string_textbox(attribute_name=ast.attribute_name) + match ast.type: + case RestrictionAstType.MATCHES_STRING: + return attribute_obj.matches(ast.value) + case RestrictionAstType.NOT_MATCHES_STRING: + return attribute_obj.not_matches(ast.value) + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + + def ast_to_atomic_restriction(ast: RestrictionAst, *, attribute: AttributeDefinition) -> Restriction: # noqa: PLR0912 + assert ast.attribute_name is not None + attribute_type = attribute["type"] + restriction: Restriction + + match ast.type: + case RestrictionAstType.CHECKED: + restriction = fac.checkbox(attribute_name=ast.attribute_name).checked() + case RestrictionAstType.UNCHECKED: + restriction = fac.checkbox(attribute_name=ast.attribute_name).unchecked() + case RestrictionAstType.IS_EMPTY: + restriction = attribute_with_empty_check(attribute=attribute).is_empty() + case RestrictionAstType.IS_NOT_EMPTY: + restriction = attribute_with_empty_check(attribute=attribute).is_not_empty() + case RestrictionAstType.CAN_INPUT: + assert ast.enable is not None + attribute_obj = fac.create(attribute["type"], attribute_name=ast.attribute_name) + restriction = attribute_obj.enabled() if ast.enable else attribute_obj.disabled() + case RestrictionAstType.EQUALS_STRING | RestrictionAstType.NOT_EQUALS_STRING: + restriction = ast_string_equality_to_restriction(ast=ast, attribute=attribute, attribute_type=attribute_type) + case RestrictionAstType.MATCHES_STRING | RestrictionAstType.NOT_MATCHES_STRING: + restriction = ast_string_match_to_restriction(ast=ast, attribute=attribute, attribute_type=attribute_type) + case RestrictionAstType.EQUALS_INTEGER | RestrictionAstType.NOT_EQUALS_INTEGER: + assert isinstance(ast.value, int) + attribute_obj = fac.integer_textbox(attribute_name=ast.attribute_name) + match ast.type: + case RestrictionAstType.EQUALS_INTEGER: + restriction = attribute_obj.equals(ast.value) + case RestrictionAstType.NOT_EQUALS_INTEGER: + restriction = attribute_obj.not_equals(ast.value) + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + case RestrictionAstType.HAS_CHOICE | RestrictionAstType.NOT_HAS_CHOICE: + assert ast.choice_name is not None + attribute_obj = fac.selection(attribute_name=ast.attribute_name) + match ast.type: + case RestrictionAstType.HAS_CHOICE: + restriction = attribute_obj.has_choice(choice_name=ast.choice_name) + case RestrictionAstType.NOT_HAS_CHOICE: + restriction = attribute_obj.not_has_choice(choice_name=ast.choice_name) + case _: + raise ValueError(f"未知のAST種別です。 :: type='{ast.type}'") + case RestrictionAstType.HAS_LABEL: + assert ast.label_names is not None + restriction = fac.annotation_link(attribute_name=ast.attribute_name).has_label(label_names=ast.label_names) + case RestrictionAstType.IMPLY: + raise AssertionError("`imply`は `_ast_to_restriction` で処理されるため、ここには到達しません。") + case _ as never: + assert_noreturn(never) + return restriction + + match ast.type: + case RestrictionAstType.IMPLY: + assert ast.premise is not None + assert ast.conclusion is not None + premise_restriction = _ast_to_restriction(ast.premise, fac=fac) + conclusion_restriction = _ast_to_restriction(ast.conclusion, fac=fac) + return premise_restriction.imply(conclusion_restriction) + + assert ast.attribute_name is not None + attribute = fac.accessor.get_attribute(attribute_name=ast.attribute_name) + return ast_to_atomic_restriction(ast, attribute=attribute) + + +def _raise_invalid_restriction(*, attribute: AttributeDefinition, condition: dict[str, Any], detail: str | None = None) -> NoReturn: + """ + 属性型に対して不正な制約が指定されたことを表す例外を送出します。 + + Args: + attribute: アノテーション仕様上の属性定義です。 + condition: 不正だった制約条件です。 + detail: 補足メッセージです。 + + Raises: + ValueError: 常に送出されます。 + """ + attribute_name = get_english_message(attribute["name"]) + message = f"属性'{attribute_name}'(type='{attribute['type']}')では制約'{condition['_type']}'を利用できません。" + if detail is not None: + message += f" {detail}" + raise ValueError(message) + + +def _restriction_to_ast(restriction: Restriction, *, accessor: AnnotationSpecsAccessor) -> RestrictionAst: + """ + `Restriction` を意味ベースの `RestrictionAst` へ変換します。 + + Args: + restriction: 変換元の `Restriction` です。 + accessor: 属性名や選択肢名の解決に使う `AnnotationSpecsAccessor` です。 + + Returns: + 変換後の `RestrictionAst` です。 + + Raises: + ValueError: ASTへ変換できない制約が含まれている場合 + """ + + def parse_integer_value(value: str, *, attribute: AttributeDefinition, condition: dict[str, Any]) -> int: + """ + 整数属性向けの文字列値を整数へ変換します。 + + Args: + value: 変換対象の文字列値です。 + attribute: アノテーション仕様上の属性定義です。 + condition: 元の条件辞書です。 + + Returns: + 変換後の整数値です。 + + Raises: + ValueError: 整数へ変換できない場合 + """ + try: + return int(value) + except ValueError as exc: + _raise_invalid_restriction(attribute=attribute, condition=condition, detail="整数属性には整数値を指定してください。") + raise AssertionError("unreachable") from exc + + def equals_restriction_to_ast( + *, + attribute: AttributeDefinition, + attribute_name: str, + attribute_type: AdditionalDataDefinitionType, + value: str, + ) -> RestrictionAst: + match attribute_type: + case "flag" if value == "true": + return RestrictionAst(type=RestrictionAstType.CHECKED, attribute_name=attribute_name) + case "text" | "comment" | "integer" | "link" | "tracking" | "choice" | "select" if value == "": + return RestrictionAst(type=RestrictionAstType.IS_EMPTY, attribute_name=attribute_name) + case "text" | "comment" | "tracking": + return RestrictionAst(type=RestrictionAstType.EQUALS_STRING, attribute_name=attribute_name, value=value) + case "integer": + return RestrictionAst( + type=RestrictionAstType.EQUALS_INTEGER, + attribute_name=attribute_name, + value=parse_integer_value(value, attribute=attribute, condition={"_type": "Equals", "value": value}), + ) + case "choice" | "select": + choice = get_choice(cast(list[AttributeChoice], attribute["choices"]), choice_id=value) + return RestrictionAst( + type=RestrictionAstType.HAS_CHOICE, + attribute_name=attribute_name, + choice_name=get_english_message(choice["name"]), + ) + case _: + raise ValueError( + f"RestrictionをASTへ変換できません。 :: restriction_type='Equals', attribute_type='{attribute_type}', value={value!r}" + ) + + def not_equals_restriction_to_ast( + *, + attribute: AttributeDefinition, + attribute_name: str, + attribute_type: AdditionalDataDefinitionType, + value: str, + ) -> RestrictionAst: + match attribute_type: + case "flag" if value == "true": + return RestrictionAst(type=RestrictionAstType.UNCHECKED, attribute_name=attribute_name) + case "text" | "comment" | "integer" | "link" | "tracking" | "choice" | "select" if value == "": + return RestrictionAst(type=RestrictionAstType.IS_NOT_EMPTY, attribute_name=attribute_name) + case "text" | "comment" | "tracking": + return RestrictionAst(type=RestrictionAstType.NOT_EQUALS_STRING, attribute_name=attribute_name, value=value) + case "integer": + return RestrictionAst( + type=RestrictionAstType.NOT_EQUALS_INTEGER, + attribute_name=attribute_name, + value=parse_integer_value(value, attribute=attribute, condition={"_type": "NotEquals", "value": value}), + ) + case "choice" | "select": + choice = get_choice(cast(list[AttributeChoice], attribute["choices"]), choice_id=value) + return RestrictionAst( + type=RestrictionAstType.NOT_HAS_CHOICE, + attribute_name=attribute_name, + choice_name=get_english_message(choice["name"]), + ) + case _: + raise ValueError( + f"RestrictionをASTへ変換できません。 :: restriction_type='NotEquals', attribute_type='{attribute_type}', value={value!r}" + ) + + def restriction_to_atomic_ast( + restriction: Restriction, + *, + attribute: AttributeDefinition, + attribute_name: str, + ) -> RestrictionAst: + attribute_type = attribute["type"] + match restriction: + case CanInput(enable=enable): + return RestrictionAst(type=RestrictionAstType.CAN_INPUT, attribute_name=attribute_name, enable=enable) + case Equals(value=value): + return equals_restriction_to_ast( + attribute=attribute, + attribute_name=attribute_name, + attribute_type=attribute_type, + value=value, + ) + case NotEquals(value=value): + return not_equals_restriction_to_ast( + attribute=attribute, + attribute_name=attribute_name, + attribute_type=attribute_type, + value=value, + ) + case Matches(value=value) if attribute_type in {"text", "comment"}: + return RestrictionAst(type=RestrictionAstType.MATCHES_STRING, attribute_name=attribute_name, value=value) + case NotMatches(value=value) if attribute_type in {"text", "comment"}: + return RestrictionAst(type=RestrictionAstType.NOT_MATCHES_STRING, attribute_name=attribute_name, value=value) + case HasLabel(label_ids=label_ids) if attribute_type == "link": + label_names = [get_english_message(accessor.get_label(label_id=label_id)["label_name"]) for label_id in label_ids] + return RestrictionAst(type=RestrictionAstType.HAS_LABEL, attribute_name=attribute_name, label_names=label_names) + case _: + _raise_invalid_restriction(attribute=attribute, condition=restriction.to_dict()["condition"]) + + match restriction: + case Imply(premise_restriction=premise_restriction, conclusion_restriction=conclusion_restriction): + return RestrictionAst( + type=RestrictionAstType.IMPLY, + premise=_restriction_to_ast(premise_restriction, accessor=accessor), + conclusion=_restriction_to_ast(conclusion_restriction, accessor=accessor), + ) + + attribute = accessor.get_attribute(attribute_id=restriction.attribute_id) + attribute_name = get_english_message(attribute["name"]) + return restriction_to_atomic_ast(restriction, attribute=attribute, attribute_name=attribute_name) diff --git a/tests/util/test_annotation_specs.py b/tests/util/test_annotation_specs.py index f9f5d813..e508c0eb 100644 --- a/tests/util/test_annotation_specs.py +++ b/tests/util/test_annotation_specs.py @@ -1,6 +1,6 @@ import pytest -from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, Lang, get_choice, get_english_message, get_message_with_lang +from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, AttributeChoice, Lang, get_choice, get_english_message, get_message_with_lang class Test__get_english_message: @@ -87,7 +87,7 @@ def test_get_attribute_by_id_and_label__not_found(self): class Test__get_choice: def setup_method(self): - self.choices = [ + self.choices: list[AttributeChoice] = [ {"choice_id": "1", "name": {"messages": [{"lang": "en-US", "message": "Option1"}]}}, {"choice_id": "2", "name": {"messages": [{"lang": "en-US", "message": "Option2"}]}}, ] diff --git a/tests/util/test_attribute_restrictions.py b/tests/util/test_attribute_restrictions.py index 2367a6c7..edfbd3fc 100644 --- a/tests/util/test_attribute_restrictions.py +++ b/tests/util/test_attribute_restrictions.py @@ -1,10 +1,26 @@ +import copy import json from pathlib import Path import pytest - -from annofabapi.util.annotation_specs import AnnotationSpecsAccessor -from annofabapi.util.attribute_restrictions import AnnotationLink, AttributeFactory, Checkbox, IntegerTextbox, Selection, StringTextbox, TrackingId +from pydantic import ValidationError + +from annofabapi.pydantic_models.additional_data_definition_type import AdditionalDataDefinitionType +from annofabapi.util.annotation_specs import AnnotationSpecsAccessor, get_english_message +from annofabapi.util.attribute_restrictions import ( + AnnotationLink, + AttributeFactory, + AttributeRestrictionCatalogItem, + Checkbox, + IntegerTextbox, + Restriction, + RestrictionAst, + RestrictionAstType, + Selection, + StringTextbox, + TrackingId, + get_attribute_restriction_catalog, +) accessor = AnnotationSpecsAccessor(annotation_specs=json.loads(Path("tests/data/util/attribute_restrictions/annotation_specs.json").read_text())) @@ -22,7 +38,7 @@ def test__unchecked(self): } def test__is_valid_attribute_type(self): - with pytest.raises(ValueError, match="属性の種類が'tracking'である属性は、クラス'Checkbox'では扱えません。"): + with pytest.raises(ValueError): Checkbox(accessor, attribute_id="d349e76d-b59a-44cd-94b4-713a00b2e84d") @@ -197,3 +213,286 @@ def test__selection(self): selection = self.factory.selection(attribute_name="car_kind") assert isinstance(selection, Selection) assert selection.attribute_id == "cbb0155f-1631-48e1-8fc3-43c5f254b6f2" + + def test__create(self): + actual = self.factory.create(AdditionalDataDefinitionType.TEXT, attribute_name="note") + + assert isinstance(actual, StringTextbox) + assert actual.attribute_id == "9b05648d-1e16-4ea2-ab79-48907f5eed00" + + +class Test__Restriction: + def test__from_dict(self): + restriction_dict = { + "additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", + "condition": { + "_type": "Imply", + "premise": { + "additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", + "condition": {"_type": "Equals", "value": "true"}, + }, + "condition": {"_type": "NotEquals", "value": ""}, + }, + } + + actual = Restriction.from_dict(restriction_dict) + + assert actual.to_dict() == restriction_dict + + def test__to_ast(self): + restriction = Restriction.from_dict( + { + "additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", + "condition": { + "_type": "Imply", + "premise": { + "additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", + "condition": {"_type": "Equals", "value": "true"}, + }, + "condition": {"_type": "NotEquals", "value": ""}, + }, + } + ) + + actual = restriction.to_ast(accessor.annotation_specs) + + assert actual == RestrictionAst( + type=RestrictionAstType.IMPLY, + premise=RestrictionAst(type=RestrictionAstType.CHECKED, attribute_name="occluded"), + conclusion=RestrictionAst(type=RestrictionAstType.IS_NOT_EMPTY, attribute_name="note"), + ) + + def test__to_human_readable(self): + restriction = Restriction.from_dict( + { + "additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", + "condition": { + "_type": "Imply", + "premise": { + "additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", + "condition": {"_type": "Equals", "value": "true"}, + }, + "condition": {"_type": "NotEquals", "value": ""}, + }, + } + ) + + actual = restriction.to_human_readable(accessor.annotation_specs) + + assert actual == "If 'occluded' is checked, 'note' is not empty." + + def test__to_human_readable__右側にネストしたimplyは条件をまとめる(self): + restriction = Restriction.from_dict( + { + "additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", + "condition": { + "_type": "Imply", + "premise": { + "additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", + "condition": {"_type": "Equals", "value": "true"}, + }, + "condition": { + "_type": "Imply", + "premise": { + "additional_data_definition_id": "ec27de5d-122c-40e7-89bc-5500e37bae6a", + "condition": {"_type": "Equals", "value": "2"}, + }, + "condition": {"_type": "NotEquals", "value": ""}, + }, + }, + } + ) + + actual = restriction.to_human_readable(accessor.annotation_specs) + + assert actual == "If 'occluded' is checked and 'traffic_lane' is 2, 'note' is not empty." + + def test__from_dict__妥当性検証せずに復元する(self): + restriction_dict = { + "additional_data_definition_id": "d349e76d-b59a-44cd-94b4-713a00b2e84d", + "condition": {"_type": "Matches", "value": "\\d+"}, + } + + actual = Restriction.from_dict(restriction_dict) + + assert actual.to_dict() == restriction_dict + + def test__to_ast__tracking_id属性にmatchesは指定できない(self): + restriction_dict = { + "additional_data_definition_id": "d349e76d-b59a-44cd-94b4-713a00b2e84d", + "condition": {"_type": "Matches", "value": "\\d+"}, + } + restriction = Restriction.from_dict(restriction_dict) + + with pytest.raises(ValueError): + restriction.to_ast(accessor.annotation_specs) + + def test__to_ast__integer属性に整数以外の値は指定できない(self): + restriction_dict = { + "additional_data_definition_id": "ec27de5d-122c-40e7-89bc-5500e37bae6a", + "condition": {"_type": "Equals", "value": "foo"}, + } + restriction = Restriction.from_dict(restriction_dict) + + with pytest.raises(ValueError): + restriction.to_ast(accessor.annotation_specs) + + def test__from_dict__can_input_true(self): + restriction_dict = { + "additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", + "condition": {"_type": "CanInput", "enable": True}, + } + restriction = Restriction.from_dict(restriction_dict) + + assert restriction.to_dict() == restriction_dict + assert restriction.to_ast(accessor.annotation_specs) == RestrictionAst( + type=RestrictionAstType.CAN_INPUT, + attribute_name="occluded", + enable=True, + ) + + def test__from_ast(self): + ast = RestrictionAst( + type=RestrictionAstType.IMPLY, + premise=RestrictionAst(type=RestrictionAstType.CHECKED, attribute_name="occluded"), + conclusion=RestrictionAst(type=RestrictionAstType.HAS_CHOICE, attribute_name="car_kind", choice_name="general_car"), + ) + + actual = Restriction.from_ast(ast, accessor.annotation_specs) + + assert actual.to_dict() == { + "additional_data_definition_id": "cbb0155f-1631-48e1-8fc3-43c5f254b6f2", + "condition": { + "_type": "Imply", + "premise": { + "additional_data_definition_id": "2517f635-2269-4142-8ef4-16312b4cc9f7", + "condition": {"_type": "Equals", "value": "true"}, + }, + "condition": {"_type": "Equals", "value": "7512ee39-8073-4e24-9b8c-93d99b76b7d2"}, + }, + } + + +class Test__RestrictionAst: + def test__model_dump(self): + ast = RestrictionAst( + type=RestrictionAstType.IMPLY, + premise=RestrictionAst(type=RestrictionAstType.CHECKED, attribute_name="occluded"), + conclusion=RestrictionAst(type=RestrictionAstType.IS_NOT_EMPTY, attribute_name="note"), + ) + + assert ast.model_dump(mode="python", exclude_none=True) == { + "type": "imply", + "premise": {"type": "checked", "attribute_name": "occluded"}, + "conclusion": {"type": "is_not_empty", "attribute_name": "note"}, + } + + def test__model_validate(self): + actual = RestrictionAst.model_validate( + { + "type": "imply", + "premise": {"type": "checked", "attribute_name": "occluded"}, + "conclusion": {"type": "has_choice", "attribute_name": "car_kind", "choice_name": "general_car"}, + } + ) + + assert actual == RestrictionAst( + type=RestrictionAstType.IMPLY, + premise=RestrictionAst(type=RestrictionAstType.CHECKED, attribute_name="occluded"), + conclusion=RestrictionAst(type=RestrictionAstType.HAS_CHOICE, attribute_name="car_kind", choice_name="general_car"), + ) + assert actual.type is RestrictionAstType.IMPLY + + def test__to_restriction(self): + ast = RestrictionAst(type=RestrictionAstType.MATCHES_STRING, attribute_name="note", value="[abc]+") + + actual = ast.to_restriction(accessor.annotation_specs) + + assert actual.to_dict() == { + "additional_data_definition_id": "9b05648d-1e16-4ea2-ab79-48907f5eed00", + "condition": {"_type": "Matches", "value": "[abc]+"}, + } + + def test__to_restriction__trackingにはmatches_stringを指定できない(self): + ast = RestrictionAst(type=RestrictionAstType.MATCHES_STRING, attribute_name="tracking", value="foo") + + with pytest.raises(ValueError): + ast.to_restriction(accessor.annotation_specs) + + def test__to_human_readable(self): + ast = RestrictionAst(type=RestrictionAstType.HAS_LABEL, attribute_name="link_car", label_names=["car", "number_plate"]) + + actual = ast.to_human_readable() + + assert actual == "'link_car' has labels 'car', 'number_plate'" + + def test__invalid_fields(self): + with pytest.raises(ValidationError): + RestrictionAst(type=RestrictionAstType.EQUALS_STRING, attribute_name="note") + + def test__invalid_field_type(self): + with pytest.raises(ValidationError): + RestrictionAst(type=RestrictionAstType.EQUALS_STRING, attribute_name="note", value=1) + + def test__model_json_schema(self): + actual = RestrictionAst.model_json_schema() + properties = actual["$defs"]["RestrictionAst"]["properties"] + + assert properties["type"]["description"] == "ASTノードの種類です。" + assert properties["attribute_name"]["description"] == "対象属性の名前です。" + assert properties["premise"]["description"] == "`imply` ノードの前提です。" + + +class Test__get_attribute_restriction_catalog: + def test__catalog(self): + actual = get_attribute_restriction_catalog(accessor.annotation_specs) + + assert all(isinstance(item, AttributeRestrictionCatalogItem) for item in actual) + assert isinstance(actual[0].allowed_ast_types[0], RestrictionAstType) + assert { + "attribute_name": "tracking", + "attribute_type": "tracking", + "allowed_ast_types": ["can_input", "is_empty", "is_not_empty", "equals_string", "not_equals_string"], + "choice_names": None, + "label_names": None, + } in [item.model_dump() for item in actual] + assert { + "attribute_name": "car_kind", + "attribute_type": "choice", + "allowed_ast_types": ["can_input", "is_empty", "is_not_empty", "has_choice", "not_has_choice"], + "choice_names": ["general_car", "emergency_vehicle", "construction_vehicle"], + "label_names": None, + } in [item.model_dump() for item in actual] + + def test__複数link属性でも同じラベル一覧を返す(self): + annotation_specs = copy.deepcopy(accessor.annotation_specs) + expected_label_names = [get_english_message(label["label_name"]) for label in annotation_specs["labels"]] + link_attribute = next(attribute for attribute in annotation_specs["additionals"] if attribute["type"] == "link") + copied_link_attribute = copy.deepcopy(link_attribute) + copied_link_attribute["additional_data_definition_id"] = "dummy-link-id" + copied_link_attribute["name"]["messages"][0]["message"] = "link_car_2" + copied_link_attribute["name"]["messages"][1]["message"] = "リンク_車両2" + copied_link_attribute["name"]["messages"][2]["message"] = "link_car_2" + annotation_specs["additionals"].append(copied_link_attribute) + + actual = get_attribute_restriction_catalog(annotation_specs) + + link_items = [item for item in actual if item.attribute_type == AdditionalDataDefinitionType.LINK] + assert len(link_items) == 2 + assert link_items[0].label_names == expected_label_names + assert link_items[1].label_names == expected_label_names + + def test__catalog_model_json_schema(self): + actual = AttributeRestrictionCatalogItem.model_json_schema() + + assert ( + actual["properties"]["attribute_name"]["description"] + == "アノテーション仕様に定義された属性名です。LLMはこの名前を使って属性を参照します。" + ) + assert ( + actual["properties"]["allowed_ast_types"]["description"] + == "この属性で利用できる意味ベースAST種別の一覧です。LLMはこの一覧に含まれないAST種別を使ってはいけません。" + ) + assert ( + actual["properties"]["choice_names"]["description"] == "choice/select 属性で利用できる選択肢名の一覧です。それ以外の属性では null です。" + ) diff --git a/tests/util/test_page.py b/tests/util/test_page.py index daa7d3b8..fd18602d 100644 --- a/tests/util/test_page.py +++ b/tests/util/test_page.py @@ -58,7 +58,7 @@ def test_with_both_ids(self): def test_annotation_id_without_input_data_id_raises_error(self): """annotation_idのみを指定した場合にValueErrorが発生することをテスト""" - with pytest.raises(ValueError, match="'input_data_id' must be specified if 'annotation_id' is specified"): + with pytest.raises(ValueError): create_image_editor_url("project1", "task1", annotation_id="annotation1") @@ -92,5 +92,5 @@ def test_with_both_ids(self): def test_annotation_id_without_input_data_id_raises_error(self): """annotation_idのみを指定した場合にValueErrorが発生することをテスト""" - with pytest.raises(ValueError, match="'input_data_id' must be specified if 'annotation_id' is specified"): + with pytest.raises(ValueError): create_3dpc_editor_url("project1", "task1", annotation_id="annotation1")