Skip to content

Commit 93bf08e

Browse files
adimalkarromanlutzCopilot
authored
FIX: let the PUZZLED keyword masker select words with non-ASCII letters (#3051)
Co-authored-by: adimalkar <adimalkar@users.noreply.github.com> Co-authored-by: Roman Lutz <romanlutz13@gmail.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 7e7e2e8 commit 93bf08e

5 files changed

Lines changed: 416 additions & 30 deletions

File tree

‎pyrit/converter/puzzled/keyword_masker.py‎

Lines changed: 93 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,71 @@
1717
import logging
1818
import re
1919
import threading
20+
import unicodedata
21+
from collections.abc import Iterator
2022
from dataclasses import dataclass
2123
from typing import Any
2224

2325
logger = logging.getLogger(__name__)
2426

25-
# The one tokenizer used for both candidate selection and placeholder ordering. Requiring
26-
# word boundaries keeps candidates to runs of letters that can actually be matched again
27-
# when masking, so a token embedded in an alphanumeric blob (the "h" of "h4ck3r") is never
28-
# selected and then silently dropped.
29-
_WORD_PATTERN = re.compile(r"\b[A-Za-z]+\b")
27+
# The one tokenizer used for candidate selection, placeholder ordering and replacement
28+
# (always through ``_iter_words``). Requiring word boundaries keeps candidates to whole
29+
# words, so a token embedded in an alphanumeric blob (the "h" of "h4ck3r") is never
30+
# selected. ``[^\W\d_]`` admits Unicode letters, so words such as "Schädlinge" or Cyrillic
31+
# words are candidates too; ``_iter_words`` then drops matches that are not all letters.
32+
_WORD_PATTERN = re.compile(r"\b[^\W\d_]+\b")
33+
34+
35+
def _is_word_continuation(char: str) -> bool:
36+
return char in ("\u200c", "\u200d") or unicodedata.category(char).startswith("M")
37+
38+
39+
def _word_key(word: str) -> str:
40+
"""
41+
Return the case-insensitive key that identifies a word.
42+
43+
Selection, deduplication, ordering and replacement all compare words through this key,
44+
so two spellings are either the same masked word everywhere or different words
45+
everywhere. For ASCII it equals ``str.lower``.
46+
47+
Args:
48+
word (str): A word from the prompt or from the caller.
49+
50+
Returns:
51+
str: The case-folded word.
52+
"""
53+
return word.casefold()
54+
55+
56+
def _iter_words(prompt: str) -> Iterator[re.Match[str]]:
57+
"""
58+
Yield the maskable words of ``prompt``.
59+
60+
``[^\\W\\d_]`` still admits characters that are numeric without being decimal digits,
61+
such as "²" or "¼", so a match is kept only if every character is a letter. "abc²" is
62+
therefore rejected whole, like "h4ck3r", rather than becoming a candidate.
63+
64+
``\\w`` does not cover combining marks (Devanagari vowel signs, Hebrew and Arabic vowel
65+
points, or the accents of NFD-decomposed text) or zero-width joiners and non-joiners.
66+
These characters can continue a word even though ``\\b`` treats them as boundaries.
67+
A match that touches one of them is only a fragment, so it is skipped as well.
68+
69+
Args:
70+
prompt (str): The prompt to scan.
71+
72+
Yields:
73+
re.Match[str]: Each whole-word match, in order of appearance.
74+
"""
75+
for match in _WORD_PATTERN.finditer(prompt):
76+
if not match.group().isalpha():
77+
continue
78+
start, end = match.span()
79+
if start > 0 and _is_word_continuation(prompt[start - 1]):
80+
continue
81+
if end < len(prompt) and _is_word_continuation(prompt[end]):
82+
continue
83+
yield match
84+
3085

3186
# Part-of-speech label used when spaCy is unavailable or the word was caller-supplied.
3287
_GENERIC_POS = "word"
@@ -170,15 +225,15 @@ def _get_nlp() -> Any:
170225

171226
def _pos_lookup(prompt: str) -> dict[str, str]:
172227
"""
173-
Map each alphabetic noun/verb (lowercased) in the prompt to its clue label.
228+
Map each alphabetic noun/verb in the prompt (by ``_word_key``) to its clue label.
174229
175230
Returns an empty mapping when spaCy is unavailable.
176231
177232
Args:
178233
prompt (str): The prompt to tag.
179234
180235
Returns:
181-
dict[str, str]: Lowercased word to part-of-speech label ("noun" or "verb").
236+
dict[str, str]: Word key to part-of-speech label ("noun" or "verb").
182237
"""
183238
nlp = _get_nlp()
184239
if nlp is None:
@@ -187,7 +242,7 @@ def _pos_lookup(prompt: str) -> dict[str, str]:
187242
for token in nlp(prompt):
188243
if token.is_alpha and token.pos_ in _MASKABLE_POS:
189244
# Keep the first tag seen for a given surface form.
190-
lookup.setdefault(token.text.lower(), _MASKABLE_POS[token.pos_])
245+
lookup.setdefault(_word_key(token.text), _MASKABLE_POS[token.pos_])
191246
return lookup
192247

193248

@@ -214,20 +269,20 @@ def _rank_candidates(
214269
Returns:
215270
list[str]: Distinct candidate words (as they appear in the prompt) in priority order.
216271
"""
217-
tokens = _WORD_PATTERN.findall(prompt)
272+
tokens = [match.group() for match in _iter_words(prompt)]
218273
seen: set[str] = set()
219274
unique: list[str] = []
220275
for token in tokens:
221-
key = token.lower()
276+
key = _word_key(token)
222277
if key not in seen:
223278
seen.add(key)
224279
unique.append(token)
225280

226-
essential_lower = {w.lower() for w in (essential_words or [])}
281+
essential_keys = {_word_key(w) for w in (essential_words or [])}
227282

228283
def tier(word: str) -> int:
229-
key = word.lower()
230-
if key in essential_lower:
284+
key = _word_key(word)
285+
if key in essential_keys:
231286
return 0
232287
if key in _ESSENTIAL_HARM_WORDS:
233288
return 1
@@ -237,7 +292,7 @@ def tier(word: str) -> int:
237292
return 3
238293
return 4
239294

240-
return sorted(unique, key=lambda w: (tier(w), -len(w), w.lower()))
295+
return sorted(unique, key=lambda w: (tier(w), -len(w), _word_key(w)))
241296

242297

243298
def mask_prompt(
@@ -278,25 +333,34 @@ def mask_prompt(
278333
return MaskResult(masked_prompt=prompt, masked_words=[])
279334

280335
# Number placeholders by where each chosen word first appears (case-insensitively), so
281-
# [WORD1] is the leftmost masked word. Candidates come from the same word-boundary scan,
282-
# so every chosen word has an entry here.
336+
# [WORD1] is the leftmost masked word. Candidates come from the same scan, so every
337+
# chosen word has an entry here.
283338
first_index: dict[str, int] = {}
284-
for match in _WORD_PATTERN.finditer(prompt):
285-
first_index.setdefault(match.group().lower(), match.start())
286-
ordered = sorted(chosen, key=lambda w: first_index[w.lower()])
339+
for match in _iter_words(prompt):
340+
first_index.setdefault(_word_key(match.group()), match.start())
341+
ordered = sorted(chosen, key=lambda w: first_index[_word_key(w)])
287342

288343
masked_words: list[MaskedWord] = []
289344
placeholders: dict[str, str] = {}
290345
for index, word in enumerate(ordered, start=1):
291346
placeholder = f"[WORD{index}]"
292-
pos = pos_lookup.get(word.lower(), _GENERIC_POS)
347+
pos = pos_lookup.get(_word_key(word), _GENERIC_POS)
293348
masked_words.append(MaskedWord(text=word, placeholder=placeholder, pos=pos))
294-
placeholders[word.lower()] = placeholder
295-
296-
# Replace every occurrence of every chosen word in one case-insensitive pass, so a word
297-
# that recurs or appears in different casing is never left in cleartext, and a placeholder
298-
# already inserted cannot be re-matched while masking the next word.
299-
pattern = re.compile(r"\b(" + "|".join(re.escape(w) for w in ordered) + r")\b", re.IGNORECASE)
300-
masked_prompt = pattern.sub(lambda m: placeholders[m.group(0).lower()], prompt)
301-
302-
return MaskResult(masked_prompt=masked_prompt, masked_words=masked_words)
349+
placeholders[_word_key(word)] = placeholder
350+
351+
# Replace the word spans of the same scan, looked up by the same key. Every occurrence of
352+
# a chosen word is masked whatever its casing, a fragment the scan skipped (next to a
353+
# combining mark or joiner) is never masked, and every replaced span has a placeholder.
354+
# Building the result in one pass means an inserted placeholder can never be matched again.
355+
pieces: list[str] = []
356+
last_end = 0
357+
for match in _iter_words(prompt):
358+
placeholder = placeholders.get(_word_key(match.group()))
359+
if placeholder is None:
360+
continue
361+
pieces.append(prompt[last_end : match.start()])
362+
pieces.append(placeholder)
363+
last_end = match.end()
364+
pieces.append(prompt[last_end:])
365+
366+
return MaskResult(masked_prompt="".join(pieces), masked_words=masked_words)

‎pyrit/converter/puzzled/puzzle_builders.py‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,32 @@ def label(self) -> str:
5656
return self.value.replace("_", " ")
5757

5858

59+
def _upper_same_length(word: str) -> str:
60+
"""
61+
Uppercase ``word`` without changing its length or case-folded spelling.
62+
63+
``str.upper`` expands some letters ("ß" becomes "SS", "fi" becomes "FI"), which would make
64+
an encoded word longer than its clue. It also changes the case-folded spelling of dotless
65+
"ı" to "i", merging distinct words. Keep a character unchanged when either would happen.
66+
67+
Args:
68+
word (str): The word to uppercase.
69+
70+
Returns:
71+
str: The uppercased word, preserving its length and case-folded spelling.
72+
"""
73+
return "".join(
74+
upper if len(upper := char.upper()) == 1 and upper.casefold() == char.casefold() else char for char in word
75+
)
76+
77+
5978
def _normalize(words: list[str]) -> list[str]:
6079
"""
6180
Uppercase the words and strip surrounding whitespace.
6281
82+
Uppercasing keeps each word's length and case-folded spelling (see ``_upper_same_length``),
83+
so every puzzle agrees with both the masker's length clue and its word identity.
84+
6385
Args:
6486
words (list[str]): The masked keywords to encode.
6587
@@ -71,7 +93,7 @@ def _normalize(words: list[str]) -> list[str]:
7193
"""
7294
if not words:
7395
raise ValueError("At least one word is required to build a puzzle.")
74-
cleaned = [w.strip().upper() for w in words]
96+
cleaned = [_upper_same_length(w.strip()) for w in words]
7597
if any(not w for w in cleaned):
7698
raise ValueError("Words must contain at least one non-whitespace character.")
7799
return cleaned

‎tests/unit/converter/test_puzzled_converter.py‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,114 @@ async def test_crossword_falls_back_to_anagram_when_it_cannot_hide_the_word():
163163
assert "malware" not in result.output_text.lower()
164164

165165

166+
# --- non-ASCII words end to end ----------------------------------------------
167+
168+
169+
def _section(output: str, start: str, end: str) -> str:
170+
return output.split(start, 1)[1].split(end, 1)[0].strip()
171+
172+
173+
def _clue_lengths(output: str) -> list[int]:
174+
clues = _section(output, "Clues (one per hidden word, giving its length and part of speech):", "Follow these")
175+
return [int(line.split(" = ", 1)[1].split("-letter", 1)[0]) for line in clues.splitlines()]
176+
177+
178+
def _grid_contains(grid: list[list[str]], word: str) -> bool:
179+
# Try every start cell in each of the eight directions a word search may use.
180+
size = len(grid)
181+
directions = [(0, 1), (0, -1), (1, 0), (-1, 0), (1, 1), (1, -1), (-1, 1), (-1, -1)]
182+
for row in range(size):
183+
for col in range(size):
184+
for row_delta, col_delta in directions:
185+
cells = [(row + i * row_delta, col + i * col_delta) for i in range(len(word))]
186+
if all(0 <= r < size and 0 <= c < size for r, c in cells) and all(
187+
grid[r][c] == letter for (r, c), letter in zip(cells, word, strict=True)
188+
):
189+
return True
190+
return False
191+
192+
193+
async def test_anagram_encodes_exactly_the_letters_the_clues_promise():
194+
# "ß" uppercases to "SS"; encoding it that way would put 12 letters behind clues for 6 + 5.
195+
result = await PuzzledConverter(puzzle_type="anagram", num_to_mask=2, seed=42).convert_async(prompt="straße tasse")
196+
puzzle = _section(result.output_text, "Puzzle:", "Clues")
197+
assert sorted(_clue_lengths(result.output_text)) == [5, 6]
198+
assert len(puzzle) == 11
199+
assert sorted(puzzle) == sorted("STRAßETASSE")
200+
201+
202+
async def test_crossword_lines_match_their_length_clues():
203+
result = await PuzzledConverter(puzzle_type="crossword", num_to_mask=2, seed=42).convert_async(
204+
prompt="straße tasse"
205+
)
206+
lines = _section(result.output_text, "Puzzle:", "Clues").splitlines()
207+
assert [len(line.split(". ", 1)[1]) for line in lines] == _clue_lengths(result.output_text)
208+
209+
210+
async def test_word_search_hides_words_at_their_clued_length():
211+
result = await PuzzledConverter(puzzle_type="word_search", num_to_mask=2, seed=42).convert_async(
212+
prompt="straße tasse"
213+
)
214+
grid = [row.split(" ") for row in _section(result.output_text, "Puzzle:", "Clues").splitlines()]
215+
assert sorted(_clue_lengths(result.output_text)) == [5, 6]
216+
# Each word occupies exactly as many cells as its clue says, keeping its "ß".
217+
assert _grid_contains(grid, "STRAßE")
218+
assert _grid_contains(grid, "TASSE")
219+
assert not _grid_contains(grid, "STRASSE")
220+
221+
222+
@pytest.mark.parametrize("puzzle_type", ["word_search", "anagram", "crossword"])
223+
@pytest.mark.parametrize(
224+
"prompt, instruction",
225+
[
226+
# Unicode case variants of one word share a placeholder.
227+
("ÉCOLE école", "[WORD1] [WORD1]"),
228+
# Dotted "I" and dotless "ı" fold to different words, so with one mask the other stays.
229+
("KIŞ kış", "[WORD1] kış"),
230+
],
231+
)
232+
async def test_unicode_case_variants_convert_for_every_puzzle_type(puzzle_type, prompt, instruction):
233+
result = await PuzzledConverter(puzzle_type=puzzle_type, num_to_mask=1, seed=0).convert_async(prompt=prompt)
234+
assert _section(result.output_text, "Instruction with hidden words:", "The hidden words are") == instruction
235+
236+
237+
@pytest.mark.parametrize("puzzle_type", ["word_search", "anagram", "crossword"])
238+
@pytest.mark.parametrize("joiner", ["\u200c", "\u200d"])
239+
async def test_joined_words_are_never_partially_masked_async(*, puzzle_type: str, joiner: str) -> None:
240+
joined_word = f"अक्{joiner}षर"
241+
converter = PuzzledConverter(puzzle_type=puzzle_type, num_to_mask=3, seed=42)
242+
with pytest.raises(ValueError, match="no maskable words"):
243+
await converter.convert_async(prompt=joined_word)
244+
245+
result = await converter.convert_async(prompt=f"garden {joined_word} now")
246+
instruction = _section(result.output_text, "Instruction with hidden words:", "The hidden words are")
247+
assert instruction == f"[WORD1] {joined_word} [WORD2]"
248+
assert _clue_lengths(result.output_text) == [6, 3]
249+
250+
251+
@pytest.mark.parametrize("puzzle_type", ["word_search", "anagram", "crossword"])
252+
@pytest.mark.parametrize("words", [("ılık", "ilik"), ("ilik", "ılık")])
253+
async def test_puzzles_preserve_distinct_case_folded_words_async(*, puzzle_type: str, words: tuple[str, str]) -> None:
254+
result = await PuzzledConverter(puzzle_type=puzzle_type, num_to_mask=2, seed=42).convert_async(
255+
prompt=" ".join(words)
256+
)
257+
instruction = _section(result.output_text, "Instruction with hidden words:", "The hidden words are")
258+
assert instruction == "[WORD1] [WORD2]"
259+
assert _clue_lengths(result.output_text) == [4, 4]
260+
puzzle = _section(result.output_text, "Puzzle:", "Clues")
261+
if puzzle_type == "word_search":
262+
grid = [row.split(" ") for row in puzzle.splitlines()]
263+
assert _grid_contains(grid, "ıLıK")
264+
assert _grid_contains(grid, "ILIK")
265+
elif puzzle_type == "anagram":
266+
assert sorted(puzzle) == sorted("ıLıKILIK")
267+
else:
268+
encoded_words = [line.split(". ", 1)[1] for line in puzzle.splitlines()]
269+
assert encoded_words[0] != encoded_words[1]
270+
for word, encoded in zip(words, encoded_words, strict=True):
271+
assert ("ı" in encoded) == ("ı" in word)
272+
273+
166274
# --- optional LLM semantic clues -------------------------------------------
167275

168276

0 commit comments

Comments
 (0)