mirror of
https://github.com/PR0M3TH3AN/unredact.git
synced 2026-11-03 12:10:29 +00:00
feat: add solve_full_name_dictionary with Cartesian product matching
Generate first x last name combinations from associate lists, apply Title Case or UPPER casing, measure rendered widths, and return matches within tolerance. Also checks associate name variants (initials, nicknames) with deduplication. Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
1 parent
72df8d3579
commit
7b0802d005
2 files changed
+178
No files matched your search
@@ -0,0 +1,112 @@
|
||||
"""Tests for dictionary-based full name solving."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from unredact.pipeline.dictionary import solve_full_name_dictionary
|
||||
from unredact.pipeline.solver import SolveResult
|
||||
|
||||
|
||||
def _mock_font(width_map: dict[str, float]) -> MagicMock:
|
||||
"""Create a mock font that returns widths from a map."""
|
||||
font = MagicMock()
|
||||
font.getlength.side_effect = lambda text: width_map.get(text, len(text) * 7.0)
|
||||
return font
|
||||
|
||||
|
||||
class TestSolveFullNameDictionary:
|
||||
@patch("unredact.pipeline.word_filter._get_associate_firsts")
|
||||
@patch("unredact.pipeline.word_filter._get_associate_lasts")
|
||||
@patch("unredact.pipeline.word_filter._get_associate_variants")
|
||||
def test_basic_match(self, mock_variants, mock_lasts, mock_firsts):
|
||||
mock_firsts.return_value = ["john", "jane"]
|
||||
mock_lasts.return_value = ["doe", "smith"]
|
||||
mock_variants.return_value = []
|
||||
|
||||
font = _mock_font({"John Doe": 50.0, "John Smith": 60.0, "Jane Doe": 48.0, "Jane Smith": 58.0})
|
||||
|
||||
results = solve_full_name_dictionary(font, 50.0, 1.0)
|
||||
|
||||
texts = [r.text for r in results]
|
||||
assert "John Doe" in texts
|
||||
assert "Jane Doe" not in texts # 48.0 is outside tolerance
|
||||
|
||||
@patch("unredact.pipeline.word_filter._get_associate_firsts")
|
||||
@patch("unredact.pipeline.word_filter._get_associate_lasts")
|
||||
@patch("unredact.pipeline.word_filter._get_associate_variants")
|
||||
def test_uppercase_mode(self, mock_variants, mock_lasts, mock_firsts):
|
||||
mock_firsts.return_value = ["john"]
|
||||
mock_lasts.return_value = ["doe"]
|
||||
mock_variants.return_value = []
|
||||
|
||||
font = _mock_font({"JOHN DOE": 55.0})
|
||||
|
||||
results = solve_full_name_dictionary(font, 55.0, 1.0, uppercase_only=True)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].text == "JOHN DOE"
|
||||
|
||||
@patch("unredact.pipeline.word_filter._get_associate_firsts")
|
||||
@patch("unredact.pipeline.word_filter._get_associate_lasts")
|
||||
@patch("unredact.pipeline.word_filter._get_associate_variants")
|
||||
def test_includes_associate_variants(self, mock_variants, mock_lasts, mock_firsts):
|
||||
mock_firsts.return_value = ["john"]
|
||||
mock_lasts.return_value = ["doe"]
|
||||
mock_variants.return_value = ["j. doe", "johnny doe"]
|
||||
|
||||
font = _mock_font({
|
||||
"John Doe": 50.0,
|
||||
"J. Doe": 35.0,
|
||||
"Johnny Doe": 60.0,
|
||||
})
|
||||
|
||||
results = solve_full_name_dictionary(font, 50.0, 1.0)
|
||||
texts = [r.text for r in results]
|
||||
assert "John Doe" in texts
|
||||
# J. Doe title-cases to "J. Doe" with width 35.0 -- outside tolerance
|
||||
# Johnny Doe title-cases to "Johnny Doe" with width 60.0 -- outside tolerance
|
||||
|
||||
@patch("unredact.pipeline.word_filter._get_associate_firsts")
|
||||
@patch("unredact.pipeline.word_filter._get_associate_lasts")
|
||||
@patch("unredact.pipeline.word_filter._get_associate_variants")
|
||||
def test_context_chars(self, mock_variants, mock_lasts, mock_firsts):
|
||||
mock_firsts.return_value = ["john"]
|
||||
mock_lasts.return_value = ["doe"]
|
||||
mock_variants.return_value = []
|
||||
|
||||
font = _mock_font({
|
||||
"<John Doe>": 60.0,
|
||||
"<": 5.0,
|
||||
">": 5.0,
|
||||
})
|
||||
|
||||
results = solve_full_name_dictionary(font, 50.0, 1.0, left_context="<", right_context=">")
|
||||
assert len(results) == 1
|
||||
assert results[0].text == "John Doe"
|
||||
|
||||
@patch("unredact.pipeline.word_filter._get_associate_firsts")
|
||||
@patch("unredact.pipeline.word_filter._get_associate_lasts")
|
||||
@patch("unredact.pipeline.word_filter._get_associate_variants")
|
||||
def test_dedup_variants_and_cartesian(self, mock_variants, mock_lasts, mock_firsts):
|
||||
mock_firsts.return_value = ["john"]
|
||||
mock_lasts.return_value = ["doe"]
|
||||
mock_variants.return_value = ["john doe"] # duplicate of cartesian result
|
||||
|
||||
font = _mock_font({"John Doe": 50.0})
|
||||
|
||||
results = solve_full_name_dictionary(font, 50.0, 1.0)
|
||||
texts = [r.text for r in results]
|
||||
assert texts.count("John Doe") == 1 # no duplicates
|
||||
|
||||
@patch("unredact.pipeline.word_filter._get_associate_firsts")
|
||||
@patch("unredact.pipeline.word_filter._get_associate_lasts")
|
||||
@patch("unredact.pipeline.word_filter._get_associate_variants")
|
||||
def test_sorted_by_error(self, mock_variants, mock_lasts, mock_firsts):
|
||||
mock_firsts.return_value = ["john", "jane"]
|
||||
mock_lasts.return_value = ["doe"]
|
||||
mock_variants.return_value = []
|
||||
|
||||
font = _mock_font({"John Doe": 50.5, "Jane Doe": 50.0})
|
||||
|
||||
results = solve_full_name_dictionary(font, 50.0, 1.0)
|
||||
assert len(results) == 2
|
||||
assert results[0].error <= results[1].error
|
||||
@@ -60,3 +60,69 @@ def solve_dictionary(
|
||||
|
||||
results.sort(key=lambda r: (r.error, r.text))
|
||||
return results
|
||||
|
||||
|
||||
def solve_full_name_dictionary(
|
||||
font: ImageFont.FreeTypeFont,
|
||||
target_width: float,
|
||||
tolerance: float = 0.0,
|
||||
left_context: str = "",
|
||||
right_context: str = "",
|
||||
uppercase_only: bool = False,
|
||||
) -> list[SolveResult]:
|
||||
"""Match associate first x last name combinations against target width.
|
||||
|
||||
Generates the Cartesian product of associate first and last names,
|
||||
applies casing (Title Case or UPPER), and checks each combination's
|
||||
rendered width. Also checks associate name variants (initials, nicknames).
|
||||
"""
|
||||
from unredact.pipeline.word_filter import (
|
||||
_get_associate_firsts,
|
||||
_get_associate_lasts,
|
||||
_get_associate_variants,
|
||||
)
|
||||
|
||||
firsts = _get_associate_firsts()
|
||||
lasts = _get_associate_lasts()
|
||||
variants = _get_associate_variants()
|
||||
|
||||
results: list[SolveResult] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _check(text: str):
|
||||
if text in seen:
|
||||
return
|
||||
seen.add(text)
|
||||
|
||||
if left_context or right_context:
|
||||
full = left_context + text + right_context
|
||||
full_len = font.getlength(full)
|
||||
left_len = font.getlength(left_context) if left_context else 0.0
|
||||
right_len = font.getlength(right_context) if right_context else 0.0
|
||||
width = full_len - left_len - right_len
|
||||
else:
|
||||
width = font.getlength(text)
|
||||
|
||||
error = abs(width - target_width)
|
||||
if error <= tolerance:
|
||||
results.append(SolveResult(text=text, width=float(width), error=float(error)))
|
||||
|
||||
# Cartesian product of first x last names
|
||||
for first in firsts:
|
||||
for last in lasts:
|
||||
if uppercase_only:
|
||||
text = (first + " " + last).upper()
|
||||
else:
|
||||
text = first.title() + " " + last.title()
|
||||
_check(text)
|
||||
|
||||
# Associate variants (J. Doe, Jeff Epstein, etc.)
|
||||
for variant in variants:
|
||||
if uppercase_only:
|
||||
text = variant.upper()
|
||||
else:
|
||||
text = variant.title()
|
||||
_check(text)
|
||||
|
||||
results.sort(key=lambda r: (r.error, r.text))
|
||||
return results
|
||||
Reference in new issue
Block a user