Skip to content

Commit f39c516

Browse files
committed
FIX: match non-ASCII values in SQLite seed filters
1 parent 3f729ac commit f39c516

6 files changed

Lines changed: 94 additions & 4 deletions

File tree

‎pyrit/memory/memory_interface.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4000,7 +4000,8 @@ def _add_list_conditions(
40004000
)
40014001
)
40024002
else:
4003-
conditions.extend(field.contains(value) for value in values)
4003+
# The column holds JSON text with non-ASCII characters escaped, so search for the value as JSON spells it.
4004+
conditions.extend(field.contains(json.dumps(value)[1:-1]) for value in values)
40044005

40054006
async def _serialize_seed_value_async(self, prompt: Seed) -> str:
40064007
"""

‎pyrit/memory/sqlite_memory.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ def _unicode_lower(value: str | int | float | bytes | None) -> str | None:
273273

274274
@staticmethod
275275
def _register_analytics_lower(*, engine: Engine) -> None:
276-
"""Install the analytics-only Unicode function on every pooled SQLite connection."""
276+
"""Install the Unicode lowercase function for analytics and JSON filters on every pooled SQLite connection."""
277277

278278
@event.listens_for(engine, "connect")
279279
def register(dbapi_connection: Any, connection_record: Any) -> None:
@@ -447,7 +447,7 @@ def _get_condition_json_property_match(
447447
if case_sensitive:
448448
extracted_value, target = raw, value
449449
else:
450-
extracted_value, target = func.lower(raw), value.lower()
450+
extracted_value, target = UnicodeLower(raw), value.lower()
451451

452452
if partial_match:
453453
escaped = target.replace("%", "\\%").replace("_", "\\_")
@@ -494,7 +494,9 @@ def _get_condition_json_array_match(
494494
column_name = json_column.key
495495
pp_param = f"property_path_{uid}"
496496
sp_param = f"array_element_path_{uid}"
497-
value_expression = f"LOWER(json_extract(value, :{sp_param}))" if array_element_path else "LOWER(value)"
497+
element = f"json_extract(value, :{sp_param})" if array_element_path else "value"
498+
# SQLite's LOWER folds ASCII only; match the Unicode folding applied to the values below.
499+
value_expression = f"{UnicodeLower.SQLITE_FUNCTION_NAME}({element})"
498500

499501
conditions = []
500502
bindparams_dict: dict[str, str] = {pp_param: property_path}

‎tests/unit/memory/memory_interface/test_interface_remove_seeds.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,21 @@ async def test_remove_seeds_by_list_filters_is_case_insensitive(sqlite_instance:
275275
assert await sqlite_instance.remove_seeds_from_memory_async(harm_categories=["violence"]) == 1
276276

277277

278+
async def test_remove_seeds_by_list_filters_matches_non_ascii_values(sqlite_instance: MemoryInterface):
279+
seed_prompts = [
280+
SeedPrompt(value="p1", harm_categories=["ÉTHIQUE"], data_type="text"),
281+
SeedPrompt(value="p2", harm_categories=["Überwachung"], data_type="text"),
282+
SeedPrompt(value="p3", harm_categories=["fraud"], data_type="text"),
283+
]
284+
await sqlite_instance.add_seeds_to_memory_async(seeds=seed_prompts, added_by="test")
285+
286+
assert await sqlite_instance.remove_seeds_from_memory_async(harm_categories=["ÉTHIQUE"]) == 1
287+
assert await sqlite_instance.remove_seeds_from_memory_async(harm_categories=["überwachung"]) == 1
288+
289+
remaining = await sqlite_instance.get_seeds_async()
290+
assert [seed.value for seed in remaining] == ["p3"]
291+
292+
278293
async def test_remove_seeds_by_groups_authors_parameters_match_whole_elements(sqlite_instance: MemoryInterface):
279294
seed_prompts = [
280295
SeedPrompt(value="p1", groups=["team"], authors=["Ann"], parameters=["goal"], data_type="text"),

‎tests/unit/memory/memory_interface/test_interface_seed_examples.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,19 @@ async def test_get_seed_examples_filters_match_across_members(sqlite_instance: M
148148
assert await _ids(sqlite_instance, seed_types=["objective"], data_types=["image_path"]) == []
149149

150150

151+
async def test_get_seed_examples_harm_filter_folds_non_ascii_case(sqlite_instance: MemoryInterface):
152+
ethics, surveillance = uuid4(), uuid4()
153+
await _add(
154+
sqlite_instance,
155+
SeedPrompt(value="a", dataset_name=DATASET, prompt_group_id=ethics, harm_categories=["ÉTHIQUE"]),
156+
SeedPrompt(value="b", dataset_name=DATASET, prompt_group_id=surveillance, harm_categories=["überwachung"]),
157+
)
158+
159+
assert await _ids(sqlite_instance, harm_categories=["ÉTHIQUE"]) == [ethics]
160+
assert await _ids(sqlite_instance, harm_categories=["éthique"]) == [ethics]
161+
assert await _ids(sqlite_instance, harm_categories=["Überwachung"]) == [surveillance]
162+
163+
151164
async def test_get_seed_examples_keeps_named_and_unnamed_scopes_apart(sqlite_instance: MemoryInterface):
152165
group = uuid4()
153166
named = SeedPrompt(value="named", dataset_name=DATASET, prompt_group_id=group)

‎tests/unit/memory/memory_interface/test_interface_seed_prompts.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,31 @@ async def test_get_seeds_with_substring_filters_harm_categories(sqlite_instance:
522522
assert result[1].authors == ["author2"]
523523

524524

525+
@pytest.mark.parametrize(
526+
"filters, expected",
527+
[
528+
({"authors": ["Frédéric Dubut"]}, ["p1"]),
529+
({"authors": ["Frédéric"]}, ["p1"]),
530+
({"harm_categories": ["ÉTHIQUE"]}, ["p1"]),
531+
({"harm_categories": ["Überwachung"]}, ["p2"]),
532+
({"authors": ['"AJ"']}, ["p3"]),
533+
({"authors": ["C:\\red"]}, ["p3"]),
534+
],
535+
)
536+
async def test_get_seeds_with_list_filters_matches_non_ascii_values(
537+
sqlite_instance: MemoryInterface, filters: dict[str, list[str]], expected: list[str]
538+
):
539+
seed_prompts = [
540+
SeedPrompt(value="p1", harm_categories=["ÉTHIQUE"], authors=["Frédéric Dubut"], data_type="text"),
541+
SeedPrompt(value="p2", harm_categories=["Überwachung"], authors=["Frederic Smith"], data_type="text"),
542+
SeedPrompt(value="p3", authors=['Ann "AJ" Jones', "C:\\red-team"], data_type="text"),
543+
]
544+
await sqlite_instance.add_seeds_to_memory_async(seeds=seed_prompts, added_by="test")
545+
546+
result = await sqlite_instance.get_seeds_async(**filters)
547+
assert [seed.value for seed in result] == expected
548+
549+
525550
async def test_get_seeds_with_substring_filters_groups(sqlite_instance: MemoryInterface):
526551
seed_prompts = [
527552
SeedPrompt(value="prompt1", groups=["group1"], data_type="text"),

‎tests/unit/memory/test_sqlite_memory.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1248,3 +1248,37 @@ async def test_get_message_pieces_filters_on_integer_prompt_metadata_async(sqlit
12481248

12491249
assert len(retrieved) == 1
12501250
assert retrieved[0].original_value == "sent"
1251+
1252+
1253+
@pytest.mark.parametrize(
1254+
"kwargs, expected",
1255+
[
1256+
({"property_path": "$.label", "value": "éthique"}, 1),
1257+
({"property_path": "$.label", "value": "ÉTHIQUE"}, 1),
1258+
({"property_path": "$.label", "value": "éth", "partial_match": True}, 1),
1259+
({"property_path": "$.label", "value": "éthique", "case_sensitive": True}, 0),
1260+
({"property_path": "$.label", "value": "ethique"}, 0),
1261+
({"property_path": "$.items", "array_element_path": "$.name", "array_to_match": ["ÜBERWACHUNG", "fraud"]}, 1),
1262+
({"property_path": "$.items", "array_element_path": "$.name", "array_to_match": ["überwachung", "missing"]}, 0),
1263+
],
1264+
)
1265+
async def test_json_match_conditions_fold_non_ascii_case_async(
1266+
sqlite_instance: SQLiteMemory, kwargs: dict[str, object], expected: int
1267+
) -> None:
1268+
piece = MessagePiece(
1269+
conversation_id=str(uuid.uuid4()),
1270+
role="assistant",
1271+
original_value="stored",
1272+
prompt_metadata={"label": "ÉTHIQUE", "items": [{"name": "Überwachung"}, {"name": "Fraud"}]},
1273+
)
1274+
await sqlite_instance.add_message_pieces_to_memory_async(message_pieces=[piece])
1275+
1276+
match = (
1277+
sqlite_instance._get_condition_json_array_match
1278+
if "array_to_match" in kwargs
1279+
else sqlite_instance._get_condition_json_property_match
1280+
)
1281+
condition = match(json_column=PromptMemoryEntry.prompt_metadata, **kwargs)
1282+
async with await sqlite_instance.get_session_async() as session:
1283+
rows = (await session.execute(select(PromptMemoryEntry.id).where(condition))).all()
1284+
assert len(rows) == expected

0 commit comments

Comments
 (0)