From bf3d06e9f1a5209e926e54261989f3905c4d6bb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 20:02:48 +0000 Subject: [PATCH] feat: mask standalone ranks wrapped in quotes (v2.6.8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rank in quotes as its own value («молодший сержант», log lines '… → «молодший сержант»') is now masked even without a PIB. New _mask_quoted_ranks pass runs after the PIB loop; only exact ALL_RANK_FORMS inside quotes are touched, already-masked forms are skipped. Quotes stay; unmask restores via rank mapping. +4 tests. Documented collision edge case. https://claude.ai/code/session_01XT6iUWaQgahXDB9TWX9Bq7 --- CHANGELOG.md | 18 +++++++++++++++ data_masking.py | 2 +- masking/constants.py | 4 +++- masking/engine.py | 53 +++++++++++++++++++++++++++++++++++++++++++- tests/test_quoted.py | 29 ++++++++++++++++++++++++ unmasking/cli.py | 2 +- 6 files changed, 104 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c668f6..230f79f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ 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.8] - 2026-07 + +### Added +- **Standalone ranks in quotes are now masked.** A rank wrapped in quotes as + a value on its own — `«молодший сержант»`, `звання «капітан» присвоєно`, + or log lines `… → «молодший сержант» …` — is masked even without an + accompanying PIB. Only exact known rank forms (`ALL_RANK_FORMS`) inside the + quotes are touched; arbitrary quoted text (`«важливо»`, `«138»`) is left + alone. Quotes stay in place; unmask restores via the `rank` mapping. + +### Known limitation +- If a masked rank happens to collide with a *different, unmasked* bare rank + elsewhere in the same line (e.g. `капітан «майор»` where `майор` masks to + `капітан`), roundtrip may be ambiguous — an inherent property of + deterministic masking on collisions, not specific to quotes. Realistic + formats (one quoted rank, or several distinct quoted ranks) round-trip + correctly. + ## [2.6.7] - 2026-07 ### Fixed diff --git a/data_masking.py b/data_masking.py index 582e8a6..d53027b 100644 --- a/data_masking.py +++ b/data_masking.py @@ -30,7 +30,7 @@ # Re-exports from masking package for backward compatibility # ============================================================================ -__version__ = "2.6.7" +__version__ = "2.6.8" from masking.constants import ( __version__, __author__, __contact__, __phone__, __license__, __year__, diff --git a/masking/constants.py b/masking/constants.py index 227b613..222a183 100644 --- a/masking/constants.py +++ b/masking/constants.py @@ -26,7 +26,7 @@ # ============================================================================ # МЕТАДАНІ # ============================================================================ -__version__ = "2.6.7" +__version__ = "2.6.8" __author__ = "Vladyslav V. Prodan" __contact__ = "github.com/click0" __phone__ = "+38(099)6053340" @@ -177,6 +177,8 @@ EXCLUDE_WORDS_LOWER = frozenset(w.lower() for w in EXCLUDE_WORDS) RANKS_LIST_LOWER = frozenset(r.lower() for r in RANKS_LIST) +# Усі граматичні форми звань (для точного збігу вмісту лапок «...») +ALL_RANK_FORMS_LOWER = frozenset(f.lower() for f in ALL_RANK_FORMS) _MONTHS_UA_LIST = [ "січня", "лютого", "березня", "квітня", "травня", "червня", diff --git a/masking/engine.py b/masking/engine.py index 6c83ec9..604f71a 100644 --- a/masking/engine.py +++ b/masking/engine.py @@ -206,6 +206,51 @@ def replace_match(match): return pattern.sub(replace_match, text) +# Лапки (відкриваючі/закриваючі будь-якого стилю) для пошуку значень у лапках +_QUOTED_RE = re.compile(r'([«"„“\'])([^«»"„“”\']{1,60})([»"”“\'])') + + +def _mask_quoted_ranks(text: str, masking_dict: Dict, instance_counters: Dict) -> str: + """ + Маскує звання, взяте в лапки як самостійне значення: «молодший сержант». + + Основний парсер маскує звання лише в парі з ПІБ. Але у форматах-логах + звання йде як окреме значення в лапках без ПІБ. Тут маскуємо ТІЛЬКИ якщо + весь вміст лапок — рівно відома форма звання (ALL_RANK_FORMS), щоб не + зачепити довільний текст. Лапки лишаються на місці; unmask відновлює + звання зі словника rank як звичайно. + """ + if not _cfg.MASK_RANKS: + return text + + # Вже використані маски звань — щоб не маскувати результат повторно + already = { + info["masked_as"].lower() + for info in masking_dict["mappings"].get("rank", {}).values() + if isinstance(info, dict) and "masked_as" in info + } + + segments = [] + prev_end = 0 + for m in _QUOTED_RE.finditer(text): + inner = m.group(2).strip() + low = inner.lower() + if low not in _cfg.ALL_RANK_FORMS_LOWER or low in already: + continue + masked = mask_rank_preserve_case(inner, masking_dict, instance_counters) + if masked == inner: + continue + already.add(masked.lower()) + segments.append(text[prev_end:m.start()]) + segments.append(m.group(1) + masked + m.group(3)) + prev_end = m.end() + + if segments: + segments.append(text[prev_end:]) + text = ''.join(segments) + return text + + def mask_text_context_aware(text: str, masking_dict: Dict, instance_counters: Dict) -> str: """ Головна функція маскування тексту з контекстним аналізом. @@ -391,7 +436,13 @@ def mask_text_context_aware(text: str, masking_dict: Dict, instance_counters: Di iteration += 1 masked_lines.append(final_line) - return '\n'.join(masked_lines) + text = '\n'.join(masked_lines) + + # Звання-значення в лапках без ПІБ («молодший сержант») — після + # основного циклу, з пропуском уже замаскованих форм + text = _mask_quoted_ranks(text, masking_dict, instance_counters) + + return text def mask_json_recursive(data: Any, masking_dict: Dict, instance_counters: Dict) -> Any: if isinstance(data, dict): return {key: mask_json_recursive(value, masking_dict, instance_counters) for key, value in data.items()} diff --git a/tests/test_quoted.py b/tests/test_quoted.py index 64aa0b7..2fc2d11 100644 --- a/tests/test_quoted.py +++ b/tests/test_quoted.py @@ -86,3 +86,32 @@ def test_ipn_assignment_not_treated_as_name(self): masked, _ = mask('ІПН=1234567890 Петренко Іван Васильович') assert 'Петренко' not in masked assert '1234567890' not in masked + + +class TestQuotedRankStandalone: + """Звання в лапках як самостійне значення (без ПІБ) — v2.6.8.""" + + def test_data_ipn_rank_log_line(self): + line = ('[WARNING] data-ipn: ІПН=3577412582 — звання «1258963214» → ' + '«молодший сержант» (джерело data-ipn головне)') + masked, md = mask(line) + assert '«молодший сержант»' not in masked # звання замасковано + from unmasking.engine import unmask_text_v2 + restored, _ = unmask_text_v2(masked, md, check_mapping_version(md)) + assert restored == line + + def test_quoted_rank_label(self): + masked, _ = mask('звання «капітан» присвоєно') + assert '«капітан»' not in masked + assert masked.count('«') == 1 and masked.count('»') == 1 + + def test_two_distinct_quoted_ranks_roundtrip(self): + line = 'звання «молодший сержант» та «сержант»' + masked, md = mask(line) + from unmasking.engine import unmask_text_v2 + restored, _ = unmask_text_v2(masked, md, check_mapping_version(md)) + assert restored == line + + def test_non_rank_in_quotes_untouched(self): + assert mask('слово «важливо» тут')[0] == 'слово «важливо» тут' + assert mask('позиція «138» ключ')[0] == 'позиція «138» ключ' diff --git a/unmasking/cli.py b/unmasking/cli.py index 244c41e..2115b07 100644 --- a/unmasking/cli.py +++ b/unmasking/cli.py @@ -58,7 +58,7 @@ # ============================================================================ # МЕТАДАНІ # ============================================================================ -__version__ = "2.6.7" +__version__ = "2.6.8" def main():