diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f684e..ba99c0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/data_masking.py b/data_masking.py index 293b897..818e4a5 100644 --- a/data_masking.py +++ b/data_masking.py @@ -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__, diff --git a/masking/constants.py b/masking/constants.py index f30d4d8..d533694 100644 --- a/masking/constants.py +++ b/masking/constants.py @@ -26,7 +26,7 @@ # ============================================================================ # МЕТАДАНІ # ============================================================================ -__version__ = "2.6.5" +__version__ = "2.6.6" __author__ = "Vladyslav V. Prodan" __contact__ = "github.com/click0" __phone__ = "+38(099)6053340" @@ -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) diff --git a/masking/context.py b/masking/context.py index 82bbdaa..38f541e 100644 --- a/masking/context.py +++ b/masking/context.py @@ -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: @@ -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 diff --git a/masking/helpers.py b/masking/helpers.py index c749f25..a904bcb 100644 --- a/masking/helpers.py +++ b/masking/helpers.py @@ -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: @@ -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 diff --git a/masking/language.py b/masking/language.py index f7d2767..a7e9566 100644 --- a/masking/language.py +++ b/masking/language.py @@ -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 diff --git a/tests/test_quoted.py b/tests/test_quoted.py new file mode 100644 index 0000000..364caeb --- /dev/null +++ b/tests/test_quoted.py @@ -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 + '»' diff --git a/unmasking/cli.py b/unmasking/cli.py index ddad09c..23663d9 100644 --- a/unmasking/cli.py +++ b/unmasking/cli.py @@ -58,7 +58,7 @@ # ============================================================================ # МЕТАДАНІ # ============================================================================ -__version__ = "2.6.5" +__version__ = "2.6.6" def main():