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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ 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.6] - 2026-06

### Fixed
- **Quoted values are now masked.** Names, rank+PIB and IPNs wrapped in quotes
(`«…»`, `"…"`, `„…“`) were skipped because words stuck to the quote chars
(`«Петренко`, `сержант»`) and only `,.!?;:` was stripped. Quotes are now
stripped in recognition (`looks_like_name`, `is_pib_anchor`,
`is_likely_surname_by_case`, PIB/rank token extraction) and normalized to
spaces in `normalize_string`. Quotes stay in place; roundtrip preserved.
Note: a bare rank without a following PIB is still not masked (unchanged
behavior, independent of quotes).

## [2.6.5] - 2026-06

### Changed
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.5"
__version__ = "2.6.6"

from masking.constants import (
__version__, __author__, __contact__, __phone__, __license__, __year__,
Expand Down
6 changes: 5 additions & 1 deletion masking/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
# ============================================================================
# МЕТАДАНІ
# ============================================================================
__version__ = "2.6.5"
__version__ = "2.6.6"
__author__ = "Vladyslav V. Prodan"
__contact__ = "github.com/click0"
__phone__ = "+38(099)6053340"
Expand Down Expand Up @@ -166,6 +166,10 @@
MONTHS_GENITIVE_PATTERN = '|'.join(re.escape(m) for m in MONTHS_GENITIVE.keys())

# Maximum input file size in bytes (default: 100 MB)
# Лапки всіх стилів (українські «», німецькі „“, англійські "" '', ‟).
# Значення в лапках («сержант», «Петренко») мають розпізнаватись і маскуватись.
QUOTE_CHARS = '«»„“”‟“”„"\''

MAX_INPUT_FILE_SIZE = 100 * 1024 * 1024

EXCLUDE_WORDS_LOWER = frozenset(w.lower() for w in EXCLUDE_WORDS)
Expand Down
5 changes: 3 additions & 2 deletions masking/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,8 @@ def parse_hybrid_line(line: str) -> Tuple[Optional[str], Optional[str], Optional
rank_matches.sort(key=lambda x: x[0])
rank_index, found_rank, rank_position, rank_word_count = rank_matches[0]
pib_start_index = rank_position + rank_word_count
rank_words = line.split()[rank_position:rank_position + rank_word_count]
rank_words = [w.strip(_cfg.QUOTE_CHARS) for w in
line.split()[rank_position:rank_position + rank_word_count]]
found_rank_original_case = ' '.join(rank_words) if rank_words else found_rank

if pib_start_index == -1:
Expand All @@ -221,7 +222,7 @@ def parse_hybrid_line(line: str) -> Tuple[Optional[str], Optional[str], Optional
pib_words = []
for word in parts[pib_start_index:pib_start_index + 3]:
if looks_like_name(word):
pib_words.append(word.rstrip(',.!?;:'))
pib_words.append(word.strip(_cfg.QUOTE_CHARS).rstrip(',.!?;:'))
else:
break
pib = " ".join(pib_words) if pib_words else None
Expand Down
4 changes: 4 additions & 0 deletions masking/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ def normalize_string(s: str) -> str:
if not s: return ""
s_str = str(s).lower()
s_str = s_str.replace("'", "'").replace('\xa0', ' ').replace(".", " ").replace(";", " ")
# Лапки → пробіл, щоб значення в лапках («сержант») знаходились під час пошуку
for q in _cfg.QUOTE_CHARS:
s_str = s_str.replace(q, ' ')
return re.sub(r'\s+', ' ', s_str).strip()

def normalize_identifier(identifier: str) -> str:
Expand All @@ -104,6 +107,7 @@ def normalize_identifier(identifier: str) -> str:

def is_pib_anchor(word: str) -> bool:
if word.startswith("___") and word.endswith("___"): return False
word = word.strip(_cfg.QUOTE_CHARS)
if not word or len(word) <= 2: return False
word_lower = word.lower()
if word_lower in _cfg.EXCLUDE_WORDS_LOWER: return False
Expand Down
3 changes: 2 additions & 1 deletion masking/language.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@

def is_likely_surname_by_case(word: str) -> bool:
if word.startswith("___") and word.endswith("___"): return False
word = word.strip(_cfg.QUOTE_CHARS)
if not word or len(word) < 3: return False
letters_only = re.sub(r"[-']", '', word)
return letters_only.isupper() and len(letters_only) >= 3

def looks_like_name(word: str) -> bool:
if word.startswith("___") and word.endswith("___"): return False
clean_word = word.rstrip(',.!?;:')
clean_word = word.strip(_cfg.QUOTE_CHARS).rstrip(',.!?;:')
if len(clean_word) < 3: return False
if '.' in clean_word: return False
if clean_word.lower() in _cfg.EXCLUDE_WORDS_LOWER: return False
Expand Down
62 changes: 62 additions & 0 deletions tests/test_quoted.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Тести маскування значень у лапках («...», "...", „...“).

Значення, обгорнуте в лапки (ПІБ, звання+ПІБ, ІПН), має маскуватись,
а самі лапки — лишатись на місці. Roundtrip має відновлювати оригінал.
"""
import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from tests.test_initials import mask
from unmasking.engine import unmask_text_v2
from unmasking.helpers import check_mapping_version


QUOTED_CASES = [
'капітан «Петренко Іван Сергійович»',
'сержант "Коваленко Марія Іванівна"',
'майор „Сидоренко Олег Петрович“',
'ІПН «2817863534» видано',
'старший сержант «Іваненко Андрій Вікторович» отримав',
]


class TestQuotedMasking:
@pytest.mark.parametrize("text", QUOTED_CASES)
def test_quoted_value_is_masked(self, text):
masked, _ = mask(text)
assert masked != text, f"Значення в лапках не замасковано: {text!r}"

@pytest.mark.parametrize("text", QUOTED_CASES)
def test_quotes_preserved(self, text):
# Кількість лапкових символів не змінюється
masked, _ = mask(text)
for q in '«»„“”"':
assert masked.count(q) == text.count(q), (
f"Лапка {q!r} втрачена/додана: {text!r} -> {masked!r}"
)

@pytest.mark.parametrize("text", QUOTED_CASES)
def test_roundtrip(self, text):
masked, md = mask(text)
restored, _ = unmask_text_v2(masked, md, check_mapping_version(md))
assert restored == text, (
f"Roundtrip провалено:\n {text!r}\n -> {masked!r}\n <- {restored!r}"
)

def test_pib_original_not_leaked(self):
masked, _ = mask('капітан «Петренко Іван Сергійович»')
assert 'Петренко' not in masked
assert 'Сергійович' not in masked

def test_matches_unquoted_behaviour(self):
# ПІБ у лапках і без них маскується однаково детерміновано
a, _ = mask('Петренко Іван Сергійович')
b, _ = mask('«Петренко Іван Сергійович»')
assert b == '«' + a + '»'
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.5"
__version__ = "2.6.6"


def main():
Expand Down
Loading