From 9974b05813f8ee6715a26f2a620f6ae4d93d8270 Mon Sep 17 00:00:00 2001 From: Nanduu24 Date: Sun, 13 Sep 2026 18:39:20 -0500 Subject: [PATCH 1/7] FEAT: Add PinyinConverter for Chinese Pinyin-mix transformations Adds a deterministic (no-LLM) text converter that rewrites Chinese (Hanzi) characters as their Pinyin romanization, a Chinese-specific adversarial pattern described in recent Chinese LLM safety work (e.g. CSSBench). Pinyin mixing can bypass keyword/token-level safety filters that match on Hanzi rather than on romanized readings. Supports three rendering modes (full reading, first-letter initial, and a per-character mix), a configurable proportion of Hanzi to convert (values below 1.0 leave the rest as Hanzi, producing mixed Hanzi/Pinyin text), an optional syllable separator, and a seed for reproducible selection. Non-Hanzi characters always pass through unchanged. pypinyin is added as an optional dependency (pip install pyrit[pinyin]) and imported lazily, following the pattern used by other optional-dependency converters. Tests that exercise conversion are skipped when pypinyin is absent. Closes #2647 --- pyproject.toml | 5 + pyrit/converter/__init__.py | 2 + pyrit/converter/pinyin_converter.py | 206 ++++++++++++++++++ tests/unit/converter/test_pinyin_converter.py | 130 +++++++++++ 4 files changed, 343 insertions(+) create mode 100644 pyrit/converter/pinyin_converter.py create mode 100644 tests/unit/converter/test_pinyin_converter.py diff --git a/pyproject.toml b/pyproject.toml index 9b618130f2..de0bdea77a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,6 +133,10 @@ litellm = [ "litellm>=1.84.0", ] +pinyin = [ + "pypinyin>=0.53.0", +] + # all includes all functional dependencies excluding the ones from the "dev" dependency group all = [ "accelerate>=1.7.0", @@ -146,6 +150,7 @@ all = [ "opencv-python>=4.11.0.86", "playwright>=1.49.0", "pyarrow>=22.0.0; python_version >= '3.14'", + "pypinyin>=0.53.0", "sentencepiece>=0.2.0", "spacy>=3.8.13,!=3.8.14,!=3.8.15", # 3.8.14-3.8.15 missing cp314 wheels "torch>=2.7.0", diff --git a/pyrit/converter/__init__.py b/pyrit/converter/__init__.py index 6f749d62c1..f70175bf43 100644 --- a/pyrit/converter/__init__.py +++ b/pyrit/converter/__init__.py @@ -80,6 +80,7 @@ from pyrit.converter.noise_converter import NoiseConverter from pyrit.converter.pdf_converter import PDFConverter from pyrit.converter.persuasion_converter import PersuasionConverter + from pyrit.converter.pinyin_converter import PinyinConverter from pyrit.converter.policy_puppetry_converter import PolicyPuppetryConverter, PolicyPuppetryTemplate from pyrit.converter.puzzled import PuzzledConverter, PuzzleType from pyrit.converter.qr_code_converter import QRCodeConverter @@ -203,6 +204,7 @@ "NoiseConverter": "pyrit.converter.noise_converter", "PDFConverter": "pyrit.converter.pdf_converter", "PersuasionConverter": "pyrit.converter.persuasion_converter", + "PinyinConverter": "pyrit.converter.pinyin_converter", "PolicyPuppetryConverter": "pyrit.converter.policy_puppetry_converter", "PolicyPuppetryTemplate": "pyrit.converter.policy_puppetry_converter", "PositionSelectionStrategy": "pyrit.converter.text_selection_strategy", diff --git a/pyrit/converter/pinyin_converter.py b/pyrit/converter/pinyin_converter.py new file mode 100644 index 0000000000..1c49204def --- /dev/null +++ b/pyrit/converter/pinyin_converter.py @@ -0,0 +1,206 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import logging +import re +from typing import Any, Literal + +from pyrit.converter.converter import Converter, ConverterResult +from pyrit.models import ComponentIdentifier, PromptDataType + +logger = logging.getLogger(__name__) + +PinyinMode = Literal["full", "initial", "mixed"] + +# Han (Chinese) character ranges. pypinyin only has readings for these, so +# everything else (Latin, digits, punctuation, whitespace, emoji, ...) is passed +# through untouched. +_HAN_PATTERN = re.compile( + "[" + "㐀-䶿" # CJK Unified Ideographs Extension A + "一-鿿" # CJK Unified Ideographs + "豈-﫿" # CJK Compatibility Ideographs + "\U00020000-\U0002a6df" # CJK Unified Ideographs Extension B + "\U0002a700-\U0002ebef" # CJK Unified Ideographs Extensions C-F + "\U0002f800-\U0002fa1f" # CJK Compatibility Ideographs Supplement + "]" +) + + +class PinyinConverter(Converter): + """ + Replaces Chinese (Hanzi) characters with their Pinyin romanization. + + Pinyin mixing is a Chinese-specific adversarial text transformation: Hanzi characters + or spans are rewritten as full or abbreviated Pinyin while the text stays understandable + to a Chinese-reading model. This can bypass keyword- and token-level safety filters that + match on Hanzi rather than on romanized readings. The pattern is described in recent work + on Chinese LLM safety such as CSSBench. + + The converter is deterministic (no LLM call) and operates character by character: + + - ``full``: each selected Hanzi becomes its full Pinyin reading without tone marks + (e.g. ``中`` -> ``zhong``). + - ``initial``: each selected Hanzi becomes the first letter of its reading + (e.g. ``中`` -> ``z``). + - ``mixed``: each selected Hanzi is independently rendered as either its full reading or + its initial. + + ``proportion`` controls how many of the Hanzi are converted; a value below ``1.0`` leaves + the rest as Hanzi, producing mixed Hanzi/Pinyin text. Characters that are not Hanzi are + always left unchanged. Pass ``seed`` for reproducible selection. + + This converter requires the optional ``pinyin`` dependency: ``pip install pyrit[pinyin]``. + """ + + SUPPORTED_INPUT_TYPES = ("text",) + SUPPORTED_OUTPUT_TYPES = ("text",) + + def __init__( + self, + *, + mode: PinyinMode = "full", + proportion: float = 1.0, + separator: str = "", + seed: int | None = None, + ) -> None: + """ + Initialize the converter. + + Args: + mode (PinyinMode): How a converted Hanzi is rendered. ``"full"`` uses the full + toneless reading, ``"initial"`` uses only the first letter, and ``"mixed"`` + chooses one of the two independently per character. Defaults to ``"full"``. + proportion (float): Fraction in ``[0.0, 1.0]`` of the Hanzi characters to convert. + The selected count is ``round(proportion * number_of_hanzi)`` and the positions + are chosen at random (seedable). ``1.0`` converts every Hanzi. Defaults to + ``1.0``. + separator (str): String inserted after each converted syllable. Full Pinyin spans + run together by default (``中心`` -> ``zhongxin``); pass ``separator=" "`` to + keep syllable boundaries readable (``zhong xin``). Defaults to ``""``. + seed (int | None): Optional seed for reproducible selection and, in ``"mixed"`` + mode, reproducible per-character rendering. Defaults to None. + + Raises: + ValueError: If ``mode`` is not one of ``"full"``, ``"initial"``, ``"mixed"`` or if + ``proportion`` is outside ``[0.0, 1.0]``. + """ + if mode not in ("full", "initial", "mixed"): + raise ValueError('mode must be one of "full", "initial", or "mixed"') + if not 0.0 <= proportion <= 1.0: + raise ValueError("proportion must be between 0.0 and 1.0") + + self._mode: PinyinMode = mode + self._proportion = proportion + self._separator = separator + self._seed = seed + + def _build_identifier(self) -> ComponentIdentifier: + """ + Build the converter identifier with Pinyin parameters. + + Returns: + ComponentIdentifier: The identifier for this converter. + """ + return self._create_identifier( + params={ + "mode": self._mode, + "proportion": self._proportion, + "separator": self._separator, + "seed": self._seed, + } + ) + + @staticmethod + def _import_pypinyin() -> Any: + """ + Import the optional ``pypinyin`` dependency, raising a helpful error if it is missing. + + Returns: + Any: The imported ``pypinyin`` module. + + Raises: + ModuleNotFoundError: If ``pypinyin`` is not installed. + """ + try: + import pypinyin + except ModuleNotFoundError as exc: + logger.error("Could not import pypinyin. You may need to install it via 'pip install pyrit[pinyin]'") + raise ModuleNotFoundError( + "PinyinConverter requires the 'pypinyin' package. Install it via 'pip install pyrit[pinyin]'." + ) from exc + return pypinyin + + def _to_pinyin(self, char: str, *, style: Any, pypinyin: Any) -> str: + """ + Return the Pinyin reading of a single Hanzi for the given ``pypinyin`` style. + + Args: + char (str): A single Hanzi character to romanize. + style (Any): A ``pypinyin.Style`` member controlling the reading format. + pypinyin (Any): The imported ``pypinyin`` module. + + Returns: + str: The Pinyin reading, or the original character when no reading is available. + """ + result = pypinyin.lazy_pinyin(char, style=style) + if not result: + return char + reading = str(result[0]) + return reading or char + + async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text") -> ConverterResult: + """ + Convert Hanzi characters in the prompt to Pinyin. + + Args: + prompt (str): The text prompt to convert. + input_type (PromptDataType): The input data type. Only ``text`` is supported. + + Returns: + ConverterResult: The converted prompt. + + Raises: + ValueError: If the input type is not supported. + """ + if not self.input_supported(input_type): + raise ValueError("Input type not supported") + + pypinyin = self._import_pypinyin() + from pypinyin import Style + + han_indices = [i for i, ch in enumerate(prompt) if _HAN_PATTERN.match(ch)] + if not han_indices: + return ConverterResult(output_text=prompt, output_type="text") + + rng = self._get_random_generator(stream="pinyin-selection") + count = round(self._proportion * len(han_indices)) + selected = set(rng.sample(han_indices, count)) if count else set() + + full_style = Style.NORMAL + initial_style = Style.FIRST_LETTER + + out: list[str] = [] + for i, ch in enumerate(prompt): + if i not in selected: + out.append(ch) + continue + + if self._mode == "full": + style = full_style + elif self._mode == "initial": + style = initial_style + else: # mixed: choose per character + style = rng.choice((full_style, initial_style)) + + reading = self._to_pinyin(ch, style=style, pypinyin=pypinyin) + out.append(reading) + # Only add a separator when the character was actually romanized. + if self._separator and reading != ch: + out.append(self._separator) + + # A separator is appended after each romanized syllable; drop the trailing one. + if self._separator and out and out[-1] == self._separator: + out.pop() + + return ConverterResult(output_text="".join(out), output_type="text") diff --git a/tests/unit/converter/test_pinyin_converter.py b/tests/unit/converter/test_pinyin_converter.py new file mode 100644 index 0000000000..cb9ed85708 --- /dev/null +++ b/tests/unit/converter/test_pinyin_converter.py @@ -0,0 +1,130 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import pytest + +from pyrit.converter import ConverterResult, PinyinConverter + + +def is_pypinyin_installed(): + try: + import pypinyin # noqa: F401 + + return True + except ModuleNotFoundError: + return False + + +# Conversion needs the optional 'pinyin' extra; the constructor/validation tests below do not. +requires_pypinyin = pytest.mark.skipif(not is_pypinyin_installed(), reason="pypinyin is not installed") + + +@requires_pypinyin +async def test_pinyin_full_mode_romanizes_every_hanzi(): + converter = PinyinConverter(mode="full") + result = await converter.convert_async(prompt="中心", input_type="text") + assert isinstance(result, ConverterResult) + assert result.output_text == "zhongxin" + assert result.output_type == "text" + + +@requires_pypinyin +async def test_pinyin_full_mode_with_separator_keeps_syllables_readable(): + converter = PinyinConverter(mode="full", separator=" ") + result = await converter.convert_async(prompt="中心", input_type="text") + # A separator is inserted between syllables but not left trailing. + assert result.output_text == "zhong xin" + + +@requires_pypinyin +async def test_pinyin_initial_mode_uses_first_letters(): + converter = PinyinConverter(mode="initial") + result = await converter.convert_async(prompt="中心", input_type="text") + assert result.output_text == "zx" + + +@requires_pypinyin +async def test_pinyin_leaves_non_hanzi_untouched(): + converter = PinyinConverter(mode="full") + result = await converter.convert_async(prompt="你好world! 123", input_type="text") + # Latin letters, punctuation, spaces, and digits pass through unchanged. + assert result.output_text == "nihaoworld! 123" + + +@requires_pypinyin +async def test_pinyin_separator_only_wraps_romanized_characters(): + converter = PinyinConverter(mode="full", separator="-") + result = await converter.convert_async(prompt="中a好", input_type="text") + # The non-Hanzi "a" gets no separator; the trailing separator after 好 is dropped. + assert result.output_text == "zhong-ahao" + + +@requires_pypinyin +async def test_pinyin_prompt_without_hanzi_is_identity(): + converter = PinyinConverter(mode="full") + prompt = "the quick brown fox" + result = await converter.convert_async(prompt=prompt, input_type="text") + assert result.output_text == prompt + + +@requires_pypinyin +async def test_pinyin_zero_proportion_is_identity(): + converter = PinyinConverter(mode="full", proportion=0.0) + result = await converter.convert_async(prompt="你好世界", input_type="text") + assert result.output_text == "你好世界" + + +@requires_pypinyin +async def test_pinyin_partial_proportion_converts_expected_count(): + # 4 Hanzi at proportion 0.5 -> exactly 2 romanized, 2 kept as Hanzi. + converter = PinyinConverter(mode="full", proportion=0.5, seed=42) + result = await converter.convert_async(prompt="你好世界", input_type="text") + output = result.output_text + remaining_hanzi = [c for c in output if c in "你好世界"] + assert len(remaining_hanzi) == 2 + + +@requires_pypinyin +async def test_pinyin_seed_makes_partial_selection_reproducible(): + prompt = "今天天气很好我们出去玩" + first = (await PinyinConverter(mode="full", proportion=0.5, seed=7).convert_async(prompt=prompt)).output_text + second = (await PinyinConverter(mode="full", proportion=0.5, seed=7).convert_async(prompt=prompt)).output_text + assert first == second + + +@requires_pypinyin +async def test_pinyin_mixed_mode_is_reproducible_with_seed(): + prompt = "今天天气很好" + first = (await PinyinConverter(mode="mixed", seed=13).convert_async(prompt=prompt)).output_text + second = (await PinyinConverter(mode="mixed", seed=13).convert_async(prompt=prompt)).output_text + assert first == second + # Mixed mode still romanizes everything at proportion 1.0, so no Hanzi remains. + assert not any("一" <= c <= "鿿" for c in first) + + +async def test_pinyin_rejects_unsupported_input_type(): + # The input-type guard runs before pypinyin is imported, so this needs no extra. + converter = PinyinConverter() + with pytest.raises(ValueError, match="Input type not supported"): + await converter.convert_async(prompt="你好", input_type="image_path") + + +def test_pinyin_rejects_invalid_mode(): + with pytest.raises(ValueError, match="mode must be one of"): + PinyinConverter(mode="tone") # type: ignore[arg-type] + + +@pytest.mark.parametrize("bad", [-0.1, 1.5]) +def test_pinyin_rejects_out_of_range_proportion(bad): + with pytest.raises(ValueError, match="proportion must be between 0.0 and 1.0"): + PinyinConverter(proportion=bad) + + +def test_pinyin_identifier_includes_parameters(): + converter = PinyinConverter(mode="initial", proportion=0.25, separator=" ", seed=99) + identifier = converter.get_identifier() + assert identifier.class_name == "PinyinConverter" + assert identifier.params["mode"] == "initial" + assert identifier.params["proportion"] == 0.25 + assert identifier.params["separator"] == " " + assert identifier.params["seed"] == 99 From dfd29dc0fed7af9021a40b2fcd525123f6dcd84a Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Mon, 14 Sep 2026 13:57:51 -0700 Subject: [PATCH 2/7] MAINT: Make pypinyin a core dependency Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pyproject.toml | 6 +---- pyrit/converter/pinyin_converter.py | 27 ++----------------- .../unit/common/test_lazy_package_imports.py | 6 +++++ tests/unit/converter/test_pinyin_converter.py | 24 ----------------- uv.lock | 11 ++++++++ 5 files changed, 20 insertions(+), 54 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index de0bdea77a..e692806bc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dependencies = [ "PyJWT[crypto]>=2.8.0", "pyodbc>=5.1.0", "pypdf>=6.10.2", + "pypinyin>=0.53.0", "python-docx>=1.1.0", "python-dotenv>=1.2.2", "reportlab>=4.4.4", @@ -133,10 +134,6 @@ litellm = [ "litellm>=1.84.0", ] -pinyin = [ - "pypinyin>=0.53.0", -] - # all includes all functional dependencies excluding the ones from the "dev" dependency group all = [ "accelerate>=1.7.0", @@ -150,7 +147,6 @@ all = [ "opencv-python>=4.11.0.86", "playwright>=1.49.0", "pyarrow>=22.0.0; python_version >= '3.14'", - "pypinyin>=0.53.0", "sentencepiece>=0.2.0", "spacy>=3.8.13,!=3.8.14,!=3.8.15", # 3.8.14-3.8.15 missing cp314 wheels "torch>=2.7.0", diff --git a/pyrit/converter/pinyin_converter.py b/pyrit/converter/pinyin_converter.py index 1c49204def..d5fce59245 100644 --- a/pyrit/converter/pinyin_converter.py +++ b/pyrit/converter/pinyin_converter.py @@ -1,15 +1,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -import logging import re from typing import Any, Literal from pyrit.converter.converter import Converter, ConverterResult from pyrit.models import ComponentIdentifier, PromptDataType -logger = logging.getLogger(__name__) - PinyinMode = Literal["full", "initial", "mixed"] # Han (Chinese) character ranges. pypinyin only has readings for these, so @@ -50,7 +47,7 @@ class PinyinConverter(Converter): the rest as Hanzi, producing mixed Hanzi/Pinyin text. Characters that are not Hanzi are always left unchanged. Pass ``seed`` for reproducible selection. - This converter requires the optional ``pinyin`` dependency: ``pip install pyrit[pinyin]``. + Pinyin dictionaries are loaded lazily when converting prompts. """ SUPPORTED_INPUT_TYPES = ("text",) @@ -111,26 +108,6 @@ def _build_identifier(self) -> ComponentIdentifier: } ) - @staticmethod - def _import_pypinyin() -> Any: - """ - Import the optional ``pypinyin`` dependency, raising a helpful error if it is missing. - - Returns: - Any: The imported ``pypinyin`` module. - - Raises: - ModuleNotFoundError: If ``pypinyin`` is not installed. - """ - try: - import pypinyin - except ModuleNotFoundError as exc: - logger.error("Could not import pypinyin. You may need to install it via 'pip install pyrit[pinyin]'") - raise ModuleNotFoundError( - "PinyinConverter requires the 'pypinyin' package. Install it via 'pip install pyrit[pinyin]'." - ) from exc - return pypinyin - def _to_pinyin(self, char: str, *, style: Any, pypinyin: Any) -> str: """ Return the Pinyin reading of a single Hanzi for the given ``pypinyin`` style. @@ -166,7 +143,7 @@ async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text if not self.input_supported(input_type): raise ValueError("Input type not supported") - pypinyin = self._import_pypinyin() + import pypinyin from pypinyin import Style han_indices = [i for i, ch in enumerate(prompt) if _HAN_PATTERN.match(ch)] diff --git a/tests/unit/common/test_lazy_package_imports.py b/tests/unit/common/test_lazy_package_imports.py index 44adcee5a8..6c3706e44f 100644 --- a/tests/unit/common/test_lazy_package_imports.py +++ b/tests/unit/common/test_lazy_package_imports.py @@ -76,6 +76,12 @@ "pyrit.converter.base64_converter", "pyrit.converter.audio_echo_converter", ), + ( + "pyrit.converter", + "PinyinConverter", + "pyrit.converter.pinyin_converter", + "pypinyin", + ), ( "pyrit.datasets", "SeedDatasetProvider", diff --git a/tests/unit/converter/test_pinyin_converter.py b/tests/unit/converter/test_pinyin_converter.py index cb9ed85708..5c708a3f81 100644 --- a/tests/unit/converter/test_pinyin_converter.py +++ b/tests/unit/converter/test_pinyin_converter.py @@ -6,20 +6,6 @@ from pyrit.converter import ConverterResult, PinyinConverter -def is_pypinyin_installed(): - try: - import pypinyin # noqa: F401 - - return True - except ModuleNotFoundError: - return False - - -# Conversion needs the optional 'pinyin' extra; the constructor/validation tests below do not. -requires_pypinyin = pytest.mark.skipif(not is_pypinyin_installed(), reason="pypinyin is not installed") - - -@requires_pypinyin async def test_pinyin_full_mode_romanizes_every_hanzi(): converter = PinyinConverter(mode="full") result = await converter.convert_async(prompt="中心", input_type="text") @@ -28,7 +14,6 @@ async def test_pinyin_full_mode_romanizes_every_hanzi(): assert result.output_type == "text" -@requires_pypinyin async def test_pinyin_full_mode_with_separator_keeps_syllables_readable(): converter = PinyinConverter(mode="full", separator=" ") result = await converter.convert_async(prompt="中心", input_type="text") @@ -36,14 +21,12 @@ async def test_pinyin_full_mode_with_separator_keeps_syllables_readable(): assert result.output_text == "zhong xin" -@requires_pypinyin async def test_pinyin_initial_mode_uses_first_letters(): converter = PinyinConverter(mode="initial") result = await converter.convert_async(prompt="中心", input_type="text") assert result.output_text == "zx" -@requires_pypinyin async def test_pinyin_leaves_non_hanzi_untouched(): converter = PinyinConverter(mode="full") result = await converter.convert_async(prompt="你好world! 123", input_type="text") @@ -51,7 +34,6 @@ async def test_pinyin_leaves_non_hanzi_untouched(): assert result.output_text == "nihaoworld! 123" -@requires_pypinyin async def test_pinyin_separator_only_wraps_romanized_characters(): converter = PinyinConverter(mode="full", separator="-") result = await converter.convert_async(prompt="中a好", input_type="text") @@ -59,7 +41,6 @@ async def test_pinyin_separator_only_wraps_romanized_characters(): assert result.output_text == "zhong-ahao" -@requires_pypinyin async def test_pinyin_prompt_without_hanzi_is_identity(): converter = PinyinConverter(mode="full") prompt = "the quick brown fox" @@ -67,14 +48,12 @@ async def test_pinyin_prompt_without_hanzi_is_identity(): assert result.output_text == prompt -@requires_pypinyin async def test_pinyin_zero_proportion_is_identity(): converter = PinyinConverter(mode="full", proportion=0.0) result = await converter.convert_async(prompt="你好世界", input_type="text") assert result.output_text == "你好世界" -@requires_pypinyin async def test_pinyin_partial_proportion_converts_expected_count(): # 4 Hanzi at proportion 0.5 -> exactly 2 romanized, 2 kept as Hanzi. converter = PinyinConverter(mode="full", proportion=0.5, seed=42) @@ -84,7 +63,6 @@ async def test_pinyin_partial_proportion_converts_expected_count(): assert len(remaining_hanzi) == 2 -@requires_pypinyin async def test_pinyin_seed_makes_partial_selection_reproducible(): prompt = "今天天气很好我们出去玩" first = (await PinyinConverter(mode="full", proportion=0.5, seed=7).convert_async(prompt=prompt)).output_text @@ -92,7 +70,6 @@ async def test_pinyin_seed_makes_partial_selection_reproducible(): assert first == second -@requires_pypinyin async def test_pinyin_mixed_mode_is_reproducible_with_seed(): prompt = "今天天气很好" first = (await PinyinConverter(mode="mixed", seed=13).convert_async(prompt=prompt)).output_text @@ -103,7 +80,6 @@ async def test_pinyin_mixed_mode_is_reproducible_with_seed(): async def test_pinyin_rejects_unsupported_input_type(): - # The input-type guard runs before pypinyin is imported, so this needs no extra. converter = PinyinConverter() with pytest.raises(ValueError, match="Input type not supported"): await converter.convert_async(prompt="你好", input_type="image_path") diff --git a/uv.lock b/uv.lock index e781166b28..5d13bb4e19 100644 --- a/uv.lock +++ b/uv.lock @@ -4735,6 +4735,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/08/1e9731038124a9127e1d27848952b86fb32b2f45f8f1b94adc7f0817a6ac/pypdf-6.17.0-py3-none-any.whl", hash = "sha256:5bd827266a21553b74d910e350131a6227b72f2ab4209bf372814b8195fa11c5", size = 388051, upload-time = "2026-09-04T11:30:42.681Z" }, ] +[[package]] +name = "pypinyin" +version = "0.55.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/a4/784cf98c09e0dc22776b0d7d8a4a5b761218bcae4608c2416ce1e167c8af/pypinyin-0.55.0.tar.gz", hash = "sha256:b5711b3a0c6f76e67408ec6b2e3c4987a3a806b7c528076e7c7b86fcf0eaa66b", size = 839836, upload-time = "2025-07-20T12:01:50.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/7b/4cabc76fcc21c3c7d5c671d8783984d30ac9d3bb387c4ba784fca3cdfa3a/pypinyin-0.55.0-py2.py3-none-any.whl", hash = "sha256:d53b1e8ad2cdb815fb2cb604ed3123372f5a28c6f447571244aca36fc62a286f", size = 840203, upload-time = "2025-07-20T12:01:48.535Z" }, +] + [[package]] name = "pyrit" version = "1.2.0.dev0" @@ -4768,6 +4777,7 @@ dependencies = [ { name = "pyjwt", extra = ["crypto"] }, { name = "pyodbc" }, { name = "pypdf" }, + { name = "pypinyin" }, { name = "python-docx" }, { name = "python-dotenv" }, { name = "reportlab" }, @@ -4907,6 +4917,7 @@ requires-dist = [ { name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0" }, { name = "pyodbc", specifier = ">=5.1.0" }, { name = "pypdf", specifier = ">=6.10.2" }, + { name = "pypinyin", specifier = ">=0.53.0" }, { name = "python-docx", specifier = ">=1.1.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "reportlab", specifier = ">=4.4.4" }, From 20e194d603c26380c96bfe601210735c365fc2a9 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Mon, 14 Sep 2026 14:30:23 -0700 Subject: [PATCH 3/7] FIX: Preserve Pinyin phrase context and trailing input Resolve readings against the complete prompt before selecting replacements. Preserve original trailing characters when inserting separators and add regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/code/converters/0_converters.ipynb | 105 ++++++++++-------- doc/code/converters/0_converters.py | 3 + pyrit/converter/pinyin_converter.py | 67 +++++------ tests/unit/converter/test_pinyin_converter.py | 66 +++++++++++ 4 files changed, 153 insertions(+), 88 deletions(-) diff --git a/doc/code/converters/0_converters.ipynb b/doc/code/converters/0_converters.ipynb index 7fa11f1d9e..fae314daa4 100644 --- a/doc/code/converters/0_converters.ipynb +++ b/doc/code/converters/0_converters.ipynb @@ -27,6 +27,9 @@ "\n", "## Converter Modality Reference Table\n", "\n", + "`PinyinConverter` converts Chinese text to full Pinyin, initials, or a mixture of Hanzi and Pinyin.\n", + "It uses phrase context for pronunciation and preserves non-Hanzi text and trailing whitespace.\n", + "\n", "The following table shows all available converters organized by their input and output modalities:" ] }, @@ -98,55 +101,59 @@ "40 text text DecompositionConverter\n", "41 text text DenylistConverter\n", "42 text text DiacriticConverter\n", - "43 text text EcojiConverter\n", - "44 text text EmojiConverter\n", - "45 text text FirstLetterConverter\n", - "46 text text FlipConverter\n", - "47 text text IPAConverter\n", - "48 text text ImagePromptStyleConverter\n", - "49 text text InsertPunctuationConverter\n", - "50 text text JsonStringConverter\n", - "51 text text LLMGenericTextConverter\n", - "52 text text LeetspeakConverter\n", - "53 text text MaliciousQuestionGeneratorConverter\n", - "54 text text MathObfuscationConverter\n", - "55 text text MathPromptConverter\n", - "56 text text MorseConverter\n", - "57 text text NatoConverter\n", - "58 text text NegationTrapConverter\n", - "59 text text NoiseConverter\n", - "60 text text PersuasionConverter\n", - "61 text text PolicyPuppetryConverter\n", - "62 text text PuzzledConverter\n", - "63 text text ROT13Converter\n", - "64 text text RandomCapitalLettersConverter\n", - "65 text text RandomTranslationConverter\n", - "66 text text RepeatTokenConverter\n", - "67 text text SATAMaskingConverter\n", - "68 text text ScientificTranslationConverter\n", - "69 text text SearchReplaceConverter\n", - "70 text text SelectiveTextConverter\n", - "71 text text SneakyBitsSmugglerConverter\n", - "72 text text StringJoinConverter\n", - "73 text text SuffixAppendConverter\n", - "74 text text SuperscriptConverter\n", - "75 text text TaskFramingConverter\n", - "76 text text TatweelConverter\n", - "77 text text TemplateSegmentConverter\n", - "78 text text TenseConverter\n", - "79 text text TextJailbreakConverter\n", - "80 text text ToneConverter\n", - "81 text text ToxicSentenceGeneratorConverter\n", - "82 text text TranslationConverter\n", - "83 text text UnicodeConfusableConverter\n", - "84 text text UnicodeReplacementConverter\n", - "85 text text UnicodeSubstitutionConverter\n", - "86 text text UrlConverter\n", - "87 text text VariationConverter\n", - "88 text text VariationSelectorSmugglerConverter\n", - "89 text text VigenereConverter\n", - "90 text text ZalgoConverter\n", - "91 text text ZeroWidthConverter\n" + "43 text text DigitBijectionConverter\n", + "44 text text EcojiConverter\n", + "45 text text EmojiConverter\n", + "46 text text FirstLetterConverter\n", + "47 text text FlipConverter\n", + "48 text text IPAConverter\n", + "49 text text ImagePromptStyleConverter\n", + "50 text text InsertPunctuationConverter\n", + "51 text text JsonStringConverter\n", + "52 text text LLMGenericTextConverter\n", + "53 text text LeetspeakConverter\n", + "54 text text LetterBijectionConverter\n", + "55 text text MaliciousQuestionGeneratorConverter\n", + "56 text text MathObfuscationConverter\n", + "57 text text MathPromptConverter\n", + "58 text text MorseConverter\n", + "59 text text NatoConverter\n", + "60 text text NegationTrapConverter\n", + "61 text text NoiseConverter\n", + "62 text text PersuasionConverter\n", + "63 text text PinyinConverter\n", + "64 text text PolicyPuppetryConverter\n", + "65 text text PuzzledConverter\n", + "66 text text ROT13Converter\n", + "67 text text RandomCapitalLettersConverter\n", + "68 text text RandomTranslationConverter\n", + "69 text text RepeatTokenConverter\n", + "70 text text SATAMaskingConverter\n", + "71 text text ScientificTranslationConverter\n", + "72 text text SearchReplaceConverter\n", + "73 text text SelectiveTextConverter\n", + "74 text text SneakyBitsSmugglerConverter\n", + "75 text text StringJoinConverter\n", + "76 text text SuffixAppendConverter\n", + "77 text text SuperscriptConverter\n", + "78 text text TaskFramingConverter\n", + "79 text text TatweelConverter\n", + "80 text text TemplateSegmentConverter\n", + "81 text text TenseConverter\n", + "82 text text TextJailbreakConverter\n", + "83 text text TokenBijectionConverter\n", + "84 text text ToneConverter\n", + "85 text text ToxicSentenceGeneratorConverter\n", + "86 text text TranslationConverter\n", + "87 text text UnicodeConfusableConverter\n", + "88 text text UnicodeReplacementConverter\n", + "89 text text UnicodeSubstitutionConverter\n", + "90 text text UrlConverter\n", + "91 text text VariationConverter\n", + "92 text text VariationSelectorSmugglerConverter\n", + "93 text text VigenereConverter\n", + "94 text text ZalgoConverter\n", + "95 text text ZeroWidthConverter\n" ] } ], diff --git a/doc/code/converters/0_converters.py b/doc/code/converters/0_converters.py index a7e3a889a2..12191725c1 100644 --- a/doc/code/converters/0_converters.py +++ b/doc/code/converters/0_converters.py @@ -22,6 +22,9 @@ # # ## Converter Modality Reference Table # +# `PinyinConverter` converts Chinese text to full Pinyin, initials, or a mixture of Hanzi and Pinyin. +# It uses phrase context for pronunciation and preserves non-Hanzi text and trailing whitespace. +# # The following table shows all available converters organized by their input and output modalities: # %% import pandas as pd diff --git a/pyrit/converter/pinyin_converter.py b/pyrit/converter/pinyin_converter.py index d5fce59245..d69a8f681f 100644 --- a/pyrit/converter/pinyin_converter.py +++ b/pyrit/converter/pinyin_converter.py @@ -2,16 +2,15 @@ # Licensed under the MIT license. import re -from typing import Any, Literal +from typing import Literal from pyrit.converter.converter import Converter, ConverterResult from pyrit.models import ComponentIdentifier, PromptDataType PinyinMode = Literal["full", "initial", "mixed"] -# Han (Chinese) character ranges. pypinyin only has readings for these, so -# everything else (Latin, digits, punctuation, whitespace, emoji, ...) is passed -# through untouched. +# Han (Chinese) character ranges eligible for conversion. Other characters +# (Latin, digits, punctuation, whitespace, emoji, ...) are passed through untouched. _HAN_PATTERN = re.compile( "[" "㐀-䶿" # CJK Unified Ideographs Extension A @@ -34,7 +33,8 @@ class PinyinConverter(Converter): match on Hanzi rather than on romanized readings. The pattern is described in recent work on Chinese LLM safety such as CSSBench. - The converter is deterministic (no LLM call) and operates character by character: + The converter uses no LLM call. It resolves readings using phrase context before + replacing individual characters: - ``full``: each selected Hanzi becomes its full Pinyin reading without tone marks (e.g. ``中`` -> ``zhong``). @@ -72,9 +72,11 @@ def __init__( The selected count is ``round(proportion * number_of_hanzi)`` and the positions are chosen at random (seedable). ``1.0`` converts every Hanzi. Defaults to ``1.0``. - separator (str): String inserted after each converted syllable. Full Pinyin spans - run together by default (``中心`` -> ``zhongxin``); pass ``separator=" "`` to - keep syllable boundaries readable (``zhong xin``). Defaults to ``""``. + separator (str): String inserted after each converted syllable, except at the end + of the prompt. Original characters, including trailing whitespace, are preserved. + Full Pinyin spans run together by default (``中心`` -> ``zhongxin``); pass + ``separator=" "`` to keep syllable boundaries readable (``zhong xin``). + Defaults to ``""``. seed (int | None): Optional seed for reproducible selection and, in ``"mixed"`` mode, reproducible per-character rendering. Defaults to None. @@ -108,23 +110,20 @@ def _build_identifier(self) -> ComponentIdentifier: } ) - def _to_pinyin(self, char: str, *, style: Any, pypinyin: Any) -> str: + def _get_pinyin_readings(self, prompt: str) -> list[str]: """ - Return the Pinyin reading of a single Hanzi for the given ``pypinyin`` style. + Resolve phrase-aware Pinyin readings aligned with the original characters. Args: - char (str): A single Hanzi character to romanize. - style (Any): A ``pypinyin.Style`` member controlling the reading format. - pypinyin (Any): The imported ``pypinyin`` module. + prompt (str): The complete prompt used as pronunciation context. Returns: - str: The Pinyin reading, or the original character when no reading is available. + list[str]: One reading per character, preserving characters without a reading. """ - result = pypinyin.lazy_pinyin(char, style=style) - if not result: - return char - reading = str(result[0]) - return reading or char + from pypinyin import Style, lazy_pinyin + + # List-returning callbacks preserve alignment but are missing from pypinyin's released type stub. + return lazy_pinyin(prompt, style=Style.NORMAL, errors=list) # type: ignore[ty:invalid-argument-type] async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text") -> ConverterResult: """ @@ -143,9 +142,6 @@ async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text if not self.input_supported(input_type): raise ValueError("Input type not supported") - import pypinyin - from pypinyin import Style - han_indices = [i for i, ch in enumerate(prompt) if _HAN_PATTERN.match(ch)] if not han_indices: return ConverterResult(output_text=prompt, output_type="text") @@ -153,31 +149,24 @@ async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text rng = self._get_random_generator(stream="pinyin-selection") count = round(self._proportion * len(han_indices)) selected = set(rng.sample(han_indices, count)) if count else set() + if not selected: + return ConverterResult(output_text=prompt, output_type="text") - full_style = Style.NORMAL - initial_style = Style.FIRST_LETTER - + readings = self._get_pinyin_readings(prompt) out: list[str] = [] - for i, ch in enumerate(prompt): + for i, (ch, reading) in enumerate(zip(prompt, readings, strict=True)): if i not in selected: out.append(ch) continue - if self._mode == "full": - style = full_style - elif self._mode == "initial": - style = initial_style - else: # mixed: choose per character - style = rng.choice((full_style, initial_style)) + reading = reading or ch + if self._mode == "initial": + reading = reading[0] + elif self._mode == "mixed": + reading = rng.choice((reading, reading[0])) - reading = self._to_pinyin(ch, style=style, pypinyin=pypinyin) out.append(reading) - # Only add a separator when the character was actually romanized. - if self._separator and reading != ch: + if self._separator and reading != ch and i < len(prompt) - 1: out.append(self._separator) - # A separator is appended after each romanized syllable; drop the trailing one. - if self._separator and out and out[-1] == self._separator: - out.pop() - return ConverterResult(output_text="".join(out), output_type="text") diff --git a/tests/unit/converter/test_pinyin_converter.py b/tests/unit/converter/test_pinyin_converter.py index 5c708a3f81..d5b8cb644c 100644 --- a/tests/unit/converter/test_pinyin_converter.py +++ b/tests/unit/converter/test_pinyin_converter.py @@ -1,9 +1,13 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +from random import Random +from unittest.mock import MagicMock, patch + import pytest from pyrit.converter import ConverterResult, PinyinConverter +from pyrit.converter.pinyin_converter import PinyinMode async def test_pinyin_full_mode_romanizes_every_hanzi(): @@ -104,3 +108,65 @@ def test_pinyin_identifier_includes_parameters(): assert identifier.params["proportion"] == 0.25 assert identifier.params["separator"] == " " assert identifier.params["seed"] == 99 + + +@pytest.mark.parametrize( + ("mode", "prompt", "expected"), + [ + ("full", "银行", "yinhang"), + ("initial", "银行", "yh"), + ("full", "重庆", "chongqing"), + ("initial", "重庆", "cq"), + ("full", "音乐", "yinyue"), + ("initial", "音乐", "yy"), + ("full", "abc 银行! 123\n重庆 \U0001f600", "abc yinhang! 123\nchongqing \U0001f600"), + ("initial", "abc 银行! 123\n重庆 \U0001f600", "abc yh! 123\ncq \U0001f600"), + ], +) +async def test_pinyin_preserves_phrase_context_async(*, mode: PinyinMode, prompt: str, expected: str) -> None: + result = await PinyinConverter(mode=mode).convert_async(prompt=prompt) + + assert result.output_text == expected + + +@pytest.mark.parametrize( + ("mode", "choice_index", "expected"), + [ + ("full", 0, "银hang"), + ("initial", 0, "银h"), + ("mixed", 0, "银hang"), + ("mixed", 1, "银h"), + ], +) +async def test_pinyin_partial_phrase_uses_unselected_context_async( + *, mode: PinyinMode, choice_index: int, expected: str +) -> None: + converter = PinyinConverter(mode=mode, proportion=0.5) + rng = MagicMock(spec=Random) + rng.sample.return_value = [1] + rng.choice.side_effect = lambda choices: choices[choice_index] + + with patch.object(converter, "_get_random_generator", return_value=rng): + result = await converter.convert_async(prompt="银行") + + assert result.output_text == expected + + +@pytest.mark.parametrize("proportion", [0.0, 0.1]) +@pytest.mark.parametrize("separator", [" ", "\n", "!", "好"]) +async def test_pinyin_preserves_original_trailing_characters_async(*, proportion: float, separator: str) -> None: + prompt = f"你好{separator}" + converter = PinyinConverter(proportion=proportion, separator=separator) + + result = await converter.convert_async(prompt=prompt) + + assert result.output_text == prompt + + +@pytest.mark.parametrize("separator", [" ", "\n", "!", "a"]) +async def test_pinyin_preserves_original_separator_after_conversion_async(separator: str) -> None: + converter = PinyinConverter(separator=separator) + + result = await converter.convert_async(prompt=f"中{separator}") + + assert result.output_text == f"zhong{separator}{separator}" From d5ab4078a8e0da90fa1ed4efc4b9f8377db012fd Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Mon, 14 Sep 2026 15:00:23 -0700 Subject: [PATCH 4/7] DOC: Keep converter overview generic Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/code/converters/0_converters.ipynb | 3 --- doc/code/converters/0_converters.py | 3 --- 2 files changed, 6 deletions(-) diff --git a/doc/code/converters/0_converters.ipynb b/doc/code/converters/0_converters.ipynb index fae314daa4..932d89c851 100644 --- a/doc/code/converters/0_converters.ipynb +++ b/doc/code/converters/0_converters.ipynb @@ -27,9 +27,6 @@ "\n", "## Converter Modality Reference Table\n", "\n", - "`PinyinConverter` converts Chinese text to full Pinyin, initials, or a mixture of Hanzi and Pinyin.\n", - "It uses phrase context for pronunciation and preserves non-Hanzi text and trailing whitespace.\n", - "\n", "The following table shows all available converters organized by their input and output modalities:" ] }, diff --git a/doc/code/converters/0_converters.py b/doc/code/converters/0_converters.py index 12191725c1..a7e3a889a2 100644 --- a/doc/code/converters/0_converters.py +++ b/doc/code/converters/0_converters.py @@ -22,9 +22,6 @@ # # ## Converter Modality Reference Table # -# `PinyinConverter` converts Chinese text to full Pinyin, initials, or a mixture of Hanzi and Pinyin. -# It uses phrase context for pronunciation and preserves non-Hanzi text and trailing whitespace. -# # The following table shows all available converters organized by their input and output modalities: # %% import pandas as pd From 7cdd0c09df6ed7782c2cf51cd9c861ddad3c2027 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Mon, 14 Sep 2026 15:18:53 -0700 Subject: [PATCH 5/7] FIX: Cover ideographic zero and extended Hanzi Include Unicode 17 CJK extensions G-J in Hanzi selection and preserve characters without dictionary readings. Cover romanization, block boundaries, and partial-selection counts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pyrit/converter/pinyin_converter.py | 7 ++- tests/unit/converter/test_pinyin_converter.py | 57 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/pyrit/converter/pinyin_converter.py b/pyrit/converter/pinyin_converter.py index d69a8f681f..abfc509677 100644 --- a/pyrit/converter/pinyin_converter.py +++ b/pyrit/converter/pinyin_converter.py @@ -13,12 +13,14 @@ # (Latin, digits, punctuation, whitespace, emoji, ...) are passed through untouched. _HAN_PATTERN = re.compile( "[" + "\u3007" # Ideographic number zero "㐀-䶿" # CJK Unified Ideographs Extension A "一-鿿" # CJK Unified Ideographs "豈-﫿" # CJK Compatibility Ideographs "\U00020000-\U0002a6df" # CJK Unified Ideographs Extension B - "\U0002a700-\U0002ebef" # CJK Unified Ideographs Extensions C-F + "\U0002a700-\U0002ee5f" # CJK Unified Ideographs Extensions C-F and I "\U0002f800-\U0002fa1f" # CJK Compatibility Ideographs Supplement + "\U00030000-\U0003347f" # CJK Unified Ideographs Extensions G, H, and J "]" ) @@ -45,7 +47,8 @@ class PinyinConverter(Converter): ``proportion`` controls how many of the Hanzi are converted; a value below ``1.0`` leaves the rest as Hanzi, producing mixed Hanzi/Pinyin text. Characters that are not Hanzi are - always left unchanged. Pass ``seed`` for reproducible selection. + always left unchanged, as are Hanzi without a dictionary reading. Pass ``seed`` for + reproducible selection. Pinyin dictionaries are loaded lazily when converting prompts. """ diff --git a/tests/unit/converter/test_pinyin_converter.py b/tests/unit/converter/test_pinyin_converter.py index d5b8cb644c..afafa6dd59 100644 --- a/tests/unit/converter/test_pinyin_converter.py +++ b/tests/unit/converter/test_pinyin_converter.py @@ -170,3 +170,60 @@ async def test_pinyin_preserves_original_separator_after_conversion_async(separa result = await converter.convert_async(prompt=f"中{separator}") assert result.output_text == f"zhong{separator}{separator}" + + +@pytest.mark.parametrize( + ("mode", "prompt", "expected"), + [ + ("full", "〇", "ling"), + ("initial", "〇", "l"), + ("full", "二〇二六", "erlingerliu"), + ("full", "\U00030021", "qian"), + ("initial", "\U00030021", "q"), + ("full", "\U00031350", "qi"), + ("initial", "\U00031350", "q"), + ], +) +async def test_pinyin_romanizes_extended_hanzi_async(*, mode: PinyinMode, prompt: str, expected: str) -> None: + result = await PinyinConverter(mode=mode).convert_async(prompt=prompt) + + assert result.output_text == expected + + +@pytest.mark.parametrize( + "character", + [ + "〇", + "\U0002ebf0", + "\U0002ee5f", + "\U00030000", + "\U0003134f", + "\U00031350", + "\U000323af", + "\U000323b0", + "\U0003347f", + ], +) +async def test_pinyin_partial_count_includes_extended_hanzi_async(character: str) -> None: + converter = PinyinConverter(proportion=0.5) + rng = MagicMock(spec=Random) + rng.sample.return_value = [1] + + with patch.object(converter, "_get_random_generator", return_value=rng): + result = await converter.convert_async(prompt=f"{character}中") + + rng.sample.assert_called_once() + assert rng.sample.call_args.args == ([0, 1], 1) + assert result.output_text == f"{character}zhong" + + +@pytest.mark.parametrize("mode", ["full", "initial", "mixed"]) +async def test_pinyin_preserves_extended_hanzi_without_readings_async(mode: PinyinMode) -> None: + prompt = "\U0002ebf0\U000323b0" + converter = PinyinConverter(mode=mode, separator="-") + + with patch.object(converter, "_get_pinyin_readings", return_value=list(prompt)) as readings: + result = await converter.convert_async(prompt=prompt) + + readings.assert_called_once() + assert result.output_text == prompt From d9a52de70dcc9cd3650036954c86ad066b2995b7 Mon Sep 17 00:00:00 2001 From: Nanduu24 Date: Mon, 14 Sep 2026 17:50:30 -0500 Subject: [PATCH 6/7] DOC: Document PinyinConverter in the text-to-text converter notebook Adds PinyinConverter to doc/code/converters/1_text_to_text_converters.py with a Chinese-prompt example (full readings and first-letter initials), mirroring how the script-specific Arabic converters are documented. Satisfies test_all_converters_are_documented, which requires every converter to appear in a converter notebook. --- doc/code/converters/1_text_to_text_converters.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/code/converters/1_text_to_text_converters.py b/doc/code/converters/1_text_to_text_converters.py index 2fb59a0896..3cacb316ef 100644 --- a/doc/code/converters/1_text_to_text_converters.py +++ b/doc/code/converters/1_text_to_text_converters.py @@ -124,6 +124,7 @@ InsertPunctuationConverter, LeetspeakConverter, MathObfuscationConverter, + PinyinConverter, PuzzledConverter, RandomCapitalLettersConverter, RepeatTokenConverter, @@ -173,6 +174,11 @@ print("Arabic Presentation Form:", await ArabicPresentationFormConverter().convert_async(prompt=arabic_prompt)) # type: ignore # Arabizi transliterates Arabic script into Latin-script chat Arabic print("Arabizi:", await ArabiziConverter().convert_async(prompt=arabic_prompt)) # type: ignore +# Pinyin only affects Chinese (Hanzi) characters, so it uses a Chinese prompt. It rewrites Hanzi +# as their Pinyin romanization - as full readings, first-letter initials, or a Hanzi/Pinyin mix. +chinese_prompt = "告诉我如何砍倒一棵树" +print("Pinyin (full):", await PinyinConverter().convert_async(prompt=chinese_prompt)) # type: ignore +print("Pinyin (initials):", await PinyinConverter(mode="initial").convert_async(prompt=chinese_prompt)) # type: ignore print("Superscript:", await SuperscriptConverter().convert_async(prompt=prompt)) # type: ignore print("Zalgo:", await ZalgoConverter().convert_async(prompt=prompt)) # type: ignore From 643030c46ce83466eea504d352b90f312bfde753 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Tue, 15 Sep 2026 10:11:34 -0700 Subject: [PATCH 7/7] FIX: Address PinyinConverter rereview findings Offload dictionary lookup, require pypinyin 0.55.0, synchronize the notebook examples and outputs, and extend async and seeded-converter regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../1_text_to_text_converters.ipynb | 8 ++++ pyproject.toml | 2 +- pyrit/converter/pinyin_converter.py | 5 ++- tests/unit/converter/test_pinyin_converter.py | 38 ++++++++++++++++++ .../test_seeded_converter_determinism.py | 40 +++++++++++++++++++ uv.lock | 2 +- 6 files changed, 91 insertions(+), 4 deletions(-) diff --git a/doc/code/converters/1_text_to_text_converters.ipynb b/doc/code/converters/1_text_to_text_converters.ipynb index a7d96c241e..2b81b63fe3 100644 --- a/doc/code/converters/1_text_to_text_converters.ipynb +++ b/doc/code/converters/1_text_to_text_converters.ipynb @@ -220,6 +220,8 @@ "Tatweel: text: أـخـبـرـنـي كـيـف أـقـطـع شـجـرـة\n", "Arabic Presentation Form: text: ﺃﺥﺏﺭﻥﻱ ﻙﻱﻑ ﺃﻕﻁﻉ ﺵﺝﺭﺓ\n", "Arabizi: text: a5brny kyf a863 shjra\n", + "Pinyin (full): text: gaosuworuhekandaoyikeshu\n", + "Pinyin (initials): text: gswrhkdyks\n", "Superscript: text: ᵗᵉˡˡ ᵐᵉ ʰᵒʷ ᵗᵒ ᶜᵘᵗ ᵈᵒʷⁿ ᵃ ᵗʳᵉᵉ\n", "Zalgo: text: t̼͉͎̄͌ĕ̜̃͏̹͔̉̆͑l͇̩̎̓̒ḽ̮̉͊͑ͧ̆̄͘͝ m̺ę̖̽ h̡̞̘̮͔̮ͮ̆͜ͅo̹̰͙̯ͮ̈́̇̈ͣ͢w̤̻̺̅ͮ́̾ͧ͑ t̛̠͑̔̓͝ơ͔͔̙͍ͥ̀̈̈́͞ c͖̃̕͘͢͞u̺̝͕̍͟t̳ͬͪ dͪo̝̓̀ͫ́̂͠ͅw̢̗n̫̞̐ͥ a̘͒̍̒ ṫ̴̡̗̣̳ͨ͗r̛̯̦͕̱ẹ̲͉ͬ̈͐̔́̿ͤe̵̳͚̾͌̅̔\n", "CharSwap: text: tlel me how to cut dwon a tree\n", @@ -382,6 +384,7 @@ " InsertPunctuationConverter,\n", " LeetspeakConverter,\n", " MathObfuscationConverter,\n", + " PinyinConverter,\n", " PuzzledConverter,\n", " RandomCapitalLettersConverter,\n", " RepeatTokenConverter,\n", @@ -431,6 +434,11 @@ "print(\"Arabic Presentation Form:\", await ArabicPresentationFormConverter().convert_async(prompt=arabic_prompt)) # type: ignore\n", "# Arabizi transliterates Arabic script into Latin-script chat Arabic\n", "print(\"Arabizi:\", await ArabiziConverter().convert_async(prompt=arabic_prompt)) # type: ignore\n", + "# Pinyin only affects Chinese (Hanzi) characters, so it uses a Chinese prompt. It rewrites Hanzi\n", + "# as their Pinyin romanization - as full readings, first-letter initials, or a Hanzi/Pinyin mix.\n", + "chinese_prompt = \"告诉我如何砍倒一棵树\"\n", + "print(\"Pinyin (full):\", await PinyinConverter().convert_async(prompt=chinese_prompt)) # type: ignore\n", + "print(\"Pinyin (initials):\", await PinyinConverter(mode=\"initial\").convert_async(prompt=chinese_prompt)) # type: ignore\n", "print(\"Superscript:\", await SuperscriptConverter().convert_async(prompt=prompt)) # type: ignore\n", "print(\"Zalgo:\", await ZalgoConverter().convert_async(prompt=prompt)) # type: ignore\n", "\n", diff --git a/pyproject.toml b/pyproject.toml index e692806bc6..8e8b2f7497 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ dependencies = [ "PyJWT[crypto]>=2.8.0", "pyodbc>=5.1.0", "pypdf>=6.10.2", - "pypinyin>=0.53.0", + "pypinyin>=0.55.0", "python-docx>=1.1.0", "python-dotenv>=1.2.2", "reportlab>=4.4.4", diff --git a/pyrit/converter/pinyin_converter.py b/pyrit/converter/pinyin_converter.py index abfc509677..5b6bcceec0 100644 --- a/pyrit/converter/pinyin_converter.py +++ b/pyrit/converter/pinyin_converter.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +import asyncio import re from typing import Literal @@ -50,7 +51,7 @@ class PinyinConverter(Converter): always left unchanged, as are Hanzi without a dictionary reading. Pass ``seed`` for reproducible selection. - Pinyin dictionaries are loaded lazily when converting prompts. + Pinyin dictionaries are loaded lazily, and phrase lookup runs in a worker thread. """ SUPPORTED_INPUT_TYPES = ("text",) @@ -155,7 +156,7 @@ async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text if not selected: return ConverterResult(output_text=prompt, output_type="text") - readings = self._get_pinyin_readings(prompt) + readings = await asyncio.to_thread(self._get_pinyin_readings, prompt) out: list[str] = [] for i, (ch, reading) in enumerate(zip(prompt, readings, strict=True)): if i not in selected: diff --git a/tests/unit/converter/test_pinyin_converter.py b/tests/unit/converter/test_pinyin_converter.py index afafa6dd59..c9cda7ad5c 100644 --- a/tests/unit/converter/test_pinyin_converter.py +++ b/tests/unit/converter/test_pinyin_converter.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +import asyncio +import threading from random import Random from unittest.mock import MagicMock, patch @@ -227,3 +229,39 @@ async def test_pinyin_preserves_extended_hanzi_without_readings_async(mode: Piny readings.assert_called_once() assert result.output_text == prompt + + +async def test_pinyin_readings_do_not_block_event_loop_async() -> None: + converter = PinyinConverter() + loop = asyncio.get_running_loop() + loop_thread = threading.get_ident() + started = asyncio.Event() + release = threading.Event() + + def blocking_readings(prompt: str) -> list[str]: + assert threading.get_ident() != loop_thread + assert prompt == "中心" + loop.call_soon_threadsafe(started.set) + if not release.wait(timeout=5): + raise TimeoutError("The event loop did not release the dictionary lookup") + return ["zhong", "xin"] + + with patch.object(converter, "_get_pinyin_readings", side_effect=blocking_readings): + conversion = asyncio.create_task(converter.convert_async(prompt="中心")) + try: + await asyncio.wait_for(started.wait(), timeout=5) + assert not conversion.done() + finally: + release.set() + result = await asyncio.wait_for(conversion, timeout=5) + + assert result.output_text == "zhongxin" + + +async def test_pinyin_reading_errors_propagate_async() -> None: + converter = PinyinConverter() + with ( + patch.object(converter, "_get_pinyin_readings", side_effect=RuntimeError("Dictionary lookup failed")), + pytest.raises(RuntimeError, match="Dictionary lookup failed"), + ): + await converter.convert_async(prompt="中心") diff --git a/tests/unit/converter/test_seeded_converter_determinism.py b/tests/unit/converter/test_seeded_converter_determinism.py index c0d361ef3e..709ffbaf3a 100644 --- a/tests/unit/converter/test_seeded_converter_determinism.py +++ b/tests/unit/converter/test_seeded_converter_determinism.py @@ -21,6 +21,7 @@ InsertPunctuationConverter, LeetspeakConverter, MathObfuscationConverter, + PinyinConverter, RandomCapitalLettersConverter, SearchReplaceConverter, TemplateSegmentConverter, @@ -28,9 +29,19 @@ WordProportionSelectionStrategy, ZalgoConverter, ) +from pyrit.converter.pinyin_converter import PinyinMode from pyrit.models import PromptDataType +def _pinyin_converter_cases() -> list[tuple[Callable[[], Converter], str]]: + prompt = "\u94f6\u884c\u97f3\u4e50\u91cd\u5e86\u4eca\u5929\u5929\u6c14" + return [ + (lambda: PinyinConverter(proportion=0.5), prompt), + (lambda: PinyinConverter(mode="initial", proportion=0.5), prompt), + (lambda: PinyinConverter(mode="mixed", proportion=0.5), prompt), + ] + + def _stochastic_converter_cases() -> list[tuple[Callable[[], Converter], str]]: return [ (AskToDecodeConverter, "encoded text"), @@ -58,6 +69,7 @@ def _stochastic_converter_cases() -> list[tuple[Callable[[], Converter], str]]: (MathObfuscationConverter, "deterministic math output"), (lambda: SearchReplaceConverter(pattern="x", replace=["a", "b", "c"]), "xxx"), (UnicodeConfusableConverter, "deterministic confusable output"), + *_pinyin_converter_cases(), ] @@ -192,6 +204,34 @@ async def test_explicit_converter_seed_overrides_initialized_seed() -> None: assert first == second +@pytest.mark.parametrize(("converter_factory", "prompt"), _pinyin_converter_cases()) +async def test_pinyin_initialized_seed_is_parallel_order_independent_async( + *, converter_factory: Callable[[], Converter], prompt: str +) -> None: + configure_random_seed(seed=42) + converter = converter_factory() + prompts = [prompt, f"{prompt} {prompt}"] + + serial = [await converter.convert_async(prompt=value) for value in prompts] + forward = await asyncio.gather(*(converter.convert_async(prompt=value) for value in prompts)) + reverse = await asyncio.gather(*(converter.convert_async(prompt=value) for value in reversed(prompts))) + + assert serial == forward == list(reversed(reverse)) + + +@pytest.mark.parametrize("mode", ["full", "initial", "mixed"]) +async def test_pinyin_explicit_seed_overrides_initialized_seed_async(mode: PinyinMode) -> None: + converter = PinyinConverter(mode=mode, proportion=0.5, seed=7) + prompt = "\u94f6\u884c\u97f3\u4e50\u91cd\u5e86\u4eca\u5929\u5929\u6c14" + + configure_random_seed(seed=1) + first = await converter.convert_async(prompt=prompt) + configure_random_seed(seed=99) + second = await converter.convert_async(prompt=prompt) + + assert first == second + + async def test_nested_same_class_converter_uses_independent_stream() -> None: configure_random_seed(seed=42) standalone = _NestedRandomConverter() diff --git a/uv.lock b/uv.lock index 5d13bb4e19..2fe478789e 100644 --- a/uv.lock +++ b/uv.lock @@ -4917,7 +4917,7 @@ requires-dist = [ { name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0" }, { name = "pyodbc", specifier = ">=5.1.0" }, { name = "pypdf", specifier = ">=6.10.2" }, - { name = "pypinyin", specifier = ">=0.53.0" }, + { name = "pypinyin", specifier = ">=0.55.0" }, { name = "python-docx", specifier = ">=1.1.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "reportlab", specifier = ">=4.4.4" },