From e6d3f152170e326c59ee84161953538bf9934d4d Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Wed, 19 Mar 2025 19:51:35 +0100 Subject: [PATCH 01/20] feat: add `SuperscriptConverter` (simple implementation) --- pyrit/prompt_converter/__init__.py | 2 + .../prompt_converter/superscript_converter.py | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 pyrit/prompt_converter/superscript_converter.py diff --git a/pyrit/prompt_converter/__init__.py b/pyrit/prompt_converter/__init__.py index 0e7f6f9e63..9397f0bef9 100644 --- a/pyrit/prompt_converter/__init__.py +++ b/pyrit/prompt_converter/__init__.py @@ -47,6 +47,7 @@ from pyrit.prompt_converter.search_replace_converter import SearchReplaceConverter from pyrit.prompt_converter.string_join_converter import StringJoinConverter from pyrit.prompt_converter.suffix_append_converter import SuffixAppendConverter +from pyrit.prompt_converter.superscript_converter import SuperscriptConverter from pyrit.prompt_converter.tense_converter import TenseConverter from pyrit.prompt_converter.text_to_hex_converter import TextToHexConverter from pyrit.prompt_converter.tone_converter import ToneConverter @@ -101,6 +102,7 @@ "SearchReplaceConverter", "StringJoinConverter", "SuffixAppendConverter", + "SuperscriptConverter", "TextToHexConverter", "TenseConverter", "ToneConverter", diff --git a/pyrit/prompt_converter/superscript_converter.py b/pyrit/prompt_converter/superscript_converter.py new file mode 100644 index 0000000000..bd7e8a2438 --- /dev/null +++ b/pyrit/prompt_converter/superscript_converter.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from pyrit.models import PromptDataType +from pyrit.prompt_converter import ConverterResult, PromptConverter + + +class SuperscriptConverter(PromptConverter): + """ + Converts the input text to superscript text. + + Note: This converter leaves unsupported characters unchanged. + """ + + def __init__(self): + self._superscript_map = { + "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", + "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", + "a": "ᵃ", "b": "ᵇ", "c": "ᶜ", "d": "ᵈ", "e": "ᵉ", + "f": "ᶠ", "g": "ᵍ", "h": "ʰ", "i": "ⁱ", "j": "ʲ", + "k": "ᵏ", "l": "ˡ", "m": "ᵐ", "n": "ⁿ", "o": "ᵒ", + "p": "ᵖ", "r": "ʳ", "s": "ˢ", "t": "ᵗ", "u": "ᵘ", + "v": "ᵛ", "w": "ʷ", "x": "ˣ", "y": "ʸ", "z": "ᶻ", + "A": "ᴬ", "B": "ᴮ", "D": "ᴰ", "E": "ᴱ", "G": "ᴳ", + "H": "ᴴ", "I": "ᴵ", "J": "ᴶ", "K": "ᴷ", "L": "ᴸ", + "M": "ᴹ", "N": "ᴺ", "O": "ᴼ", "P": "ᴾ", "R": "ᴿ", + "T": "ᵀ", "U": "ᵁ", "V": "ⱽ", "W": "ᵂ", "+": "⁺", + "-": "⁻", "=": "⁼", "(": "⁽", ")": "⁾", + } + + def _to_superscript(self, text: str) -> str: + return "".join(self._superscript_map.get(char, char) for char in text) + + async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text") -> ConverterResult: + if not self.input_supported(input_type): + raise ValueError("Input type not supported") + + words = prompt.split() + result = [] + + for word in words: + result.append(self._to_superscript(word)) + + converted_text = " ".join(result) + result = ConverterResult(output_text=converted_text, output_type="text") + return result + + def input_supported(self, input_type: PromptDataType) -> bool: + return input_type == "text" + + def output_supported(self, output_type: PromptDataType) -> bool: + return output_type == "text" From f2b99a4d57d6204e5019d5742aa816764c7d21df Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Wed, 19 Mar 2025 19:54:00 +0100 Subject: [PATCH 02/20] feat: add a simple test for the converter --- .../converter/test_superscript_converter.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 tests/unit/converter/test_superscript_converter.py diff --git a/tests/unit/converter/test_superscript_converter.py b/tests/unit/converter/test_superscript_converter.py new file mode 100644 index 0000000000..70b7b8624b --- /dev/null +++ b/tests/unit/converter/test_superscript_converter.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import pytest + +from pyrit.prompt_converter import ConverterResult, SuperscriptConverter + + +@pytest.mark.asyncio +async def test_superscript_converter(): + converter = SuperscriptConverter() + prompts = ["Let's test this converter!", "Unsupported characters stay the same: qCFQSXYZ"] + expected_outputs = ["ᴸᵉᵗ'ˢ ᵗᵉˢᵗ ᵗʰⁱˢ ᶜᵒⁿᵛᵉʳᵗᵉʳ!", "ᵁⁿˢᵘᵖᵖᵒʳᵗᵉᵈ ᶜʰᵃʳᵃᶜᵗᵉʳˢ ˢᵗᵃʸ ᵗʰᵉ ˢᵃᵐᵉ: qCFQSXYZ"] + for prompt, expected_output in zip(prompts, expected_outputs): + result = await converter.convert_async(prompt=prompt, input_type="text") + assert isinstance(result, ConverterResult) + assert result.output_text == expected_output From 53a118597beea585f14301ac4e5117097f10d879 Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Wed, 19 Mar 2025 23:36:50 +0100 Subject: [PATCH 03/20] implement `alternate` mode --- .../prompt_converter/superscript_converter.py | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/pyrit/prompt_converter/superscript_converter.py b/pyrit/prompt_converter/superscript_converter.py index bd7e8a2438..9c6ce5f328 100644 --- a/pyrit/prompt_converter/superscript_converter.py +++ b/pyrit/prompt_converter/superscript_converter.py @@ -1,18 +1,38 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +from typing import Optional + from pyrit.models import PromptDataType from pyrit.prompt_converter import ConverterResult, PromptConverter class SuperscriptConverter(PromptConverter): """ - Converts the input text to superscript text. - - Note: This converter leaves unsupported characters unchanged. + Converts the input text to superscript text. Supports various modes for conversion. + + Supported modes: + - 'all': Converts all words. The default mode. + - 'alternate': Converts every other word. Configurable. + + Note: + This converter leaves characters that do not have a superscript equivalent unchanged. """ - def __init__(self): + def __init__( + self, + mode: Optional[str] = 'all', + alternate_step: Optional[int] = 2, + ): + """ + Initialize the SuperscriptConverter. + + Args: + mode (Optional[str]): Conversion mode - 'all', or 'alternate'. Defaults to 'all'. + alternate_step (Optional[int]): For 'alternate' mode, convert every nth word. Defaults to 2. + """ + self.mode = mode + self.alternate_step = alternate_step self._superscript_map = { "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", @@ -38,8 +58,20 @@ async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text words = prompt.split() result = [] - for word in words: - result.append(self._to_superscript(word)) + if self.mode == 'alternate': + # Convert every nth word + for i, word in enumerate(words): + if i % self.alternate_step == 0: + result.append(self._to_superscript(word)) + else: + result.append(word) + + #TODO: add more modes here + + else: + # Convert every word if mode is not recognized or it's actually 'all' + for word in words: + result.append(self._to_superscript(word)) converted_text = " ".join(result) result = ConverterResult(output_text=converted_text, output_type="text") From 2d5ec0b69bfa625a076e728f27e787527b193ea8 Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Wed, 19 Mar 2025 23:44:40 +0100 Subject: [PATCH 04/20] tests: extract conversion logic to a helper function --- .../unit/converter/test_superscript_converter.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/unit/converter/test_superscript_converter.py b/tests/unit/converter/test_superscript_converter.py index 70b7b8624b..37a9308803 100644 --- a/tests/unit/converter/test_superscript_converter.py +++ b/tests/unit/converter/test_superscript_converter.py @@ -6,12 +6,18 @@ from pyrit.prompt_converter import ConverterResult, SuperscriptConverter -@pytest.mark.asyncio -async def test_superscript_converter(): - converter = SuperscriptConverter() - prompts = ["Let's test this converter!", "Unsupported characters stay the same: qCFQSXYZ"] - expected_outputs = ["ᴸᵉᵗ'ˢ ᵗᵉˢᵗ ᵗʰⁱˢ ᶜᵒⁿᵛᵉʳᵗᵉʳ!", "ᵁⁿˢᵘᵖᵖᵒʳᵗᵉᵈ ᶜʰᵃʳᵃᶜᵗᵉʳˢ ˢᵗᵃʸ ᵗʰᵉ ˢᵃᵐᵉ: qCFQSXYZ"] +async def _check_conversion(converter, prompts, expected_outputs): for prompt, expected_output in zip(prompts, expected_outputs): result = await converter.convert_async(prompt=prompt, input_type="text") assert isinstance(result, ConverterResult) assert result.output_text == expected_output + + +@pytest.mark.asyncio +async def test_superscript_converter(): + defalut_converter = SuperscriptConverter() + await _check_conversion( + defalut_converter, + ["Let's test this converter!", "Unsupported characters stay the same: qCFQSXYZ"], + ["ᴸᵉᵗ'ˢ ᵗᵉˢᵗ ᵗʰⁱˢ ᶜᵒⁿᵛᵉʳᵗᵉʳ!", "ᵁⁿˢᵘᵖᵖᵒʳᵗᵉᵈ ᶜʰᵃʳᵃᶜᵗᵉʳˢ ˢᵗᵃʸ ᵗʰᵉ ˢᵃᵐᵉ: qCFQSXYZ"], + ) From a8d905bc46b8e1844e2221387155426f85910ed4 Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Wed, 19 Mar 2025 23:45:55 +0100 Subject: [PATCH 05/20] test 'alternate' mode --- tests/unit/converter/test_superscript_converter.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/converter/test_superscript_converter.py b/tests/unit/converter/test_superscript_converter.py index 37a9308803..002c1d1feb 100644 --- a/tests/unit/converter/test_superscript_converter.py +++ b/tests/unit/converter/test_superscript_converter.py @@ -21,3 +21,10 @@ async def test_superscript_converter(): ["Let's test this converter!", "Unsupported characters stay the same: qCFQSXYZ"], ["ᴸᵉᵗ'ˢ ᵗᵉˢᵗ ᵗʰⁱˢ ᶜᵒⁿᵛᵉʳᵗᵉʳ!", "ᵁⁿˢᵘᵖᵖᵒʳᵗᵉᵈ ᶜʰᵃʳᵃᶜᵗᵉʳˢ ˢᵗᵃʸ ᵗʰᵉ ˢᵃᵐᵉ: qCFQSXYZ"], ) + + alternate_converter = SuperscriptConverter(mode="alternate") + await _check_conversion( + alternate_converter, + ["word1 word2 word3 word4 word5"], + ["ʷᵒʳᵈ¹ word2 ʷᵒʳᵈ³ word4 ʷᵒʳᵈ⁵"], + ) From aaa04d47e8015601b0ba22675b1a396dea62cc29 Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Thu, 20 Mar 2025 18:20:29 +0100 Subject: [PATCH 06/20] refactor: move `get_n_random` to `utils.py` --- pyrit/common/utils.py | 14 ++++++++++++++ .../charswap_attack_converter.py | 19 ++----------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/pyrit/common/utils.py b/pyrit/common/utils.py index b01fbc8fe3..91b614424f 100644 --- a/pyrit/common/utils.py +++ b/pyrit/common/utils.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +import logging +import random from typing import List, Union @@ -42,3 +44,15 @@ def combine_list(list1: Union[str, List[str]], list2: Union[str, List[str]]) -> # Merge and keep only unique values combined = list(set(list1 + list2)) return combined + + +def get_n_random(low: int, high: int, n: int) -> list[int]: + """ + Generate a list of n random indices within a given range: low (inclusive) and high (exclusive). + """ + result = [] + try: + result = random.sample(range(low, high), n) + except ValueError: + logging.getLogger(__name__).debug(f"Sample size of {n} exceeds population size of {high - low}") + return result diff --git a/pyrit/prompt_converter/charswap_attack_converter.py b/pyrit/prompt_converter/charswap_attack_converter.py index f009eba682..218cd2fe74 100644 --- a/pyrit/prompt_converter/charswap_attack_converter.py +++ b/pyrit/prompt_converter/charswap_attack_converter.py @@ -1,18 +1,15 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -import logging import math import random import re import string +from pyrit.common.utils import get_n_random from pyrit.models import PromptDataType from pyrit.prompt_converter import ConverterResult, PromptConverter -# Use logger -logger = logging.getLogger(__name__) - class CharSwapGenerator(PromptConverter): """ @@ -86,7 +83,7 @@ async def convert_async(self, *, prompt: str, input_type="text") -> ConverterRes perturbed_word_list = words.copy() # Get random indices of words to undergo swapping - random_words_idx = self._get_n_random(0, word_list_len, num_perturb_words) + random_words_idx = get_n_random(0, word_list_len, num_perturb_words) # Apply perturbation by swapping characters in the selected words for idx in random_words_idx: @@ -99,15 +96,3 @@ async def convert_async(self, *, prompt: str, input_type="text") -> ConverterRes output_text = re.sub(r'\s([?.!,\'"])', r"\1", new_prompt).strip() return ConverterResult(output_text=output_text, output_type="text") - - def _get_n_random(self, low: int, high: int, n: int) -> list: - """ - Utility function to generate random indices. - Words at these indices will be subjected to perturbation. - """ - result = [] - try: - result = random.sample(range(low, high), n) - except ValueError: - logger.debug(f"[CharSwapConverter] Sample size of {n} exceeds population size of {high - low}") - return result From 9ca5938896eb72e6a962ab3ea792b4ac89502bf9 Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Thu, 20 Mar 2025 19:57:34 +0100 Subject: [PATCH 07/20] rename `get_n_random` -> `get_random_indices` and update its logic to sample based on percentage --- pyrit/common/utils.py | 15 ++++++++++++--- .../prompt_converter/charswap_attack_converter.py | 7 ++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/pyrit/common/utils.py b/pyrit/common/utils.py index 91b614424f..5d0ede0cd6 100644 --- a/pyrit/common/utils.py +++ b/pyrit/common/utils.py @@ -2,6 +2,7 @@ # Licensed under the MIT license. import logging +import math import random from typing import List, Union @@ -46,13 +47,21 @@ def combine_list(list1: Union[str, List[str]], list2: Union[str, List[str]]) -> return combined -def get_n_random(low: int, high: int, n: int) -> list[int]: +def get_random_indices(low: int, high: int, percentage: float) -> list[int]: """ - Generate a list of n random indices within a given range: low (inclusive) and high (exclusive). + Generate a list of random indices within a given range based on a percentage. + + Args: + low: Lower bound of the range (inclusive). + high: Upper bound of the range (exclusive). + percentage: Percentage of range to sample (0.0 to 1.0). """ result = [] + n = max(1, math.ceil((high - low) * percentage)) try: result = random.sample(range(low, high), n) except ValueError: - logging.getLogger(__name__).debug(f"Sample size of {n} exceeds population size of {high - low}") + logging.getLogger(__name__).debug( + f"Sample size of {n} exceeds population size of {high - low}" + ) return result diff --git a/pyrit/prompt_converter/charswap_attack_converter.py b/pyrit/prompt_converter/charswap_attack_converter.py index 218cd2fe74..a0af2e9076 100644 --- a/pyrit/prompt_converter/charswap_attack_converter.py +++ b/pyrit/prompt_converter/charswap_attack_converter.py @@ -1,12 +1,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -import math import random import re import string -from pyrit.common.utils import get_n_random +from pyrit.common.utils import get_random_indices from pyrit.models import PromptDataType from pyrit.prompt_converter import ConverterResult, PromptConverter @@ -76,14 +75,12 @@ async def convert_async(self, *, prompt: str, input_type="text") -> ConverterRes # Tokenize the prompt into words and punctuation using regex words = re.findall(r"\w+|\S+", prompt) - word_list_len = len(words) - num_perturb_words = max(1, math.ceil(word_list_len * self.word_swap_ratio)) # Copy the original word list for perturbation perturbed_word_list = words.copy() # Get random indices of words to undergo swapping - random_words_idx = get_n_random(0, word_list_len, num_perturb_words) + random_words_idx = get_random_indices(0, len(words), self.word_swap_ratio) # Apply perturbation by swapping characters in the selected words for idx in random_words_idx: From e96ae010e188b731fb56011b78529eab864fac42 Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Fri, 21 Mar 2025 13:01:53 +0100 Subject: [PATCH 08/20] rename parameter `percentage` to `sample_ratio` --- pyrit/common/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyrit/common/utils.py b/pyrit/common/utils.py index 5d0ede0cd6..f93b32e28d 100644 --- a/pyrit/common/utils.py +++ b/pyrit/common/utils.py @@ -47,17 +47,17 @@ def combine_list(list1: Union[str, List[str]], list2: Union[str, List[str]]) -> return combined -def get_random_indices(low: int, high: int, percentage: float) -> list[int]: +def get_random_indices(low: int, high: int, sample_ratio: float) -> list[int]: """ - Generate a list of random indices within a given range based on a percentage. + Generate a list of random indices within a given range based on a sample ratio. Args: low: Lower bound of the range (inclusive). high: Upper bound of the range (exclusive). - percentage: Percentage of range to sample (0.0 to 1.0). + sample_ratio: Ratio of range to sample (0.0 to 1.0). """ result = [] - n = max(1, math.ceil((high - low) * percentage)) + n = max(1, math.ceil((high - low) * sample_ratio)) try: result = random.sample(range(low, high), n) except ValueError: From 35499a38f0578fb2888c2e59c9e209b43fd3034f Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Fri, 21 Mar 2025 13:11:13 +0100 Subject: [PATCH 09/20] feat: add 'random' mode --- pyrit/prompt_converter/superscript_converter.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pyrit/prompt_converter/superscript_converter.py b/pyrit/prompt_converter/superscript_converter.py index 9c6ce5f328..dd801e0a93 100644 --- a/pyrit/prompt_converter/superscript_converter.py +++ b/pyrit/prompt_converter/superscript_converter.py @@ -3,6 +3,7 @@ from typing import Optional +from pyrit.common.utils import get_random_indices from pyrit.models import PromptDataType from pyrit.prompt_converter import ConverterResult, PromptConverter @@ -14,6 +15,7 @@ class SuperscriptConverter(PromptConverter): Supported modes: - 'all': Converts all words. The default mode. - 'alternate': Converts every other word. Configurable. + - 'random': Converts a random selection of words based on a percentage. Note: This converter leaves characters that do not have a superscript equivalent unchanged. @@ -23,6 +25,7 @@ def __init__( self, mode: Optional[str] = 'all', alternate_step: Optional[int] = 2, + random_percentage: Optional[int] = 50, ): """ Initialize the SuperscriptConverter. @@ -30,9 +33,11 @@ def __init__( Args: mode (Optional[str]): Conversion mode - 'all', or 'alternate'. Defaults to 'all'. alternate_step (Optional[int]): For 'alternate' mode, convert every nth word. Defaults to 2. + random_percentage (Optional[int]): For 'random' mode, percentage of words to convert. Defaults to 50. """ self.mode = mode self.alternate_step = alternate_step + self.random_percentage = random_percentage self._superscript_map = { "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", @@ -66,6 +71,16 @@ async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text else: result.append(word) + elif self.mode == 'random': + # Convert random words based on percentage + word_count = len(words) + random_indices = get_random_indices(0, word_count, self.random_percentage / 100.0) + for i, word in enumerate(words): + if i in random_indices: + result.append(self._to_superscript(word)) + else: + result.append(word) + #TODO: add more modes here else: From 0878a9a748981d3cb1a2a711b9d252ffcbddb6c4 Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Fri, 21 Mar 2025 13:39:44 +0100 Subject: [PATCH 10/20] add tests for 'random' mode --- .../converter/test_superscript_converter.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/unit/converter/test_superscript_converter.py b/tests/unit/converter/test_superscript_converter.py index 002c1d1feb..2bfd005777 100644 --- a/tests/unit/converter/test_superscript_converter.py +++ b/tests/unit/converter/test_superscript_converter.py @@ -2,6 +2,7 @@ # Licensed under the MIT license. import pytest +import random from pyrit.prompt_converter import ConverterResult, SuperscriptConverter @@ -28,3 +29,25 @@ async def test_superscript_converter(): ["word1 word2 word3 word4 word5"], ["ʷᵒʳᵈ¹ word2 ʷᵒʳᵈ³ word4 ʷᵒʳᵈ⁵"], ) + +@pytest.mark.asyncio +async def test_random_superscript_converter(): + full_random_converter = SuperscriptConverter(mode="random", random_percentage=100) + await _check_conversion( + full_random_converter, + ["Let's test random mode"], + ["ᴸᵉᵗ'ˢ ᵗᵉˢᵗ ʳᵃⁿᵈᵒᵐ ᵐᵒᵈᵉ"], + ) + # zero_random_converter = SuperscriptConverter(mode="random", random_percentage=0) + # await _check_conversion( + # zero_random_converter, + # ["Let's test random mode"], + # ["Let's test random mode"], + # ) + + random.seed(32) # with seed=32 and 6 words, words at [1,2,5] will be converted + half_random_converter = SuperscriptConverter(mode="random", random_percentage=50) + test_text = "one two three four five six" + expected_output = "ᵒⁿᵉ ᵗʷᵒ three four ᶠⁱᵛᵉ six" + result = await half_random_converter.convert_async(prompt=test_text, input_type="text") + assert result.output_text == expected_output From 126e4206cf0da4a4a2e004dcb0b9bebc8e32d3df Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Fri, 21 Mar 2025 13:52:38 +0100 Subject: [PATCH 11/20] fix for `get_random_indices`: when the ratio is 0, return an empty list --- pyrit/common/utils.py | 11 ++++++++++- tests/unit/converter/test_superscript_converter.py | 12 ++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/pyrit/common/utils.py b/pyrit/common/utils.py index f93b32e28d..dc906977fa 100644 --- a/pyrit/common/utils.py +++ b/pyrit/common/utils.py @@ -56,8 +56,17 @@ def get_random_indices(low: int, high: int, sample_ratio: float) -> list[int]: high: Upper bound of the range (exclusive). sample_ratio: Ratio of range to sample (0.0 to 1.0). """ + # Special case: return empty list + if sample_ratio == 0: + return [] + result = [] - n = max(1, math.ceil((high - low) * sample_ratio)) + n = math.ceil((high - low) * sample_ratio) + + # Ensure at least 1 index for non-zero sample ratio + if sample_ratio > 0 and n == 0: + n = 1 + try: result = random.sample(range(low, high), n) except ValueError: diff --git a/tests/unit/converter/test_superscript_converter.py b/tests/unit/converter/test_superscript_converter.py index 2bfd005777..4853cac2b1 100644 --- a/tests/unit/converter/test_superscript_converter.py +++ b/tests/unit/converter/test_superscript_converter.py @@ -38,12 +38,12 @@ async def test_random_superscript_converter(): ["Let's test random mode"], ["ᴸᵉᵗ'ˢ ᵗᵉˢᵗ ʳᵃⁿᵈᵒᵐ ᵐᵒᵈᵉ"], ) - # zero_random_converter = SuperscriptConverter(mode="random", random_percentage=0) - # await _check_conversion( - # zero_random_converter, - # ["Let's test random mode"], - # ["Let's test random mode"], - # ) + zero_random_converter = SuperscriptConverter(mode="random", random_percentage=0) + await _check_conversion( + zero_random_converter, + ["Let's test random mode"], + ["Let's test random mode"], + ) random.seed(32) # with seed=32 and 6 words, words at [1,2,5] will be converted half_random_converter = SuperscriptConverter(mode="random", random_percentage=50) From 7d1e02b34b30384ad430636f7c3a80c209bec2b1 Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Fri, 21 Mar 2025 14:27:32 +0100 Subject: [PATCH 12/20] new test case with more words and 20% --- .../converter/test_superscript_converter.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/unit/converter/test_superscript_converter.py b/tests/unit/converter/test_superscript_converter.py index 4853cac2b1..c47562e6d2 100644 --- a/tests/unit/converter/test_superscript_converter.py +++ b/tests/unit/converter/test_superscript_converter.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +import math import pytest import random @@ -51,3 +52,25 @@ async def test_random_superscript_converter(): expected_output = "ᵒⁿᵉ ᵗʷᵒ three four ᶠⁱᵛᵉ six" result = await half_random_converter.convert_async(prompt=test_text, input_type="text") assert result.output_text == expected_output + + # Test with a longer text (37 words) and 20% conversion rate + + random.seed() + twenty_percent_converter = SuperscriptConverter(mode="random", random_percentage=20) + + long_text = "Prompt: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." + word_count = len(long_text.split()) + assert word_count == 37 + + result = await twenty_percent_converter.convert_async(prompt=long_text, input_type="text") + original_words = long_text.split() + converted_words = result.output_text.split() + assert len(converted_words) == len(original_words) + + # Count words that were actually converted + converted_count = sum(1 for original, converted in zip(original_words, converted_words) + if original != converted) + + # With 37 words and 20%, math.ceil(37 * 0.2) = 8 words should be converted + expected_conversion_count = math.ceil(word_count * 0.2) + assert converted_count == expected_conversion_count From a8eafefe90d34dddf5423ab6ccee43a81dc03495 Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Fri, 21 Mar 2025 18:10:20 +0100 Subject: [PATCH 13/20] formatting --- pyrit/common/utils.py | 5 +- .../prompt_converter/superscript_converter.py | 79 +++++++++++++++---- .../converter/test_superscript_converter.py | 7 +- 3 files changed, 68 insertions(+), 23 deletions(-) diff --git a/pyrit/common/utils.py b/pyrit/common/utils.py index dc906977fa..2df9304a2e 100644 --- a/pyrit/common/utils.py +++ b/pyrit/common/utils.py @@ -4,7 +4,6 @@ import logging import math import random - from typing import List, Union @@ -70,7 +69,5 @@ def get_random_indices(low: int, high: int, sample_ratio: float) -> list[int]: try: result = random.sample(range(low, high), n) except ValueError: - logging.getLogger(__name__).debug( - f"Sample size of {n} exceeds population size of {high - low}" - ) + logging.getLogger(__name__).debug(f"Sample size of {n} exceeds population size of {high - low}") return result diff --git a/pyrit/prompt_converter/superscript_converter.py b/pyrit/prompt_converter/superscript_converter.py index dd801e0a93..b1b6174f86 100644 --- a/pyrit/prompt_converter/superscript_converter.py +++ b/pyrit/prompt_converter/superscript_converter.py @@ -23,7 +23,7 @@ class SuperscriptConverter(PromptConverter): def __init__( self, - mode: Optional[str] = 'all', + mode: Optional[str] = "all", alternate_step: Optional[int] = 2, random_percentage: Optional[int] = 50, ): @@ -39,18 +39,65 @@ def __init__( self.alternate_step = alternate_step self.random_percentage = random_percentage self._superscript_map = { - "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", - "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", - "a": "ᵃ", "b": "ᵇ", "c": "ᶜ", "d": "ᵈ", "e": "ᵉ", - "f": "ᶠ", "g": "ᵍ", "h": "ʰ", "i": "ⁱ", "j": "ʲ", - "k": "ᵏ", "l": "ˡ", "m": "ᵐ", "n": "ⁿ", "o": "ᵒ", - "p": "ᵖ", "r": "ʳ", "s": "ˢ", "t": "ᵗ", "u": "ᵘ", - "v": "ᵛ", "w": "ʷ", "x": "ˣ", "y": "ʸ", "z": "ᶻ", - "A": "ᴬ", "B": "ᴮ", "D": "ᴰ", "E": "ᴱ", "G": "ᴳ", - "H": "ᴴ", "I": "ᴵ", "J": "ᴶ", "K": "ᴷ", "L": "ᴸ", - "M": "ᴹ", "N": "ᴺ", "O": "ᴼ", "P": "ᴾ", "R": "ᴿ", - "T": "ᵀ", "U": "ᵁ", "V": "ⱽ", "W": "ᵂ", "+": "⁺", - "-": "⁻", "=": "⁼", "(": "⁽", ")": "⁾", + "0": "⁰", + "1": "¹", + "2": "²", + "3": "³", + "4": "⁴", + "5": "⁵", + "6": "⁶", + "7": "⁷", + "8": "⁸", + "9": "⁹", + "a": "ᵃ", + "b": "ᵇ", + "c": "ᶜ", + "d": "ᵈ", + "e": "ᵉ", + "f": "ᶠ", + "g": "ᵍ", + "h": "ʰ", + "i": "ⁱ", + "j": "ʲ", + "k": "ᵏ", + "l": "ˡ", + "m": "ᵐ", + "n": "ⁿ", + "o": "ᵒ", + "p": "ᵖ", + "r": "ʳ", + "s": "ˢ", + "t": "ᵗ", + "u": "ᵘ", + "v": "ᵛ", + "w": "ʷ", + "x": "ˣ", + "y": "ʸ", + "z": "ᶻ", + "A": "ᴬ", + "B": "ᴮ", + "D": "ᴰ", + "E": "ᴱ", + "G": "ᴳ", + "H": "ᴴ", + "I": "ᴵ", + "J": "ᴶ", + "K": "ᴷ", + "L": "ᴸ", + "M": "ᴹ", + "N": "ᴺ", + "O": "ᴼ", + "P": "ᴾ", + "R": "ᴿ", + "T": "ᵀ", + "U": "ᵁ", + "V": "ⱽ", + "W": "ᵂ", + "+": "⁺", + "-": "⁻", + "=": "⁼", + "(": "⁽", + ")": "⁾", } def _to_superscript(self, text: str) -> str: @@ -63,7 +110,7 @@ async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text words = prompt.split() result = [] - if self.mode == 'alternate': + if self.mode == "alternate": # Convert every nth word for i, word in enumerate(words): if i % self.alternate_step == 0: @@ -71,7 +118,7 @@ async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text else: result.append(word) - elif self.mode == 'random': + elif self.mode == "random": # Convert random words based on percentage word_count = len(words) random_indices = get_random_indices(0, word_count, self.random_percentage / 100.0) @@ -81,7 +128,7 @@ async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text else: result.append(word) - #TODO: add more modes here + # TODO: add more modes here else: # Convert every word if mode is not recognized or it's actually 'all' diff --git a/tests/unit/converter/test_superscript_converter.py b/tests/unit/converter/test_superscript_converter.py index c47562e6d2..f3e4db365e 100644 --- a/tests/unit/converter/test_superscript_converter.py +++ b/tests/unit/converter/test_superscript_converter.py @@ -2,9 +2,10 @@ # Licensed under the MIT license. import math -import pytest import random +import pytest + from pyrit.prompt_converter import ConverterResult, SuperscriptConverter @@ -31,6 +32,7 @@ async def test_superscript_converter(): ["ʷᵒʳᵈ¹ word2 ʷᵒʳᵈ³ word4 ʷᵒʳᵈ⁵"], ) + @pytest.mark.asyncio async def test_random_superscript_converter(): full_random_converter = SuperscriptConverter(mode="random", random_percentage=100) @@ -68,8 +70,7 @@ async def test_random_superscript_converter(): assert len(converted_words) == len(original_words) # Count words that were actually converted - converted_count = sum(1 for original, converted in zip(original_words, converted_words) - if original != converted) + converted_count = sum(1 for original, converted in zip(original_words, converted_words) if original != converted) # With 37 words and 20%, math.ceil(37 * 0.2) = 8 words should be converted expected_conversion_count = math.ceil(word_count * 0.2) From 7e00d54199be8063720fc5a25e27e2152c8951e3 Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Sun, 23 Mar 2025 15:24:02 +0100 Subject: [PATCH 14/20] mypy: fix errors --- pyrit/prompt_converter/superscript_converter.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyrit/prompt_converter/superscript_converter.py b/pyrit/prompt_converter/superscript_converter.py index b1b6174f86..5f4c43efc0 100644 --- a/pyrit/prompt_converter/superscript_converter.py +++ b/pyrit/prompt_converter/superscript_converter.py @@ -136,8 +136,7 @@ async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text result.append(self._to_superscript(word)) converted_text = " ".join(result) - result = ConverterResult(output_text=converted_text, output_type="text") - return result + return ConverterResult(output_text=converted_text, output_type="text") def input_supported(self, input_type: PromptDataType) -> bool: return input_type == "text" From 35eea3e1355f7a399b8664d9d07185b6f0bed23a Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Sun, 23 Mar 2025 16:13:02 +0100 Subject: [PATCH 15/20] fix `UnicodeDecodeError` flake8 complaining about some superscript characters --- .../prompt_converter/superscript_converter.py | 118 +++++++++--------- .../converter/test_superscript_converter.py | 23 +++- 2 files changed, 77 insertions(+), 64 deletions(-) diff --git a/pyrit/prompt_converter/superscript_converter.py b/pyrit/prompt_converter/superscript_converter.py index 5f4c43efc0..c95debf7fd 100644 --- a/pyrit/prompt_converter/superscript_converter.py +++ b/pyrit/prompt_converter/superscript_converter.py @@ -39,65 +39,65 @@ def __init__( self.alternate_step = alternate_step self.random_percentage = random_percentage self._superscript_map = { - "0": "⁰", - "1": "¹", - "2": "²", - "3": "³", - "4": "⁴", - "5": "⁵", - "6": "⁶", - "7": "⁷", - "8": "⁸", - "9": "⁹", - "a": "ᵃ", - "b": "ᵇ", - "c": "ᶜ", - "d": "ᵈ", - "e": "ᵉ", - "f": "ᶠ", - "g": "ᵍ", - "h": "ʰ", - "i": "ⁱ", - "j": "ʲ", - "k": "ᵏ", - "l": "ˡ", - "m": "ᵐ", - "n": "ⁿ", - "o": "ᵒ", - "p": "ᵖ", - "r": "ʳ", - "s": "ˢ", - "t": "ᵗ", - "u": "ᵘ", - "v": "ᵛ", - "w": "ʷ", - "x": "ˣ", - "y": "ʸ", - "z": "ᶻ", - "A": "ᴬ", - "B": "ᴮ", - "D": "ᴰ", - "E": "ᴱ", - "G": "ᴳ", - "H": "ᴴ", - "I": "ᴵ", - "J": "ᴶ", - "K": "ᴷ", - "L": "ᴸ", - "M": "ᴹ", - "N": "ᴺ", - "O": "ᴼ", - "P": "ᴾ", - "R": "ᴿ", - "T": "ᵀ", - "U": "ᵁ", - "V": "ⱽ", - "W": "ᵂ", - "+": "⁺", - "-": "⁻", - "=": "⁼", - "(": "⁽", - ")": "⁾", + "0": "\u2070", + "1": "\u00b9", + "2": "\u00b2", + "3": "\u00b3", + "4": "\u2074", + "5": "\u2075", + "6": "\u2076", + "7": "\u2077", + "8": "\u2078", + "9": "\u2079", + "a": "\u1d43", + "b": "\u1d47", + "c": "\u1d9c", + "d": "\u1d48", + "e": "\u1d49", + "f": "\u1da0", + "g": "\u1d4d", + "h": "\u02b0", + "i": "\u2071", + "j": "\u02b2", + "k": "\u1d4f", + "l": "\u02e1", + "m": "\u1d50", + "n": "\u207f", + "o": "\u1d52", + "p": "\u1d56", + "r": "\u02b3", + "s": "\u02e2", + "t": "\u1d57", + "u": "\u1d58", + "v": "\u1d5b", + "w": "\u02b7", + "x": "\u02e3", + "y": "\u02b8", + "z": "\u1dbb", + "A": "\u1d2c", + "B": "\u1d2d", + "D": "\u1d30", + "E": "\u1d31", + "G": "\u1d33", + "H": "\u1d34", + "I": "\u1d35", + "J": "\u1d36", + "K": "\u1d37", + "L": "\u1d38", + "M": "\u1d39", + "N": "\u1d3a", + "O": "\u1d3c", + "P": "\u1d3e", + "R": "\u1d3f", + "T": "\u1d40", + "U": "\u1d41", + "V": "\u2c7d", + "W": "\u1d42", + "+": "\u207a", + "-": "\u207b", + "=": "\u207c", + "(": "\u207d", + ")": "\u207e", } def _to_superscript(self, text: str) -> str: diff --git a/tests/unit/converter/test_superscript_converter.py b/tests/unit/converter/test_superscript_converter.py index f3e4db365e..247cbcb49d 100644 --- a/tests/unit/converter/test_superscript_converter.py +++ b/tests/unit/converter/test_superscript_converter.py @@ -22,14 +22,20 @@ async def test_superscript_converter(): await _check_conversion( defalut_converter, ["Let's test this converter!", "Unsupported characters stay the same: qCFQSXYZ"], - ["ᴸᵉᵗ'ˢ ᵗᵉˢᵗ ᵗʰⁱˢ ᶜᵒⁿᵛᵉʳᵗᵉʳ!", "ᵁⁿˢᵘᵖᵖᵒʳᵗᵉᵈ ᶜʰᵃʳᵃᶜᵗᵉʳˢ ˢᵗᵃʸ ᵗʰᵉ ˢᵃᵐᵉ: qCFQSXYZ"], + [ + "\u1d38\u1d49\u1d57'\u02e2 \u1d57\u1d49\u02e2\u1d57 \u1d57\u02b0\u2071\u02e2 " + "\u1d9c\u1d52\u207f\u1d5b\u1d49\u02b3\u1d57\u1d49\u02b3!", + "\u1d41\u207f\u02e2\u1d58\u1d56\u1d56\u1d52\u02b3\u1d57\u1d49\u1d48 " + "\u1d9c\u02b0\u1d43\u02b3\u1d43\u1d9c\u1d57\u1d49\u02b3\u02e2 " + "\u02e2\u1d57\u1d43\u02b8 \u1d57\u02b0\u1d49 \u02e2\u1d43\u1d50\u1d49: qCFQSXYZ", + ], ) alternate_converter = SuperscriptConverter(mode="alternate") await _check_conversion( alternate_converter, ["word1 word2 word3 word4 word5"], - ["ʷᵒʳᵈ¹ word2 ʷᵒʳᵈ³ word4 ʷᵒʳᵈ⁵"], + ["\u02b7\u1d52\u02b3\u1d48\u00b9 word2 \u02b7\u1d52\u02b3\u1d48\u00b3 word4 \u02b7\u1d52\u02b3\u1d48\u2075"], ) @@ -39,7 +45,10 @@ async def test_random_superscript_converter(): await _check_conversion( full_random_converter, ["Let's test random mode"], - ["ᴸᵉᵗ'ˢ ᵗᵉˢᵗ ʳᵃⁿᵈᵒᵐ ᵐᵒᵈᵉ"], + [ + "\u1d38\u1d49\u1d57'\u02e2 \u1d57\u1d49\u02e2\u1d57 " + "\u02b3\u1d43\u207f\u1d48\u1d52\u1d50 \u1d50\u1d52\u1d48\u1d49" + ], ) zero_random_converter = SuperscriptConverter(mode="random", random_percentage=0) await _check_conversion( @@ -51,7 +60,7 @@ async def test_random_superscript_converter(): random.seed(32) # with seed=32 and 6 words, words at [1,2,5] will be converted half_random_converter = SuperscriptConverter(mode="random", random_percentage=50) test_text = "one two three four five six" - expected_output = "ᵒⁿᵉ ᵗʷᵒ three four ᶠⁱᵛᵉ six" + expected_output = "\u1d52\u207f\u1d49 \u1d57\u02b7\u1d52 three four \u1da0\u2071\u1d5b\u1d49 six" result = await half_random_converter.convert_async(prompt=test_text, input_type="text") assert result.output_text == expected_output @@ -60,7 +69,11 @@ async def test_random_superscript_converter(): random.seed() twenty_percent_converter = SuperscriptConverter(mode="random", random_percentage=20) - long_text = "Prompt: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." + long_text = ( + "Prompt: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor " + "incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud " + "exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." + ) word_count = len(long_text.split()) assert word_count == 37 From 0a2336d5f6d34517247b061f47eee95773b393a5 Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Tue, 10 Jun 2025 19:21:45 +0200 Subject: [PATCH 16/20] Reset pyrit\common\utils.py to match origin/main --- pyrit/common/utils.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/pyrit/common/utils.py b/pyrit/common/utils.py index 3dad61c453..db7e382491 100644 --- a/pyrit/common/utils.py +++ b/pyrit/common/utils.py @@ -1,9 +1,13 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. - +import logging +import math +import random from typing import List, Optional, Union +logger = logging.getLogger(__name__) + def combine_dict(existing_dict: Optional[dict] = None, new_dict: Optional[dict] = None) -> dict: """ @@ -42,3 +46,34 @@ def combine_list(list1: Union[str, List[str]], list2: Union[str, List[str]]) -> # Merge and keep only unique values combined = list(set(list1 + list2)) return combined + + +def get_random_indices(*, start: int, size: int, proportion: float) -> List[int]: + """ + Generate a list of random indices based on the specified proportion of a given size. + The indices are selected from the range [start, start + size). + + Args: + start (int): Starting index (inclusive). It's the first index that could possibly be selected. + size (int): Size of the collection to select from. This is the total number of indices available. + For example, if `start` is 0 and `size` is 10, the available indices are [0, 1, 2, ..., 9]. + proportion (float): The proportion of indices to select from the total size. Must be between 0 and 1. + For example, if `proportion` is 0.5 and `size` is 10, 5 randomly selected indices will be returned. + + Returns: + List[int]: A list of randomly selected indices based on the specified proportion. + """ + if start < 0: + raise ValueError("Start index must be non-negative") + if size <= 0: + raise ValueError("Size must be greater than 0") + if proportion < 0 or proportion > 1: + raise ValueError("Proportion must be between 0 and 1") + + if proportion == 0: + return [] + if proportion == 1: + return list(range(start, start + size)) + + n = max(math.ceil(size * proportion), 1) # the number of indices to select + return random.sample(range(start, start + size), n) From ca6dfc42b181568b5ead9aaa88cb8f4fd4991e43 Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Tue, 10 Jun 2025 21:47:33 +0200 Subject: [PATCH 17/20] refactor: simplify SuperscriptConverter by extending WordLevelConverter --- .../prompt_converter/superscript_converter.py | 208 ++++++------------ .../converter/test_superscript_converter.py | 61 ----- 2 files changed, 73 insertions(+), 196 deletions(-) diff --git a/pyrit/prompt_converter/superscript_converter.py b/pyrit/prompt_converter/superscript_converter.py index c95debf7fd..8d62a2a7f2 100644 --- a/pyrit/prompt_converter/superscript_converter.py +++ b/pyrit/prompt_converter/superscript_converter.py @@ -1,145 +1,83 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from typing import Optional +from pyrit.prompt_converter.word_level_converter import WordLevelConverter -from pyrit.common.utils import get_random_indices -from pyrit.models import PromptDataType -from pyrit.prompt_converter import ConverterResult, PromptConverter - -class SuperscriptConverter(PromptConverter): +class SuperscriptConverter(WordLevelConverter): """ - Converts the input text to superscript text. Supports various modes for conversion. - - Supported modes: - - 'all': Converts all words. The default mode. - - 'alternate': Converts every other word. Configurable. - - 'random': Converts a random selection of words based on a percentage. + Converts the input text to superscript text. - Note: - This converter leaves characters that do not have a superscript equivalent unchanged. + Note: This converter leaves characters that do not have a superscript equivalent unchanged. """ - def __init__( - self, - mode: Optional[str] = "all", - alternate_step: Optional[int] = 2, - random_percentage: Optional[int] = 50, - ): - """ - Initialize the SuperscriptConverter. - - Args: - mode (Optional[str]): Conversion mode - 'all', or 'alternate'. Defaults to 'all'. - alternate_step (Optional[int]): For 'alternate' mode, convert every nth word. Defaults to 2. - random_percentage (Optional[int]): For 'random' mode, percentage of words to convert. Defaults to 50. - """ - self.mode = mode - self.alternate_step = alternate_step - self.random_percentage = random_percentage - self._superscript_map = { - "0": "\u2070", - "1": "\u00b9", - "2": "\u00b2", - "3": "\u00b3", - "4": "\u2074", - "5": "\u2075", - "6": "\u2076", - "7": "\u2077", - "8": "\u2078", - "9": "\u2079", - "a": "\u1d43", - "b": "\u1d47", - "c": "\u1d9c", - "d": "\u1d48", - "e": "\u1d49", - "f": "\u1da0", - "g": "\u1d4d", - "h": "\u02b0", - "i": "\u2071", - "j": "\u02b2", - "k": "\u1d4f", - "l": "\u02e1", - "m": "\u1d50", - "n": "\u207f", - "o": "\u1d52", - "p": "\u1d56", - "r": "\u02b3", - "s": "\u02e2", - "t": "\u1d57", - "u": "\u1d58", - "v": "\u1d5b", - "w": "\u02b7", - "x": "\u02e3", - "y": "\u02b8", - "z": "\u1dbb", - "A": "\u1d2c", - "B": "\u1d2d", - "D": "\u1d30", - "E": "\u1d31", - "G": "\u1d33", - "H": "\u1d34", - "I": "\u1d35", - "J": "\u1d36", - "K": "\u1d37", - "L": "\u1d38", - "M": "\u1d39", - "N": "\u1d3a", - "O": "\u1d3c", - "P": "\u1d3e", - "R": "\u1d3f", - "T": "\u1d40", - "U": "\u1d41", - "V": "\u2c7d", - "W": "\u1d42", - "+": "\u207a", - "-": "\u207b", - "=": "\u207c", - "(": "\u207d", - ")": "\u207e", - } - - def _to_superscript(self, text: str) -> str: - return "".join(self._superscript_map.get(char, char) for char in text) - - async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text") -> ConverterResult: - if not self.input_supported(input_type): - raise ValueError("Input type not supported") - - words = prompt.split() + _superscript_map = { + "0": "\u2070", + "1": "\u00b9", + "2": "\u00b2", + "3": "\u00b3", + "4": "\u2074", + "5": "\u2075", + "6": "\u2076", + "7": "\u2077", + "8": "\u2078", + "9": "\u2079", + "a": "\u1d43", + "b": "\u1d47", + "c": "\u1d9c", + "d": "\u1d48", + "e": "\u1d49", + "f": "\u1da0", + "g": "\u1d4d", + "h": "\u02b0", + "i": "\u2071", + "j": "\u02b2", + "k": "\u1d4f", + "l": "\u02e1", + "m": "\u1d50", + "n": "\u207f", + "o": "\u1d52", + "p": "\u1d56", + "r": "\u02b3", + "s": "\u02e2", + "t": "\u1d57", + "u": "\u1d58", + "v": "\u1d5b", + "w": "\u02b7", + "x": "\u02e3", + "y": "\u02b8", + "z": "\u1dbb", + "A": "\u1d2c", + "B": "\u1d2d", + "D": "\u1d30", + "E": "\u1d31", + "G": "\u1d33", + "H": "\u1d34", + "I": "\u1d35", + "J": "\u1d36", + "K": "\u1d37", + "L": "\u1d38", + "M": "\u1d39", + "N": "\u1d3a", + "O": "\u1d3c", + "P": "\u1d3e", + "R": "\u1d3f", + "T": "\u1d40", + "U": "\u1d41", + "V": "\u2c7d", + "W": "\u1d42", + "+": "\u207a", + "-": "\u207b", + "=": "\u207c", + "(": "\u207d", + ")": "\u207e", + } + + async def convert_word_async(self, word: str) -> str: result = [] - - if self.mode == "alternate": - # Convert every nth word - for i, word in enumerate(words): - if i % self.alternate_step == 0: - result.append(self._to_superscript(word)) - else: - result.append(word) - - elif self.mode == "random": - # Convert random words based on percentage - word_count = len(words) - random_indices = get_random_indices(0, word_count, self.random_percentage / 100.0) - for i, word in enumerate(words): - if i in random_indices: - result.append(self._to_superscript(word)) - else: - result.append(word) - - # TODO: add more modes here - - else: - # Convert every word if mode is not recognized or it's actually 'all' - for word in words: - result.append(self._to_superscript(word)) - - converted_text = " ".join(result) - return ConverterResult(output_text=converted_text, output_type="text") - - def input_supported(self, input_type: PromptDataType) -> bool: - return input_type == "text" - - def output_supported(self, output_type: PromptDataType) -> bool: - return output_type == "text" + for char in word: + if char in self._superscript_map: + result.append(self._superscript_map[char]) + else: + result.append(char) + return "".join(result) diff --git a/tests/unit/converter/test_superscript_converter.py b/tests/unit/converter/test_superscript_converter.py index 247cbcb49d..fae5db11f7 100644 --- a/tests/unit/converter/test_superscript_converter.py +++ b/tests/unit/converter/test_superscript_converter.py @@ -1,9 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -import math -import random - import pytest from pyrit.prompt_converter import ConverterResult, SuperscriptConverter @@ -30,61 +27,3 @@ async def test_superscript_converter(): "\u02e2\u1d57\u1d43\u02b8 \u1d57\u02b0\u1d49 \u02e2\u1d43\u1d50\u1d49: qCFQSXYZ", ], ) - - alternate_converter = SuperscriptConverter(mode="alternate") - await _check_conversion( - alternate_converter, - ["word1 word2 word3 word4 word5"], - ["\u02b7\u1d52\u02b3\u1d48\u00b9 word2 \u02b7\u1d52\u02b3\u1d48\u00b3 word4 \u02b7\u1d52\u02b3\u1d48\u2075"], - ) - - -@pytest.mark.asyncio -async def test_random_superscript_converter(): - full_random_converter = SuperscriptConverter(mode="random", random_percentage=100) - await _check_conversion( - full_random_converter, - ["Let's test random mode"], - [ - "\u1d38\u1d49\u1d57'\u02e2 \u1d57\u1d49\u02e2\u1d57 " - "\u02b3\u1d43\u207f\u1d48\u1d52\u1d50 \u1d50\u1d52\u1d48\u1d49" - ], - ) - zero_random_converter = SuperscriptConverter(mode="random", random_percentage=0) - await _check_conversion( - zero_random_converter, - ["Let's test random mode"], - ["Let's test random mode"], - ) - - random.seed(32) # with seed=32 and 6 words, words at [1,2,5] will be converted - half_random_converter = SuperscriptConverter(mode="random", random_percentage=50) - test_text = "one two three four five six" - expected_output = "\u1d52\u207f\u1d49 \u1d57\u02b7\u1d52 three four \u1da0\u2071\u1d5b\u1d49 six" - result = await half_random_converter.convert_async(prompt=test_text, input_type="text") - assert result.output_text == expected_output - - # Test with a longer text (37 words) and 20% conversion rate - - random.seed() - twenty_percent_converter = SuperscriptConverter(mode="random", random_percentage=20) - - long_text = ( - "Prompt: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor " - "incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud " - "exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." - ) - word_count = len(long_text.split()) - assert word_count == 37 - - result = await twenty_percent_converter.convert_async(prompt=long_text, input_type="text") - original_words = long_text.split() - converted_words = result.output_text.split() - assert len(converted_words) == len(original_words) - - # Count words that were actually converted - converted_count = sum(1 for original, converted in zip(original_words, converted_words) if original != converted) - - # With 37 words and 20%, math.ceil(37 * 0.2) = 8 words should be converted - expected_conversion_count = math.ceil(word_count * 0.2) - assert converted_count == expected_conversion_count From 8bd294a44a771221bf8a7a000d9030ece84c155f Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Tue, 10 Jun 2025 21:50:12 +0200 Subject: [PATCH 18/20] add SuperscriptConverter to api.rst --- doc/api.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/api.rst b/doc/api.rst index cb3cd5df4f..5a10c6498f 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -321,6 +321,7 @@ API Reference SearchReplaceConverter StringJoinConverter SuffixAppendConverter + SuperscriptConverter TenseConverter TextToHexConverter ToneConverter From e64486b0701f0e24f0b120f9bbedab365d4eb72a Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Tue, 10 Jun 2025 22:05:28 +0200 Subject: [PATCH 19/20] update docstring --- pyrit/prompt_converter/superscript_converter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyrit/prompt_converter/superscript_converter.py b/pyrit/prompt_converter/superscript_converter.py index 8d62a2a7f2..da35fe78e2 100644 --- a/pyrit/prompt_converter/superscript_converter.py +++ b/pyrit/prompt_converter/superscript_converter.py @@ -6,7 +6,7 @@ class SuperscriptConverter(WordLevelConverter): """ - Converts the input text to superscript text. + Converts text to superscript. Note: This converter leaves characters that do not have a superscript equivalent unchanged. """ From 4619b11995862d519915929b1866161963403c07 Mon Sep 17 00:00:00 2001 From: Paulina Kalicka <71526180+paulinek13@users.noreply.github.com> Date: Tue, 10 Jun 2025 22:07:22 +0200 Subject: [PATCH 20/20] improve docstring formatting --- pyrit/prompt_converter/superscript_converter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyrit/prompt_converter/superscript_converter.py b/pyrit/prompt_converter/superscript_converter.py index da35fe78e2..7d76520267 100644 --- a/pyrit/prompt_converter/superscript_converter.py +++ b/pyrit/prompt_converter/superscript_converter.py @@ -8,7 +8,8 @@ class SuperscriptConverter(WordLevelConverter): """ Converts text to superscript. - Note: This converter leaves characters that do not have a superscript equivalent unchanged. + Note: + This converter leaves characters that do not have a superscript equivalent unchanged. """ _superscript_map = {