diff --git a/doc/code/converters/0_converters.ipynb b/doc/code/converters/0_converters.ipynb index 7fa11f1d9e..932d89c851 100644 --- a/doc/code/converters/0_converters.ipynb +++ b/doc/code/converters/0_converters.ipynb @@ -98,55 +98,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/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/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 diff --git a/pyproject.toml b/pyproject.toml index 2184fcbfc1..ba5b508310 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.55.0", "python-docx>=1.1.0", "python-dotenv>=1.2.2", "reportlab>=4.4.4", 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..5b6bcceec0 --- /dev/null +++ b/pyrit/converter/pinyin_converter.py @@ -0,0 +1,176 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import asyncio +import re +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 eligible for conversion. Other characters +# (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-\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 + "]" +) + + +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 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``). + - ``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, as are Hanzi without a dictionary reading. Pass ``seed`` for + reproducible selection. + + Pinyin dictionaries are loaded lazily, and phrase lookup runs in a worker thread. + """ + + 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, 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. + + 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, + } + ) + + def _get_pinyin_readings(self, prompt: str) -> list[str]: + """ + Resolve phrase-aware Pinyin readings aligned with the original characters. + + Args: + prompt (str): The complete prompt used as pronunciation context. + + Returns: + list[str]: One reading per character, preserving characters without a reading. + """ + 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: + """ + 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") + + 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() + if not selected: + return ConverterResult(output_text=prompt, output_type="text") + + 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: + out.append(ch) + continue + + reading = reading or ch + if self._mode == "initial": + reading = reading[0] + elif self._mode == "mixed": + reading = rng.choice((reading, reading[0])) + + out.append(reading) + if self._separator and reading != ch and i < len(prompt) - 1: + out.append(self._separator) + + return ConverterResult(output_text="".join(out), output_type="text") 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 new file mode 100644 index 0000000000..c9cda7ad5c --- /dev/null +++ b/tests/unit/converter/test_pinyin_converter.py @@ -0,0 +1,267 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import asyncio +import threading +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(): + 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" + + +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" + + +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" + + +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" + + +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" + + +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 + + +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 == "你好世界" + + +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 + + +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 + + +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(): + 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 + + +@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}" + + +@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 + + +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 909ea12356..3092c63afc 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.55.0" }, { name = "python-docx", specifier = ">=1.1.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "reportlab", specifier = ">=4.4.4" },