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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [2.6.4] - 2026-06

### Performance
- Unmask of non-rank data is now a single alternation-regex pass instead of
one full-text scan per mask — ~10× faster on large documents
(278 KB: ~30 s → ~2.8 s). Longer masks take priority over substrings;
per-mask occurrence counter keeps instance tracking exact. Slow per-mask
path kept as fallback for masks that break regex compilation

## [2.6.3] - 2026-06

### Fixed
- Lettered sub-items no longer masked as initials: `п. В. Петренко` (item B),
`ст. А. Кодексу`, `абз. Б. …` etc. — when a service abbreviation
(`п.`, `пп.`, `ч.`, `ст.`, `абз.`, `гл.`, `розд.`, …) directly precedes an
initials+surname pattern, the leading letter is treated as a clause marker,
not a name

## [2.6.2] - 2026-06

### Fixed
- Windows: close log file handlers before `os.unlink()` in tests
(`PermissionError: [WinError 32]` from held file locks)

## [2.6.1] - 2026-06

### Changed
- `docs/README.md` translated to English; Ukrainian original moved to
`docs/README_UK.md`; both cross-linked

## [2.6.0] - 2026-06

Release rollup of 2.5.2–2.5.8 (reversible initials, instance tracking fixes,
Expand Down
2 changes: 1 addition & 1 deletion data_masking.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
# Re-exports from masking package for backward compatibility
# ============================================================================

__version__ = "2.6.2"
__version__ = "2.6.4"

from masking.constants import (
__version__, __author__, __contact__, __phone__, __license__, __year__,
Expand Down
2 changes: 1 addition & 1 deletion masking/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
# ============================================================================
# МЕТАДАНІ
# ============================================================================
__version__ = "2.6.2"
__version__ = "2.6.4"
__author__ = "Vladyslav V. Prodan"
__contact__ = "github.com/click0"
__phone__ = "+38(099)6053340"
Expand Down
22 changes: 20 additions & 2 deletions masking/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ def _mask_initial(letter: str, context: str = "") -> str:
return random.choice(candidates)


# Службові скорочення з крапкою. Якщо такий токен стоїть безпосередньо
# перед "ініціалом + прізвищем" (П. Іванов), то перша літера — це
# буквений підпункт ("п. В. Петренко" = пункт В), а не ім'я.
_NON_INITIAL_LEFT = frozenset({
'п', 'пп', 'ч', 'чч', 'ст', 'стст', 'абз', 'гл', 'розд', 'р', 'рр',
'арт', 'н', 'прим', 'дод', 'табл', 'мал', 'рис',
})


def _left_token(text: str, pos: int) -> str:
"""Останнє слово (без розділових) безпосередньо ліворуч від pos."""
left = text[:pos].rstrip()
if not left:
return ""
tok = left.split()[-1]
return tok.rstrip('.').lower()


def _mask_initials_pib(text: str, masking_dict: Dict, instance_counters: Dict) -> str:
"""
Знаходить ПІБ з ініціалами та маскує їх.
Expand All @@ -112,7 +130,7 @@ def _mask_initials_pib(text: str, masking_dict: Dict, instance_counters: Dict) -

for m in _RE_INI2_NAME.finditer(text):
i1, i2, surname = m.group(1), m.group(2), m.group(3)
if _is_surname_candidate(surname):
if _is_surname_candidate(surname) and _left_token(text, m.start()) not in _NON_INITIAL_LEFT:
has_space = f"{i1}. {i2}." in m.group(0)
candidates.append((m.start(), m.end(), surname, [i1, i2], has_space, True))

Expand All @@ -123,7 +141,7 @@ def _mask_initials_pib(text: str, masking_dict: Dict, instance_counters: Dict) -

for m in _RE_INI1_NAME.finditer(text):
i1, surname = m.group(1), m.group(2)
if _is_surname_candidate(surname):
if _is_surname_candidate(surname) and _left_token(text, m.start()) not in _NON_INITIAL_LEFT:
candidates.append((m.start(), m.end(), surname, [i1], False, True))

# Довші патерни мають пріоритет; знімаємо перекриття
Expand Down
16 changes: 16 additions & 0 deletions tests/test_initials.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@ def test_non_pib_text_untouched(self):
masked, _ = mask(text)
assert masked == text, f"Хибне спрацювання на: {text!r}"

def test_lettered_subitem_not_masked(self):
# Літерний підпункт після службового скорочення — не ПІБ
for text in [
"розглянуто п. В. Петренко",
"згідно ст. А. Кодексу",
"абз. Б. Іванова",
"див. п. В. Сидоренко",
]:
masked, _ = mask(text)
assert masked == text, f"Підпункт хибно замасковано: {text!r} -> {masked!r}"

def test_real_pib_after_normal_word_still_masked(self):
# Справжній ініціал після звичайного слова має маскуватись
masked, _ = mask("доручено В. Коваленку")
assert masked != "доручено В. Коваленку"


class TestInitialsRoundtrip:
"""Зворотність: unmask відновлює оригінал, включно з ініціалами."""
Expand Down
45 changes: 45 additions & 0 deletions tests/test_unmask.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,5 +488,50 @@ def test_instance_tracking_scenario(self):
assert instance_map["Коваленко"][2] == "сидоренко"


class TestUnmaskOtherDataSinglePass:
"""Єдиний alternation-regex прохід (v2.6.4): колізії масок та регістр."""

def _map(self, mappings):
return {"version": "2.5.0", "mappings": mappings, "instance_tracking": {}}

def test_collision_restores_per_instance(self):
# Два різних оригінали замасковані в одне значення "Коваленко"
from unmasking.engine import unmask_other_data
masking_map = self._map({
"surname": {
"Петренко": {"masked_as": "Коваленко", "instances": [1]},
"Сидоренко": {"masked_as": "Коваленко", "instances": [2]},
}
})
text = "Коваленко прийшов, потім Коваленко пішов"
restored, stats = unmask_other_data(text, masking_map)
assert restored == "Петренко прийшов, потім Сидоренко пішов"
assert stats["restored_count"] == 2

def test_longer_mask_wins_over_substring(self):
# Коротша маска є підрядком довшої — довша має пріоритет
from unmasking.engine import unmask_other_data
masking_map = self._map({
"surname": {"Іван": {"masked_as": "Олег", "instances": [1]}},
"name": {"Іваненко": {"masked_as": "Олегенко", "instances": [1]}},
})
restored, _ = unmask_other_data("Олегенко", masking_map)
assert restored == "Іваненко"

def test_case_insensitive_match_preserves_case(self):
from unmasking.engine import unmask_other_data
masking_map = self._map({
"surname": {"петренко": {"masked_as": "коваленко", "instances": [1]}}
})
restored, _ = unmask_other_data("КОВАЛЕНКО склав звіт", masking_map)
assert restored == "ПЕТРЕНКО склав звіт"

def test_empty_mapping_noop(self):
from unmasking.engine import unmask_other_data
restored, stats = unmask_other_data("текст без масок", self._map({}))
assert restored == "текст без масок"
assert stats["restored_count"] == 0


if __name__ == "__main__":
pytest.main([__file__, "-v"])
2 changes: 1 addition & 1 deletion unmasking/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
# ============================================================================
# МЕТАДАНІ
# ============================================================================
__version__ = "2.6.2"
__version__ = "2.6.4"


def main():
Expand Down
68 changes: 59 additions & 9 deletions unmasking/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ def unmask_ranks_gender_aware(masked_text: str, masking_map: Dict) -> Tuple[str,
def unmask_other_data(masked_text: str, masking_map: Dict) -> Tuple[str, Dict]:
"""
Відновлення інших типів даних (окрім звань).

Один прохід по тексту єдиним alternation-regex з усіх масок —
O(текст) замість O(масок × текст). Альтернативи сортуються за довжиною
спадно, щоб довша маска мала пріоритет над коротшою (яка може бути її
підрядком). Instance tracking: per-mask лічильник входжень зліва направо
збігається з порядком, у якому instance-номери присвоювались при маскуванні.
"""
restored_text = masked_text

Expand All @@ -171,23 +177,68 @@ def unmask_other_data(masked_text: str, masking_map: Dict) -> Tuple[str, Dict]:
del mappings_copy["rank"]

instance_map = build_instance_map({"mappings": mappings_copy})
stats = {"restored_count": 0, "skipped_count": 0}
if not instance_map:
return restored_text, stats

# Case-insensitive lookup: lower(маска) -> {instance_num: original}
by_lower = {}
for masked_value, inst in instance_map.items():
by_lower.setdefault(masked_value.lower(), {}).update(inst)

# Єдиний regex; довші маски першими в alternation
masks_sorted = sorted(instance_map.keys(), key=len, reverse=True)
try:
big_re = re.compile(
r'(?<!\w)(' + '|'.join(re.escape(m) for m in masks_sorted) + r')(?!\w)',
re.IGNORECASE,
)
except re.error:
# Запасний шлях (екзотичні символи в масках)
return _unmask_other_data_slow(restored_text, instance_map)

seen = {} # lower(маска) -> скільки входжень уже зустріли
segments = []
prev_end = 0
for m in big_re.finditer(restored_text):
key = m.group(0).lower()
inst = by_lower.get(key)
if not inst:
continue
n = seen.get(key, 0) + 1
seen[key] = n
original_value = inst.get(n)
if original_value is None:
stats["skipped_count"] += 1
continue
original_value = _apply_original_case(m.group(0), original_value)
segments.append(restored_text[prev_end:m.start()])
segments.append(original_value)
prev_end = m.end()
stats["restored_count"] += 1

if segments:
segments.append(restored_text[prev_end:])
restored_text = ''.join(segments)

return restored_text, stats


def _unmask_other_data_slow(restored_text: str, instance_map: Dict) -> Tuple[str, Dict]:
"""Запасна реалізація через find_all_occurrences (коли єдиний regex
не компілюється). Семантика збігається з основним шляхом."""
stats = {"restored_count": 0, "skipped_count": 0}
replacements_to_do = []

for masked_value, instance_to_original in instance_map.items():
occurrences = find_all_occurrences(restored_text, masked_value)

for instance_num, (start_pos, end_pos) in enumerate(occurrences, 1):
for instance_num, (start_pos, end_pos) in enumerate(
find_all_occurrences(restored_text, masked_value), 1):
if instance_num in instance_to_original:
original_value = instance_to_original[instance_num]
replacements_to_do.append((start_pos, end_pos, original_value, masked_value))
replacements_to_do.append(
(start_pos, end_pos, instance_to_original[instance_num], masked_value))
stats["restored_count"] += 1
else:
stats["skipped_count"] += 1

# Сегментами в порядку документа — O(n); перевірка відповідності маски
# йде по незмінному тексту, перекриття пропускаються
replacements_to_do.sort(key=lambda x: x[0])
segments = []
prev_end = 0
Expand All @@ -204,7 +255,6 @@ def unmask_other_data(masked_text: str, masking_map: Dict) -> Tuple[str, Dict]:
if segments:
segments.append(restored_text[prev_end:])
restored_text = ''.join(segments)

return restored_text, stats


Expand Down
Loading