Skip to content

[DRAFT] FEAT: add superscript converter #818

New issue

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

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

Already on GitHub? Sign in to your account

Draft
wants to merge 16 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion pyrit/common/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.


import logging
import math
import random
from typing import List, Union


Expand Down Expand Up @@ -42,3 +44,30 @@ 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(low: int, high: int, sample_ratio: float) -> list[int]:
"""
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).
sample_ratio: Ratio of range to sample (0.0 to 1.0).
"""
# Special case: return empty list
if sample_ratio == 0:
return []

result = []
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:
logging.getLogger(__name__).debug(f"Sample size of {n} exceeds population size of {high - low}")
return result
2 changes: 2 additions & 0 deletions pyrit/prompt_converter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -102,6 +103,7 @@
"SearchReplaceConverter",
"StringJoinConverter",
"SuffixAppendConverter",
"SuperscriptConverter",
"TextToHexConverter",
"TenseConverter",
"ToneConverter",
Expand Down
22 changes: 2 additions & 20 deletions pyrit/prompt_converter/charswap_attack_converter.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
# 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_random_indices
from pyrit.models import PromptDataType
from pyrit.prompt_converter import ConverterResult, PromptConverter

# Use logger
logger = logging.getLogger(__name__)


class CharSwapGenerator(PromptConverter):
"""
Expand Down Expand Up @@ -79,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 = self._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:
Expand All @@ -99,15 +93,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
145 changes: 145 additions & 0 deletions pyrit/prompt_converter/superscript_converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

from typing import Optional

from pyrit.common.utils import get_random_indices
from pyrit.models import PromptDataType
from pyrit.prompt_converter import ConverterResult, PromptConverter


class SuperscriptConverter(PromptConverter):
"""
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.

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()
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"
90 changes: 90 additions & 0 deletions tests/unit/converter/test_superscript_converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

import math
import random

import pytest

from pyrit.prompt_converter import ConverterResult, SuperscriptConverter


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"],
[
"\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"],
["\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