Skip to content
Merged
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
53 changes: 26 additions & 27 deletions pyrit/converter/nato_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@
# Licensed under the MIT license.


from pyrit.converter.converter import Converter, ConverterResult
from pyrit.models import PromptDataType
from pyrit.converter.word_level_converter import WordLevelConverter


class NatoConverter(Converter):
class NatoConverter(WordLevelConverter):
"""
Converts text into NATO phonetic alphabet representation.

This converter transforms standard text into NATO phonetic alphabet format,
where each letter is replaced with its corresponding NATO phonetic code word
(e.g., "A" becomes "Alfa", "B" becomes "Bravo"). Only alphabetic characters
are converted; non-alphabetic characters are ignored.
where each ASCII letter is replaced with its corresponding NATO phonetic code
word (e.g., "A" becomes "Alfa", "B" becomes "Bravo"). Characters outside the
ASCII alphabet are preserved with their original casing. Spaces are represented
by ``<space>`` so word boundaries remain distinct from code-word separators.

The NATO phonetic alphabet is the most widely used spelling alphabet, designed
to improve clarity of voice communication. This converter can be used to test
Expand All @@ -23,13 +23,15 @@ class NatoConverter(Converter):
Reference: https://en.wikipedia.org/wiki/NATO_phonetic_alphabet

Example:
Input: "Hello"
Output: "Hotel Echo Lima Lima Oscar"
Input: "Hello world"
Output: "Hotel Echo Lima Lima Oscar <space> Whiskey Oscar Romeo Lima Delta"
"""

SUPPORTED_INPUT_TYPES = ("text",)
SUPPORTED_OUTPUT_TYPES = ("text",)

_WORD_SEPARATOR = "<space>"

_NATO_MAP = {
"A": "Alfa",
"B": "Bravo",
Expand Down Expand Up @@ -59,37 +61,34 @@ class NatoConverter(Converter):
"Z": "Zulu",
}

async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text") -> ConverterResult:
async def convert_word_async(self, word: str) -> str:
"""
Convert the given text into NATO phonetic alphabet representation.
Convert one word into NATO phonetic alphabet representation.

Args:
prompt (str): The text to be converted to NATO phonetic alphabet.
input_type (PromptDataType, optional): Type of input data. Defaults to "text".
word (str): The word to convert.

Returns:
ConverterResult: The text converted to NATO phonetic alphabet format.

Raises:
ValueError: If the input type is not supported (only "text" is supported).
str: The converted word, with code words separated by spaces.
"""
if not self.input_supported(input_type):
raise ValueError("Input type not supported")

nato_text = self._convert_to_nato(prompt)

return ConverterResult(output_text=nato_text, output_type="text")
output = [self._NATO_MAP.get(char.upper(), char) if char.isascii() else char for char in word]
return " ".join(output)

def _convert_to_nato(self, text: str) -> str:
def join_words(self, words: list[str]) -> str:
"""
Convert text to NATO phonetic alphabet representation.
Join converted words while preserving every source-space boundary.

Args:
text (str): The text to convert.
words (list[str]): The converted words.

Returns:
str: The NATO phonetic alphabet representation, with code words separated by spaces.
str: The converted words separated by explicit space tokens.
"""
output = [self._NATO_MAP[char] for char in text.upper() if char in self._NATO_MAP]
output: list[str] = []
for index, word in enumerate(words):
if index:
output.append(self._WORD_SEPARATOR)
if word:
output.append(word)

return " ".join(output)
56 changes: 44 additions & 12 deletions tests/unit/converter/test_nato_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pytest

from pyrit.converter import ConverterResult, NatoConverter
from pyrit.converter.text_selection_strategy import WordIndexSelectionStrategy


async def test_nato_converter_simple_text():
Expand Down Expand Up @@ -43,42 +44,41 @@ async def test_nato_converter_mixed_case():


async def test_nato_converter_with_numbers():
"""Test that numbers are ignored in NATO conversion."""
"""Test that numbers are preserved in NATO conversion."""
converter = NatoConverter()
prompt = "a1b2c3"

result = await converter.convert_async(prompt=prompt, input_type="text")

assert isinstance(result, ConverterResult)
assert result.output_type == "text"
# Only letters should be converted
assert result.output_text == "Alfa Bravo Charlie"
# Digits are preserved as-is so the encoded prompt keeps its full content
assert result.output_text == "Alfa 1 Bravo 2 Charlie 3"


async def test_nato_converter_with_spaces():
"""Test that spaces are ignored in NATO conversion."""
"""Test that word boundaries remain distinct from code-word separators."""
converter = NatoConverter()
prompt = "a b c"

result = await converter.convert_async(prompt=prompt, input_type="text")

assert isinstance(result, ConverterResult)
assert result.output_type == "text"
# Spaces should be ignored
assert result.output_text == "Alfa Bravo Charlie"
assert result.output_text == "Alfa <space> Bravo <space> Charlie"


async def test_nato_converter_with_punctuation():
"""Test that punctuation is ignored in NATO conversion."""
"""Test that punctuation is preserved in NATO conversion."""
converter = NatoConverter()
prompt = "Hello, world!"

result = await converter.convert_async(prompt=prompt, input_type="text")

assert isinstance(result, ConverterResult)
assert result.output_type == "text"
# Only letters should be converted
assert result.output_text == "Hotel Echo Lima Lima Oscar Whiskey Oscar Romeo Lima Delta"
# Punctuation is preserved as-is so the encoded prompt keeps its full content
assert result.output_text == "Hotel Echo Lima Lima Oscar , <space> Whiskey Oscar Romeo Lima Delta !"


async def test_nato_converter_empty_string():
Expand All @@ -94,15 +94,47 @@ async def test_nato_converter_empty_string():


async def test_nato_converter_no_letters():
"""Test NATO conversion with no alphabetic characters."""
"""Test NATO conversion with no alphabetic characters.

Regression: non-empty input must never convert to an empty prompt
(digits/punctuation are preserved rather than erased).
"""
converter = NatoConverter()
prompt = "123!@#"

result = await converter.convert_async(prompt=prompt, input_type="text")

assert isinstance(result, ConverterResult)
assert result.output_type == "text"
assert result.output_text == ""
assert result.output_text == "1 2 3 ! @ #"


async def test_nato_converter_space_only_prompt():
"""Test that a non-empty whitespace prompt remains non-empty."""
converter = NatoConverter()

result = await converter.convert_async(prompt=" ", input_type="text")

assert result.output_text == "<space> <space> <space>"


@pytest.mark.parametrize("prompt", ["é", "ß", "ı", "ñ"])
async def test_nato_converter_preserves_unmapped_unicode(prompt: str):
"""Test that Unicode characters are preserved without case conversion."""
converter = NatoConverter()

result = await converter.convert_async(prompt=prompt, input_type="text")

assert result.output_text == prompt


async def test_nato_converter_word_selection_strategy():
"""Test that NATO conversion supports the shared word selection strategies."""
converter = NatoConverter(word_selection_strategy=WordIndexSelectionStrategy(indices=[1]))

result = await converter.convert_async(prompt="abc def", input_type="text")

assert result.output_text == "abc <space> Delta Echo Foxtrot"


async def test_nato_converter_all_letters():
Expand All @@ -126,7 +158,7 @@ async def test_nato_converter_input_type_not_supported():
"""Test that non-text input types raise ValueError."""
converter = NatoConverter()

with pytest.raises(ValueError, match="Input type not supported"):
with pytest.raises(ValueError, match="Input type image_path not supported"):
await converter.convert_async(prompt="test", input_type="image_path")


Expand Down
Loading