From c62e74549782e01a5e5cf2f1e44f7c78bdbdc067 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 10:24:19 +0000 Subject: [PATCH] Fix leaks and round-trip failures found by fuzzing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A randomized round-trip and leak fuzzer (documents with ranks, names in all cases and letter cases, numbers, dates, non-breaking spaces, tabs, CRLF and hard line wraps) found these; each has a test in tests/test_fuzz_findings.py. Names left unmasked: - A full name before a rank followed by more words ("Петренко Іван Іванович доповів капітану про виконання") was not masked: the parser took name anchors only after the first rank of the line. - A line break inside a name left part of it open. Lines are joined for parsing when a name with a patronymic, or a rank and its name, crosses the break; the break comes back after masking. - Initials and a surname on adjacent lines; runs of initials and surnames ("Ю.А. Зінченко Н.І. Савчук"), now resolved by taking the set of matches that covers the most names. - Double surnames in capitals and surnames with an apostrophe before or after initials; a heading in capitals before a name; adjective surnames in oblique cases; the vocative "Любове". Unmasking did not restore the original: - "генерал-майор медичної служби" as a mask (rank boundaries now exclude a hyphen). - Non-breaking spaces or tabs inside a rank whose mask had a different number of words; ranks broken across lines. - An open rank "... медичної служби" used as the mask of another rank. - A patronymic before initials masked as a surname, then masked again. - Two forms with the same mask in one line restored the wrong way round: masks in a line are now computed in text order. - A brigade name broken across lines lost its tail in the masked text. Masks: naval ranks get naval masks; the feminine genitive of an adjective surname shares the stem of its other forms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XT6iUWaQgahXDB9TWX9Bq7 Generated-With: Claude Code 2.1.42 --- CHANGELOG.md | 50 ++++ datamasking/masking/constants.py | 4 + datamasking/masking/context.py | 31 ++- datamasking/masking/declension.py | 4 + datamasking/masking/engine.py | 374 +++++++++++++++++++++++---- datamasking/masking/mask_military.py | 34 ++- datamasking/masking/surname.py | 3 + datamasking/unmasking/engine.py | 16 +- tests/test_config_level1.py | 2 +- tests/test_fuzz_findings.py | 321 +++++++++++++++++++++++ 10 files changed, 774 insertions(+), 65 deletions(-) create mode 100644 tests/test_fuzz_findings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c15df1..095b614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,56 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed — names left unmasked +- A full name before a rank that is followed by more words was not masked + at all: `Петренко Іван Іванович доповів капітану про виконання.`, + `Петренко Іван Іванович, 1990 р.н., солдат за контрактом`. The parser + looked for a name only after the first rank of the line. +- A line break inside a name left part of it open: the surname in + `Петренка\nІвана Івановича`, the patronymic in `Петренка Івана\nІвановича`, + `Шановний Віталію\nРомановичу!`, the rank in `старшого сержанта\nПетренка …`. + Such lines are now parsed together, and the line break stays where it was. +- Initials and a surname on adjacent lines (`… Л.І.\nКоваль`, + `Петренко\nО.П.`) are masked; one initial at the end of a line + (`Додаток А.`) is still not taken for a name. +- In a run of initials and surnames (`Ю.А. Зінченко Н.І. Савчук`) only the + middle pair was masked, and `Ю.А.` and `Савчук` stayed open; the masker now + takes the set of matches that covers the most names. +- A double surname in capitals with initials (`ПЕТРЕНКО-КОВАЛЬ І.М.`) kept + one part open; a surname in capitals with an apostrophe (`ПИЛИП'ЮК О.І.`) + was not masked. +- The surname stayed open after a heading in capitals (`ВИТЯГ З НАКАЗУ + Коваль Сергій Васильович`) and for an adjective surname in an oblique case + (`Рапорт Білого Ярослава Миколайовича`). +- `Шановна Любове Андріївно!`: the vocative `Любове` was not recognised, so + the name got a surname mask and the patronymic a male name. + +### Fixed — unmasking did not restore the original +- A rank `… медичної служби` masked as `генерал-майор медичної служби` was + not restored (the unmasker found `майор медичної служби` inside it). +- A rank written with a non-breaking space, a tab or several spaces between + its words (`старший майстер-сержант`) came back with a plain space when + the mask had a different number of words. The mask now keeps the number of + words, so the separators carry over and back. +- A rank broken across lines (`старшого\nсержанта`) came back joined with a + space; the line break is now restored. `normalize_broken_ranks()` itself + is unchanged. +- An open rank `… медичної служби` (one without a name) could become the + mask of another rank, and unmasking then replaced it. +- A patronymic before initials (`Мороз Ольги Богданівни Л.А. Мороз`) was + masked as a surname and then masked again. +- Two different forms with the same mask in one line (`Іване` and `Івану` + both masked as `Ігорю`) were restored the wrong way round: masks in a line + are now recorded in text order. +- A brigade number broken across lines lost the rest of its name in the + masked text (`60 окремої\nмеханізованої бригади` → `30 окремої`). + +### Changed — masks +- Naval ranks get naval masks: `капітан 1-го рангу` and `капітан-лейтенант` + were masked as army ranks (`майор`, `лейтенант`). +- The feminine genitive of an adjective surname (`Білої`) gets the same + synthetic stem as its other forms (`Біла`, `Білій`). + ### Changed — release process - The version is bumped at release time, not on every commit. Changes collect under `## [Unreleased]`, and the release commit sets the version diff --git a/datamasking/masking/constants.py b/datamasking/masking/constants.py index 2557d0a..5759814 100644 --- a/datamasking/masking/constants.py +++ b/datamasking/masking/constants.py @@ -165,6 +165,10 @@ def set_faker_locale(locale: str) -> None: # жіноча форма маскується чоловічою і unmask повертає «солдата» замість # «солдатки» (v3.1.14). Задається рушієм на час обробки документа. FEMININE_RANK_BASES: frozenset = frozenset() +# Базові звання, які в поточному документі записані з нерозривним пробілом, +# табом чи кількома пробілами між словами: маска має ту саму кількість слів, +# щоб роздільники перенеслись у неї й назад (після 3.1.20). Задається рушієм. +SPACED_RANK_BASES: frozenset = frozenset() # ============================================================================ # СПИСКИ ТА КОНСТАНТИ diff --git a/datamasking/masking/context.py b/datamasking/masking/context.py index a700c97..4f7e039 100644 --- a/datamasking/masking/context.py +++ b/datamasking/masking/context.py @@ -375,11 +375,18 @@ def parse_hybrid_line(line: str) -> Tuple[Optional[str], Optional[str], Optional # якір у рядку. Раніше брався лише ПЕРШИЙ якір; якщо за ним не було # ≥2 слів імені (наприклад, «Сірченко Е.Г.» — уже замаскована фаза # ініціалів), увесь рядок кидався і справжній ПІБ далі лишався відкритим. + # Якорі — по всьому рядку, крім слів звань: до 3.1.20 включно бралися лише + # якорі ПІСЛЯ першого звання, і «Петренко Іван Іванович доповів + # капітану про виконання» лишалось відкритим цілком (звання знайдено, + # після нього ПІБ немає, а ПІБ перед ним не розглядався) + rank_tokens: Set[int] = set() + for _r_index, _r_form, r_first, r_count in rank_matches: + rank_tokens.update(range(r_first, r_first + r_count)) starts = [] if pib_start_index != -1: starts.append((pib_start_index, True)) for i, part in enumerate(parts): - if i > pib_start_index and is_pib_anchor(part): + if i != pib_start_index and i not in rank_tokens and is_pib_anchor(part): starts.append((i, False)) if not starts: return None, None, identifier @@ -399,8 +406,11 @@ def parse_hybrid_line(line: str) -> Tuple[Optional[str], Optional[str], Optional pib_words = _extract_pib_words(parts, start, after_rank) if not pib_words: continue - # Посада чи назва підрозділу капсом без по батькові — не ПІБ (v3.1.17) - if not after_rank and _caps_block_without_patronymic(parts, start): + # Посада чи назва підрозділу капсом без по батькові — не ПІБ (v3.1.17). + # Кандидат із по батькові у вікні («НАКАЗУ БІЛИЙ Тарас Васильович») — + # не назва, навіть коли капсом стоїть і заголовок перед ним (після 3.1.20) + if (not after_rank and _caps_block_without_patronymic(parts, start) + and not _strong_pib_words(pib_words)): continue # «Заява Петренка Олега Петровича», «Характеристика Коваль Тетяни # Сергіївни»: слово з великої перед ПІБ потрапляло у вікно з трьох @@ -453,7 +463,11 @@ def _caps_block_without_patronymic(parts: List[str], start: int) -> bool: собою. До 3.1.17 назва посади маскувалась як «МАСАҐАНИКОМ ІГОР ОРЕСТОВИЧ».""" # Блок рахується в обидва боки від початку кандидата: «СТАРШОГО ОПЕРАТОРА # РОЗРАХУНКУ» відкидалось із першого слова, а з другого («ОПЕРАТОРА - # РОЗРАХУНКУ») знову бралось за ПІБ + # РОЗРАХУНКУ») знову бралось за ПІБ. Кандидат не капсом — не частина + # блоку: «ВИТЯГ З НАКАЗУ Коваль Сергій Васильович» (до 3.1.20 включно прізвище + # лишалось відкритим, бо капсом стояв заголовок перед ним) + if not _caps_word(parts[start]): + return False begin = start while begin > 0 and _caps_word(parts[begin - 1]) and parts[begin - 1].rstrip(_cfg.QUOTE_CHARS)[-1:] not in ',.;:!?': begin -= 1 @@ -509,8 +523,13 @@ def roles_plausible(pib_words: List[str], after_rank: bool = False) -> bool: if len(surname_low) >= 7 and surname_low.endswith(('ом', 'ем')) \ and not name_low.endswith(_INSTRUMENTAL_NAME_ENDINGS): return False - if surname_low.endswith(('ого', 'ього', 'ому', 'ьому')) and analyze_name(name_low).case == 'nominative': - return False + if surname_low.endswith(('ого', 'ього', 'ому', 'ьому')): + # Відмінок імені — за по батькові, якщо воно є: «Білого Ярослава + # Миколайовича» — родовий від Ярослав, а не жіноче «Ярослава» в + # називному (до 3.1.20 включно прізвище лишалось відкритим) + pat_case = analyze_patronymic(patronymic).case if patronymic else None + if (pat_case or analyze_name(name_low).case) == 'nominative': + return False return True diff --git a/datamasking/masking/declension.py b/datamasking/masking/declension.py index b6f8bea..14c0f71 100644 --- a/datamasking/masking/declension.py +++ b/datamasking/masking/declension.py @@ -240,6 +240,10 @@ def _female_form(f: str, names: Set[str], strict: bool) -> Optional[Tuple[str, s return _pick([f[:-1] + 'а'], VOCATIVE, names, strict) if n >= 3 and f.endswith('є'): return _pick([f[:-1] + 'я'], VOCATIVE, names, strict) + if n >= 4 and f.endswith('е') and f[:-1] in names: + # Любове (III відміна) — кличний; лише відоме ім'я: іншомовні на -е + # (Рене) лишаються в називному + return f[:-1], VOCATIVE, True if f.endswith(('а', 'я')): return None if strict else (f, NOMINATIVE, False) # Любов, Нінель — не відмінюються тут diff --git a/datamasking/masking/engine.py b/datamasking/masking/engine.py index de55d67..addd614 100644 --- a/datamasking/masking/engine.py +++ b/datamasking/masking/engine.py @@ -7,17 +7,19 @@ Extracted from data_masking.py during the package refactoring (v2.5.0). """ +import bisect +import functools import json import random import re -from typing import Any, Dict, List, Optional, Pattern, Tuple +from typing import Any, Callable, Dict, List, Optional, Pattern, Tuple, Union from datamasking.masking import constants as _cfg from datamasking.masking import surname as _surname from datamasking.masking import custom as _custom from datamasking.masking.context import ( analyze_number_sign_context, analyze_br_keyword, assign_pib_roles, - looks_like_pib_line, parse_hybrid_line, WORD_SEPARATOR_RE, + is_name_patronymic_pair, looks_like_pib_line, parse_hybrid_line, WORD_SEPARATOR_RE, ) from datamasking.masking.declension import analyze_patronymic from datamasking.masking.helpers import get_deterministic_seed, add_to_mapping @@ -53,8 +55,13 @@ def _rank_hints(rank: Optional[str]) -> Tuple[Optional[str], Optional[str]]: # Прізвище у Title Case, зокрема подвійне з великої після дефіса # (Петренко-Іванова, Нечуй-Левицький) _SURNAME_RE = r'[А-ЯІЇЄҐ][а-яіїєґ\'ʼ’]{2,}(?:-[А-ЯІЇЄҐ]?[а-яіїєґ\'ʼ’]{2,})?' -_SURNAME_UPPER_RE = r'[А-ЯІЇЄҐ]{3,}' +# КАПСОМ, зокрема подвійне (ПЕТРЕНКО-КОВАЛЬ): до 3.1.20 включно бралась лише одна +# частина, і друга (чи перша — «ПЕТРЕНКО» у «ПЕТРЕНКО-КОВАЛЬ І.М.») лишалась +_SURNAME_UPPER_RE = r'[А-ЯІЇЄҐ][А-ЯІЇЄҐ\'ʼ’]{2,}(?:-[А-ЯІЇЄҐ][А-ЯІЇЄҐ\'ʼ’]+)?' _NAME_RE = r'(?:' + _SURNAME_RE + r'|' + _SURNAME_UPPER_RE + r')' +# Прізвище перед ініціалами починається з початку слова (не з «КОВАЛЬ» +# усередині «ПЕТРЕНКО-КОВАЛЬ») +_NAME_START = r"(? Tuple[Optional[str], Optional[str]]: # Прізвище + 2 ініціали: Іванов П.А. / Іванов П. А. / ІВАНОВ П.А. / Іванов П.А (без # останньої крапки — лише якщо далі не літера) _RE_NAME_INI2 = re.compile( - r'(' + _NAME_RE + r')' + _SP + r'+([А-ЯІЇЄҐ])\.' + _SP + r'*([А-ЯІЇЄҐ])(?:\.|(?![а-яіїєґА-ЯІЇЄҐa-zA-Z\'ʼ’]))' + _NAME_START + r'(' + _NAME_RE + r')' + _SP + r'+([А-ЯІЇЄҐ])\.' + _SP + r'*([А-ЯІЇЄҐ])(?:\.|(?![а-яіїєґА-ЯІЇЄҐa-zA-Z\'ʼ’]))' ) # 2 ініціали + Прізвище: П.А. Іванов / П. А. Іванов _RE_INI2_NAME = re.compile( @@ -72,13 +79,25 @@ def _rank_hints(rank: Optional[str]) -> Tuple[Optional[str], Optional[str]]: ) # Прізвище + 1 ініціал: Іванов П. _RE_NAME_INI1 = re.compile( - r'(' + _NAME_RE + r')' + _SP + r'+([А-ЯІЇЄҐ])\.(?![а-яіїєґА-ЯІЇЄҐa-zA-Z])' + _NAME_START + r'(' + _NAME_RE + r')' + _SP + r'+([А-ЯІЇЄҐ])\.(?![а-яіїєґА-ЯІЇЄҐa-zA-Z])' ) # 1 ініціал + Прізвище: П. Іванов _RE_INI1_NAME = re.compile( r'(? bool: @@ -144,6 +163,41 @@ def _left_token(text: str, pos: int) -> str: return tok +def _non_overlapping(candidates: List[Any], penalized: frozenset = frozenset()) -> List[Any]: + """Неперекривні кандидати (start = [0], end = [1]) з найбільшою + кількістю; за рівної — з меншою кількістю *penalized* (індекси), далі — + з найбільшою сумарною довжиною, далі — раніші в списку. Для одного + перекриття без штрафів — те саме, що «найдовший перший».""" + order = sorted(range(len(candidates)), key=lambda i: candidates[i][1]) + ends = [candidates[i][1] for i in order] + # best[k] — найкращий вибір серед перших k (за кінцем) кандидатів: + # (кількість, -штрафи, довжина, -сума індексів), попередній стан, узятий кандидат + best: List[Tuple[Tuple[int, int, int, int], int, int]] = [((0, 0, 0, 0), -1, -1)] + for k, i in enumerate(order): + start, end = candidates[i][0], candidates[i][1] + j = bisect.bisect_right(ends, start, 0, k) + prev = best[j][0] + take = (prev[0] + 1, prev[1] - (i in penalized), prev[2] + end - start, prev[3] - i) + best.append((take, j, i) if take > best[k][0] else (best[k][0], k, -1)) + chosen = [] + k = len(order) + while k > 0: + _score, back, taken = best[k] + if taken >= 0: + chosen.append(candidates[taken]) + k = back + return chosen + + +def _patronymic_after_name(text: str, pos: int, word: str) -> bool: + """Слово перед ініціалами — по батькові після імені («Мороз Ольги + Богданівни Л.А. Мороз»), а не прізвище: до 3.1.20 включно «Богданівни» маскувалось + як прізвище, далі основний розбір маскував маску ще раз, і unmask + повертав «Богимовичи».""" + before = text[:pos].split() + return bool(before) and is_name_patronymic_pair(before[-1], word) + + def _mask_initials_pib(text: str, masking_dict: Dict, instance_counters: Dict) -> str: """ Знаходить ПІБ з ініціалами та маскує їх. @@ -167,7 +221,7 @@ def _mask_initials_pib(text: str, masking_dict: Dict, instance_counters: Dict) - # до 3.1.9 вони зводились до одного пробілу, і unmask повертав не оригінал for m in _RE_NAME_INI2.finditer(text): surname, i1, i2 = m.group(1), m.group(2), m.group(3) - if _is_surname_candidate(surname): + if _is_surname_candidate(surname) and not _patronymic_after_name(text, m.start(1), surname): mid_gap = text[m.end(2) + 1:m.start(3)] sur_gap = text[m.end(1):m.start(2)] # «Петренко О.П» без останньої крапки — маска теж без неї @@ -183,7 +237,7 @@ def _mask_initials_pib(text: str, masking_dict: Dict, instance_counters: Dict) - for m in _RE_NAME_INI1.finditer(text): surname, i1 = m.group(1), m.group(2) - if _is_surname_candidate(surname): + if _is_surname_candidate(surname) and not _patronymic_after_name(text, m.start(1), surname): candidates.append((m.start(), m.end(), surname, [i1], "", text[m.end(1):m.start(2)], False, True)) for m in _RE_INI1_NAME.finditer(text): @@ -191,12 +245,29 @@ def _mask_initials_pib(text: str, masking_dict: Dict, instance_counters: Dict) - if _is_surname_candidate(surname) and _left_token(text, m.start()) not in _NON_INITIAL_LEFT: candidates.append((m.start(), m.end(), surname, [i1], "", text[m.end(1) + 1:m.start(2)], True, True)) - # Довші патерни мають пріоритет; знімаємо перекриття - candidates.sort(key=lambda x: (x[1] - x[0]), reverse=True) - kept: List[Tuple[int, int, str, List[str], str, str, bool, bool]] = [] - for c in candidates: - if not any(c[0] < k[1] and c[1] > k[0] for k in kept): - kept.append(c) + # Через перенос рядка — програють рівному кандидатові в межах рядка, але + # беруться, коли дають ще одне ПІБ («Ю.А. ЗІНЧЕНКО Н.І.\nСАВЧУК») + crossing_from = len(candidates) + if '\n' in text: + for m in _RE_NAME_BREAK_INI2.finditer(text): + surname, i1, i2 = m.group(1), m.group(2), m.group(3) + if (_is_surname_candidate(surname) + and not _patronymic_after_name(text, m.start(1), surname)): + candidates.append((m.start(), m.end(), surname, [i1, i2], text[m.end(2) + 1:m.start(3)], + text[m.end(1):m.start(2)], False, True)) + for m in _RE_INI2_BREAK_NAME.finditer(text): + i1, i2, surname = m.group(1), m.group(2), m.group(3) + if (_is_surname_candidate(surname) + and _left_token(text, m.start()) not in _NON_INITIAL_LEFT): + candidates.append((m.start(), m.end(), surname, [i1, i2], text[m.end(1) + 1:m.start(2)], + text[m.end(2) + 1:m.start(3)], True, True)) + + # Знімаємо перекриття: якомога більше ПІБ, за рівної кількості — довші, + # далі — у порядку пошуку. До 3.1.20 включно брався найдовший першим: + # у «Ю.А. Зінченко Н.І. Савчук» перемагало «Зінченко Н.І.», і «Ю.А.» та + # «Савчук» лишались відкритими + kept: List[Tuple[int, int, str, List[str], str, str, bool, bool]] = _non_overlapping( + candidates, frozenset(range(crossing_from, len(candidates)))) # Фаза 2: у порядку документа маскуємо, записуємо mapping # і збираємо результат сегментами (O(n)) @@ -272,7 +343,12 @@ def _unmasked_residue(text: str, masking_dict: Dict, instance_counters: Dict, for m in _get_rank_forms_re().finditer(text): info = _cfg.RANK_TO_NOMINATIVE.get(" ".join(m.group(0).split()).lower()) if info: - rank_counts[info[0]] = rank_counts.get(info[0], 0) + 1 + # Ключ — як у mapping: без «медичної служби» / «юстиції» + # (mask_rank отримує базове звання). До 3.1.20 включно відкрите + # «капітан медичної служби» рахувалось окремо від «капітан», + # могло стати маскою іншого звання, і unmask його підміняв + base = extract_base_rank(info[0])[0].lower() + rank_counts[base] = rank_counts.get(base, 0) + 1 token_counts: Dict[str, int] = {} for m in _TOKEN_RE.finditer(text): tok = m.group(0).lower() @@ -308,12 +384,16 @@ def _get_broken_ranks_re() -> Optional[Pattern[str]]: _BROKEN_RANKS_RE = re.compile(r'(?i)\b(' + '|'.join(patterns) + r')\b') return _BROKEN_RANKS_RE -def normalize_broken_ranks(text: str) -> str: +def normalize_broken_ranks(text: str, line_joiner: Optional[str] = None) -> str: """ Нормалізує розірвані звання у тексті (Bug Fix #15). Звання може бути розірване переносом рядка в документах: - "старшого\nсержанта" -> "старшого сержанта" + + *line_joiner* (рушій, після 3.1.20): перенос замінюється цим символом, а + пробіли довкола лишаються — після маскування рушій повертає перенос, і + розмаскований текст збігається з оригіналом. """ pattern = _get_broken_ranks_re() if pattern is None: @@ -323,11 +403,87 @@ def replace_match(match): # Лише розрив переносом рядка; таб чи подвійний пробіл між словами # звання лишаються (до 3.1.7 стискались безповоротно — unmask не # відновлював оригінальні роздільники) + if line_joiner is not None: + return match.group(0).replace('\n', line_joiner) return re.sub(r'\s*\n\s*', ' ', match.group(0)) return pattern.sub(replace_match, text) +# Службовий пробіл на місці переносу рядка всередині звання чи ПІБ на час +# розбору (рядок розбирається цілком): для str.split() і \s це пробіл, але +# не межа рядка; після маскування знову стає '\n' (після 3.1.20) +_LINE_JOIN = "\x1f" + + +_RANK_TAIL_WORDS: Optional[frozenset] = None + + +def _rank_tail_words() -> frozenset: + """Слова, якими може закінчуватись звання перед ПІБ («сержанта», + «служби», «відставці»).""" + global _RANK_TAIL_WORDS + if _RANK_TAIL_WORDS is None: + words = {w for form in _cfg.ALL_RANK_FORMS for w in form.split()} + words |= {"служби", "юстиції", "відставці", "запасі", "запасу", "резерві", "пенсії"} + _RANK_TAIL_WORDS = frozenset(words) + return _RANK_TAIL_WORDS + + +def _pib_crosses_break(prev: str, nxt: str) -> bool: + """Чи розриває перенос між *prev* і *nxt* ПІБ із по батькові («Петренка + Івана\\nІвановича», «Шановний Олегу\\nПетровичу!») або відділяє звання + від ПІБ («старшого сержанта\\nПетренка Івана Івановича»). + + Рядки розбираються разом, як основний цикл: знайдений ПІБ (і звання + перед ним) замінюється підкресленнями тієї самої довжини, далі — + наступний, поки не знайдеться той, що перетинає межу.""" + tail, head = prev.split()[-1:], nxt.split()[:1] + if not tail or not head or not head[0][:1].isupper(): + return False + last = tail[0].strip(".,;:!?»\"'()").lower() + if not (tail[0][:1].isupper() or last in _rank_tail_words()): + return False + fragment = prev + " " + nxt + boundary = len(prev) + if not looks_like_pib_line(fragment): + return False + for _ in range(50): + rank, pib, _ = parse_hybrid_line(fragment) + if not pib or "___" in pib: + return False + rank_span, span = locate_rank_and_pib(fragment, rank or "", pib) + if span is None: + return False + if span[0] < boundary < span[1]: + return bool(assign_pib_roles(pib.split())[2]) + if rank_span is not None and rank_span[1] <= boundary < span[0]: + return True + start = rank_span[0] if rank_span is not None else span[0] + replaced = fragment[:start] + "_" * (span[1] - start) + fragment[span[1]:] + if replaced == fragment: + return False + fragment = replaced + return False + + +def _join_broken_pib_lines(text: str) -> str: + """Рядки, між якими перенос розриває ПІБ (див. _pib_crosses_break), + склеюються через _LINE_JOIN: інакше частина ПІБ на іншому рядку лишалась + відкритою («Петренка\\nІвана Івановича» — прізвище, «Петренка Івана\\n + Івановича» — по батькові).""" + if '\n' not in text: + return text + lines = text.split('\n') + out = [lines[0]] + for nxt in lines[1:]: + if _pib_crosses_break(out[-1], nxt): + out[-1] = out[-1] + _LINE_JOIN + nxt + else: + out.append(nxt) + return '\n'.join(out) + + # Лапки (відкриваючі/закриваючі будь-якого стилю) для пошуку значень у лапках _QUOTED_RE = re.compile(r'([«"„“\'])([^«»"„“”\']{1,60})([»"”“\'])') @@ -498,6 +654,71 @@ def locate_words_before(line: str, phrase: str, before: int) -> Optional[Tuple[i return best.start(), best.end(), list(best.groups()) +def _words_pattern(phrase: str) -> str: + return (r"(? Tuple[Optional[Tuple[int, int, List[str]]], + Optional[Tuple[int, int, List[str]]]]: + """(звання, ПІБ) — входження ПІБ, перед яким упритул (через пробіли чи + лапки) стоїть звання, як їх і знаходить розбір. Немає такої пари — (None, + перше входження ПІБ). До 3.1.20 включно ПІБ брався за першим входженням у рядку: + у «М.О. Ткачук Адмірал Ткачук» звання діставалось першому «Ткачук».""" + if rank and rank.split() and pib.split(): + rank_matches = list(re.finditer(_words_pattern(rank), line)) + for m in re.finditer(_words_pattern(pib), line): + for r in reversed(rank_matches): + if r.end() <= m.start(): + if _RANK_PIB_GAP_RE.fullmatch(line[r.end():m.start()]): + return ((r.start(), r.end(), list(r.groups())), + (m.start(), m.end(), list(m.groups()))) + break + return None, locate_words(line, pib) + + +def _locate_from(line: str, phrase: str, pos: int) -> Optional[Tuple[int, int, List[str]]]: + """Перше входження *phrase*, що починається не раніше *pos*.""" + if not phrase.split(): + return None + m = re.compile(_words_pattern(phrase)).search(line, pos) + return (m.start(), m.end(), list(m.groups())) if m else None + + +def _mask_rank_in_line(rank: str, separators: List[str], masking_dict: Dict, + instance_counters: Dict) -> str: + """Маска звання з роздільниками оригіналу (відкладене маскування рядка).""" + masked = mask_rank_preserve_case(rank, masking_dict, instance_counters) + return join_with_separators(masked.split(), separators) + + +def _mask_pib_in_line(parts: List[str], rank: Optional[str], separators: List[str], + masking_dict: Dict, instance_counters: Dict) -> str: + """Маска ПІБ із 2+ слів (відкладене маскування рядка).""" + # Ролі слів: «Петренко Олег Петрович», «Іван ПЕТРЕНКО» (прізвище капсом + # після імені), «Олегу Петровичу» (без прізвища), «Олегу Петровичу + # Петренку» (v3.1.13) + surname, name, patronymic, order = assign_pib_roles(parts) + # Рід і відмінок імені: по батькові (і в кличному — «Тетяно Петрівно»), + # інакше — звання поруч (v3.1.12) + rank_case, rank_gender = _rank_hints(rank) + pat_gender = analyze_patronymic(patronymic).gender if patronymic else 'unknown' + gender_hint = pat_gender if pat_gender != 'unknown' else rank_gender + masked_roles: Dict[str, str] = {} + if surname: + masked_roles['surname'] = mask_surname(surname, masking_dict, instance_counters) if _cfg.MASK_SURNAMES else surname + masked_roles['name'] = mask_name(name, masking_dict, instance_counters, gender_hint=gender_hint, + patronymic_hint=patronymic, case_hint=rank_case) if _cfg.MASK_NAMES else name + if patronymic: + masked_roles['patronymic'] = mask_patronymic(patronymic, gender_hint or 'male', masking_dict, instance_counters) + masked_parts = [masked_roles[role] for role in order if role in masked_roles] + # Роздільники між словами (таб, «|», подвійний пробіл) — як в оригіналі + return join_with_separators(masked_parts, separators) + + def join_with_separators(words: List[str], separators: List[str]) -> str: """Склеює слова оригінальними роздільниками (їх на 1 менше, ніж слів; якщо кількість слів змінилась — одинарні пробіли).""" @@ -564,6 +785,8 @@ def _mask_text_core(text: str, masking_dict: Dict, instance_counters: Dict, # формою (див. mask_rank, v3.1.14) — і в пробному, і в основному прогоні saved_feminine = _cfg.FEMININE_RANK_BASES _cfg.FEMININE_RANK_BASES = _feminine_rank_bases(text) + saved_spaced = _cfg.SPACED_RANK_BASES + _cfg.SPACED_RANK_BASES = _spaced_rank_bases(text) saved = _cfg.AVOID_RANK_MASKS try: # Звання і слова, що лишаться відкритими, — заборонені як маски (див. _unmasked_residue) @@ -574,6 +797,7 @@ def _mask_text_core(text: str, masking_dict: Dict, instance_counters: Dict, finally: _cfg.AVOID_RANK_MASKS = saved _cfg.FEMININE_RANK_BASES = saved_feminine + _cfg.SPACED_RANK_BASES = saved_spaced _surname.set_residue(None) return ("\ufeff" + text) if bom else text @@ -589,11 +813,54 @@ def _feminine_rank_bases(text: str) -> frozenset: return frozenset(bases) +# Роздільник між словами, який не є одним звичайним пробілом +_IRREGULAR_SPACE = re.compile(r"[^\S ]| {2,}") + + +_RANK_SPAN_CACHE: Dict[int, Pattern] = {} + + +def _rank_span_regex() -> Pattern: + """Будь-яка форма звання (довші — першими) з необов'язковими «медичної + служби» / «юстиції» і «у відставці» / «в запасі» …; слова — через будь-які + пробільні роздільники.""" + key = id(_cfg.RANK_TO_NOMINATIVE) + if key not in _RANK_SPAN_CACHE: + forms = sorted(_cfg.RANK_TO_NOMINATIVE, key=len, reverse=True) + alternatives = "|".join(r"\s+".join(re.escape(w) for w in f.split()) for f in forms) + _RANK_SPAN_CACHE[key] = re.compile( + r"(? frozenset: + """Базові звання (як їх отримує mask_rank: без «медичної служби» / + «юстиції»), записані в тексті з нестандартним роздільником між словами + («старший\\xa0сержант», «сержант\\xa0медичної служби»).""" + if not _IRREGULAR_SPACE.search(text): + return frozenset() + bases = set() + for match in _rank_span_regex().finditer(text): + if _IRREGULAR_SPACE.search(match.group(0)): + base_text = extract_base_rank(" ".join(match.group(0).split()))[0] + base = get_rank_info(base_text)[0] + if base: + bases.add(base.lower()) + return frozenset(bases) + + def _mask_text_core_impl(text: str, masking_dict: Dict, instance_counters: Dict, kept: Optional[List[Tuple[str, str]]] = None) -> str: + # Переноси всередині звання чи ПІБ — службовий пробіл на час розбору + # (у кінці знову '\n'); якщо цей символ уже є в тексті — як у 3.1.20 + line_join = _LINE_JOIN not in text + # === ШАГ 0: Нормалізація розірваних звань if _cfg.RANK_LINE_BREAK_FIX: - text = normalize_broken_ranks(text) + text = normalize_broken_ranks(text, _LINE_JOIN if line_join else None) # === ШАГ 0.5: ПІБ з ініціалами (Іванов П.А., П. Іванов тощо) # Запускаємо ДО основного парсера, щоб ініціали не плутали looks_like_pib_line @@ -786,6 +1053,8 @@ def _phase_date_text() -> None: segments.append(text[prev_end:]) text = ''.join(segments) + if line_join: + text = _join_broken_pib_lines(text) lines = text.split('\n') masked_lines = [] for line in lines: @@ -802,9 +1071,11 @@ def _phase_date_text() -> None: # щойно вставленого «старшого солдата» → «старшого рядового» (такого # звання немає), а справжнє «солдата» лишалось відкритим. current_line_for_parsing = line - placeholders: List[Tuple[str, str]] = [] + # Значення — рядок або відкладене маскування: маски обчислюються + # після розбору рядка в порядку позицій (див. нижче) + placeholders: List[Tuple[str, Union[str, Callable[[], str]]]] = [] - def _hold(kind: str, value: str) -> str: + def _hold(kind: str, value: Union[str, Callable[[], str]]) -> str: token = f"___{kind}_MASKED_{len(placeholders) + 1}___" placeholders.append((token, value)) return token @@ -830,13 +1101,18 @@ def _replace_span(line: str, span: Tuple[int, int, List[str]], token: str) -> st break if rank and _cfg.MASK_RANKS: - rank_span = locate_words_before(current_line_for_parsing, rank, pib_span[0]) + # Звання й ПІБ — пара, що стоїть упритул (як її знаходить + # розбір), а не перше входження ПІБ у рядку + rank_span, adjacent_pib = locate_rank_and_pib(current_line_for_parsing, rank, pib) + if rank_span is None: + rank_span = locate_words_before(current_line_for_parsing, rank, pib_span[0]) if rank_span is not None: - masked_rank_val = mask_rank_preserve_case(rank, masking_dict, instance_counters) - masked_rank_val = join_with_separators(masked_rank_val.split(), rank_span[2]) - current_line_for_parsing = _replace_span(current_line_for_parsing, rank_span, - _hold("RANK", masked_rank_val)) - pib_span = locate_words(current_line_for_parsing, pib) + token = _hold("RANK", functools.partial(_mask_rank_in_line, rank, rank_span[2], + masking_dict, instance_counters)) + current_line_for_parsing = _replace_span(current_line_for_parsing, rank_span, token) + after_rank = rank_span[0] + len(token) + pib_span = ((_locate_from(current_line_for_parsing, pib, after_rank) if adjacent_pib else None) + or locate_words(current_line_for_parsing, pib)) if pib_span is None: break @@ -870,34 +1146,18 @@ def _replace_span(line: str, span: Tuple[int, int, List[str]], token: str) -> st iteration += 1 continue if len(parts) >= 2: - # Ролі слів: «Петренко Олег Петрович», «Іван ПЕТРЕНКО» - # (прізвище капсом після імені), «Олегу Петровичу» (без - # прізвища), «Олегу Петровичу Петренку» (v3.1.13) - surname, name, patronymic, order = assign_pib_roles(parts) - # Рід і відмінок імені: по батькові (і в кличному — - # «Тетяно Петрівно»), інакше — звання поруч (v3.1.12) - rank_case, rank_gender = _rank_hints(rank) - pat_gender = analyze_patronymic(patronymic).gender if patronymic else 'unknown' - gender_hint = pat_gender if pat_gender != 'unknown' else rank_gender - masked_roles: Dict[str, str] = {} - if surname: - masked_roles['surname'] = mask_surname(surname, masking_dict, instance_counters) if _cfg.MASK_SURNAMES else surname - masked_roles['name'] = mask_name(name, masking_dict, instance_counters, gender_hint=gender_hint, - patronymic_hint=patronymic, case_hint=rank_case) if _cfg.MASK_NAMES else name - if patronymic: - masked_roles['patronymic'] = mask_patronymic(patronymic, gender_hint or 'male', masking_dict, instance_counters) - masked_parts = [masked_roles[role] for role in order if role in masked_roles] - - # Роздільники між словами (таб, «|», подвійний пробіл) — як в оригіналі - masked_pib_str = join_with_separators(masked_parts, pib_span[2]) - current_line_for_parsing = _replace_span(current_line_for_parsing, pib_span, - _hold("PIB", masked_pib_str)) + current_line_for_parsing = _replace_span( + current_line_for_parsing, pib_span, + _hold("PIB", functools.partial(_mask_pib_in_line, parts, rank, pib_span[2], + masking_dict, instance_counters))) elif len(parts) == 1 and rank: # Звання + лише прізвище («рядовий Іванов прибув») — # раніше такий ПІБ узагалі не маскувався - masked_surname = mask_surname(parts[0], masking_dict, instance_counters) if _cfg.MASK_SURNAMES else parts[0] - current_line_for_parsing = _replace_span(current_line_for_parsing, pib_span, - _hold("PIB", masked_surname)) + lone = parts[0] + current_line_for_parsing = _replace_span( + current_line_for_parsing, pib_span, + _hold("PIB", (functools.partial(mask_surname, lone, masking_dict, instance_counters) + if _cfg.MASK_SURNAMES else lone))) else: # Нічого не замінено (напр. один ПІБ-кандидат без звання) — # сховати фрагмент, щоб цикл не крутився на тому ж місці @@ -908,11 +1168,19 @@ def _replace_span(line: str, span: Tuple[int, int, List[str]], token: str) -> st _hold("PIB", current_line_for_parsing[pib_span[0]:pib_span[1]])) iteration += 1 + final_line = current_line_for_parsing + # Маски — у порядку позицій у рядку, а не розбору: розбір бере ПІБ + # після звання раніше за ПІБ перед ним, а unmask роздає номери + # входжень маски в порядку тексту. Коли дві різні форми дістають + # однакову маску («Іване» і «Івану» → «Ігорю»), до 3.1.20 включно вони + # розмасковувались навпаки + values: Dict[str, str] = {} + for token, value in sorted(placeholders, key=lambda tv: final_line.find(tv[0])): + values[token] = value() if callable(value) else value # У зворотному порядку: значення пізнішого плейсхолдера може містити # раніший токен (перестраховка — див. перевірку "___" вище) - final_line = current_line_for_parsing - for token, value in reversed(placeholders): - final_line = final_line.replace(token, value, 1) + for token, _value in reversed(placeholders): + final_line = final_line.replace(token, values[token], 1) masked_lines.append(final_line) text = '\n'.join(masked_lines) @@ -924,6 +1192,8 @@ def _replace_span(line: str, span: Tuple[int, int, List[str]], token: str) -> st # основного циклу, з пропуском уже замаскованих форм text = _mask_quoted_ranks(text, masking_dict, instance_counters) + if line_join: + text = text.replace(_LINE_JOIN, '\n') return text def mask_json_recursive(data: Any, masking_dict: Dict, instance_counters: Dict) -> Any: diff --git a/datamasking/masking/mask_military.py b/datamasking/masking/mask_military.py index 9c7260b..b5092e2 100644 --- a/datamasking/masking/mask_military.py +++ b/datamasking/masking/mask_military.py @@ -155,7 +155,9 @@ def mask_brigade_number(original: str, masking_dict: Dict, instance_counters: Di if original in masking_dict["mappings"]["brigade_number"]: masked = masking_dict["mappings"]["brigade_number"][original]["masked_as"] else: - match = re.match(r'(\d+)((?:-[а-яіїєґА-ЯІЇЄҐ]{1,2})?\s+.+)', original) + # DOTALL: назва бригади може бути розірвана переносом рядка; без нього + # «.+» зупинялось на \n, і решта назви зникала з маски (після 3.1.20) + match = re.match(r'(\d+)((?:-[а-яіїєґА-ЯІЇЄҐ]{1,2})?\s+.+)', original, re.DOTALL) if not match: return original number, rest = match.group(1), match.group(2) seed = get_deterministic_seed(original) @@ -306,11 +308,17 @@ def mask_date_text(original: str, masking_dict: Dict, instance_counters: Dict) - # ============================================================================ def get_rank_category_and_match(text: str) -> Tuple[Optional[str], Optional[str]]: + """Категорія звання та збіг. Найдовший збіг серед усіх категорій (за + рівної довжини — перша категорія): до 3.1.20 включно армійський шаблон знаходив + «капітан» у «капітан 1-го рангу» і «капітан-лейтенант», і флотське + звання маскувалось армійським («майор»).""" text_lower = text.lower() + best: Tuple[Optional[str], Optional[str]] = (None, None) for category, pattern in _cfg.RANK_PATTERNS.items(): match = re.search(pattern, text_lower, re.IGNORECASE) - if match: return category, match.group(0) - return None, None + if match and (best[1] is None or len(match.group(0)) > len(best[1])): + best = (category, match.group(0)) + return best def get_rank_info(rank_form: str) -> Tuple[Optional[str], Optional[str], Optional[str]]: rank_lower = rank_form.lower() @@ -411,6 +419,26 @@ def mask_rank(original: str, masking_dict: Dict, instance_counters: Dict) -> str continue break + # Звання, записане в документі з нерозривним пробілом, табом чи + # подвійним пробілом між словами, потребує маски з тією самою кількістю + # слів: роздільники переносяться в маску лише слово в слово, і + # «старший\xa0майстер-сержант» → «майстер-сержант» розмасковувався зі + # звичайним пробілом (після 3.1.20) + if search_key in _cfg.SPACED_RANK_BASES and len(masked.split()) != len(search_key.split()): + need_feminine = search_key in _cfg.FEMININE_RANK_BASES + n_words = len(search_key.split()) + for distance in range(1, len(hierarchy)): + for alt_idx in (idx + distance, idx - distance): + if 0 <= alt_idx < len(hierarchy): + alt = hierarchy[alt_idx] + if (alt.lower() != search_key and len(alt.split()) == n_words + and alt.lower() not in _cfg.AVOID_RANK_MASKS + and (not need_feminine or alt.lower() in _cfg.RANK_FEMININE_MAP)): + masked = alt + break + else: + continue + break # Store mapping by base form (nominative) for consistency final_masked = add_to_mapping(masking_dict, instance_counters, "rank", lookup_key, masked) diff --git a/datamasking/masking/surname.py b/datamasking/masking/surname.py index 81453db..3428749 100644 --- a/datamasking/masking/surname.py +++ b/datamasking/masking/surname.py @@ -62,6 +62,9 @@ ("кові", "ко"), ("ком", "ко"), ("ка", "ко"), ("ку", "ко"), ("ко", "ко"), # прикметникові -ий / -ій (Білий, Заболотній) ("ого", "ий"), ("ому", "ий"), ("им", "ий"), ("ий", "ий"), ("ій", "ий"), + # жіночий родовий прикметникових (Білої, Довгої): до 3.1.20 включно не + # розпізнавався, і маска «Білої» мала іншу основу, ніж «Біла» / «Білій» + ("ої", "а"), # родові -а/-я (Сорока, Гмиря) та відмінкові -и/-і/-ою/-ею ("ою", "а"), ("ею", "я"), ("и", "а"), ("і", "а"), ("а", "а"), ("я", "я"), # інші відмінкові закінчення приголосних основ (Коваля, Ковалем, Ковалю) diff --git a/datamasking/unmasking/engine.py b/datamasking/unmasking/engine.py index 1587169..35983e8 100644 --- a/datamasking/unmasking/engine.py +++ b/datamasking/unmasking/engine.py @@ -55,6 +55,13 @@ def _reapply_separators(raw: str, restored: str) -> str: return "".join(out) +def _rank_pattern(rank_form: str) -> str: + """Регулярний вираз звання: слова через будь-які пробільні роздільники, + межі — не літера/цифра і не дефіс («майор» у «генерал-майор» — не звання).""" + return (r'(? Tuple[str, Dict]: """ Відновлення звань з урахуванням гендеру та граматичних відмінків. @@ -82,8 +89,11 @@ def unmask_ranks_gender_aware(masked_text: str, masking_map: Dict) -> Tuple[str, covered = bytearray(len(restored_text) + 1) for rank_form in ALL_RANK_FORMS: # Між словами звання — будь-який пробільний роздільник (маскування - # зберігає таб/подвійний пробіл оригіналу, v3.1.7) - pattern = r'\b' + r'\s+'.join(re.escape(w) for w in rank_form.split()) + r'\b' + # зберігає таб/подвійний пробіл оригіналу, v3.1.7). Межа — не лише + # \b: звання не починається й не закінчується на дефісі, інакше + # «майор медичної служби» знаходилось усередині «генерал-майор + # медичної служби», перекривало його, і маска лишалась у тексті + pattern = _rank_pattern(rank_form) for match in re.finditer(pattern, restored_text, re.IGNORECASE): if 1 in covered[match.start():match.end()]: continue @@ -96,7 +106,7 @@ def unmask_ranks_gender_aware(masked_text: str, masking_map: Dict) -> Tuple[str, # Fallback: звання-маски, які відсутні у словнику for masked_rank in all_masked_ranks: - pattern = r'\b' + r'\s+'.join(re.escape(w) for w in masked_rank.split()) + r'\b' + pattern = _rank_pattern(masked_rank) for match in re.finditer(pattern, restored_text, re.IGNORECASE): overlaps = any( match.start() < found["end"] and match.end() > found["start"] diff --git a/tests/test_config_level1.py b/tests/test_config_level1.py index b75d4db..10ca3fb 100644 --- a/tests/test_config_level1.py +++ b/tests/test_config_level1.py @@ -189,7 +189,7 @@ class TestEngineToggles: def test_rank_line_break_fix_off(self, run, monkeypatch): from datamasking.masking import engine calls = [] - monkeypatch.setattr(engine, "normalize_broken_ranks", lambda t: calls.append(t) or t) + monkeypatch.setattr(engine, "normalize_broken_ranks", lambda t, *a: calls.append(t) or t) run({"masking_rules": {"rank_line_break_fix": False}}) assert calls == [] run({"masking_rules": {"rank_line_break_fix": True}}, out="o2.txt") diff --git a/tests/test_fuzz_findings.py b/tests/test_fuzz_findings.py new file mode 100644 index 0000000..5773c76 --- /dev/null +++ b/tests/test_fuzz_findings.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Знахідки фазера (після 3.1.20): розмаскування не повертало оригінал або +частина ПІБ лишалась відкритою. + + - звання «… медичної служби» з маскою «генерал-майор» не розмасковувалось + («майор медичної служби» знаходилось усередині «генерал-майор …»); + - флотські звання маскувались армійськими («капітан 1-го рангу» → «майор»); + - нерозривний пробіл чи таб усередині звання губився, коли маска мала + іншу кількість слів; + - перенос рядка всередині звання не відновлювався, а всередині ПІБ — + частина ПІБ на іншому рядку лишалась відкритою; + - ПІБ перед званням, після якого йдуть ще слова, не маскувалось зовсім; + - заголовок капсом перед ПІБ, прізвище-прикметник у родовому, кличний + «Любове», подвійне прізвище капсом і апостроф у прізвищі капсом + з ініціалами, по батькові перед ініціалами; + - відкрите звання «… медичної служби» ставало маскою іншого звання; + - номер бригади, розірваний переносом, губив решту назви; + - маски в одному рядку записувались не в порядку тексту. +""" +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from datamasking.masking import constants as _cfg # noqa: E402 +from datamasking.masking.context import parse_hybrid_line # noqa: E402 +from datamasking.masking.declension import VOCATIVE, analyze_name, analyze_patronymic # noqa: E402 +from datamasking.masking.engine import ( # noqa: E402 + _pib_crosses_break, _spaced_rank_bases, normalize_broken_ranks, +) +from datamasking.masking.mask_military import get_rank_category_and_match # noqa: E402 +from datamasking.masking.surname import split_surname # noqa: E402 +from datamasking.rank_data import NAVAL_RANKS # noqa: E402 +from datamasking.unmasking.engine import unmask_text_v2 # noqa: E402 +from datamasking.unmasking.helpers import check_mapping_version # noqa: E402 +from tests.test_initials import mask # noqa: E402 + + +def _rt(text): + masked, mapping = mask(text) + restored, _ = unmask_text_v2(masked, mapping, check_mapping_version(mapping)) + assert restored == text, (masked, restored) + return masked, mapping + + +def _absent(masked, *words): + for w in words: + assert w not in masked, (w, masked) + + +class TestServiceRankUnmask: + @pytest.mark.parametrize("text", [ + "полковник медичної служби Петренко Іван Іванович", + "Рапорт полковника медичної служби Петренка Івана Івановича", + "Надати відпустку полковнику медичної служби Петренку Івану Івановичу", + "з полковником медичної служби Петренком Іваном Івановичем", + "генерал-майора медичної служби Петренка Івана Івановича", + "полковнику медичної служби у відставці Петренку Івану Івановичу", + ]) + def test_roundtrip(self, text): + masked, _ = _rt(text) + _absent(masked, "Петренк", "Іван") + + def test_mask_is_hyphenated_rank(self): + masked, mapping = _rt("полковник медичної служби Петренко Іван Іванович") + assert mapping["mappings"]["rank"]["полковник"]["masked_as"] == "генерал-майор" + assert masked.startswith("генерал-майор медичної служби ") + + +class TestNavalRanks: + @pytest.mark.parametrize("rank, category, match", [ + ("капітан 1-го рангу", "naval", "капітан 1-го рангу"), + ("капітан-лейтенант", "naval", "капітан-лейтенант"), + ("капітан", "army", "капітан"), + ("сержант", "army", "сержант"), + ("старший матрос", "naval", "старший матрос"), + ]) + def test_category(self, rank, category, match): + assert get_rank_category_and_match(rank) == (category, match) + + @pytest.mark.parametrize("text", [ + "капітан 1-го рангу Лисенко Богдан Романович", + "капітану 3-го рангу Петру Васильовичу Сидорову оголосити подяку", + "капітан-лейтенант Лисенко Богдан Романович", + ]) + def test_mask_is_naval(self, text): + masked, mapping = _rt(text) + masks = [v["masked_as"] for v in mapping["mappings"]["rank"].values()] + assert masks and all(m in NAVAL_RANKS for m in masks), masks + + +class TestSpacedRanks: + @pytest.mark.parametrize("text", [ + "капітан\xa01-го рангу Лисенко Богдан Романович", + "старший\xa0майстер-сержант Коваль Галина Іванівна", + "старшому\xa0моряку Вікторії Ігорівні Ковальській", + "старшій\xa0сержантці Коваль Галині Іванівні", + "сержант\xa0медичної\xa0служби Коваль Олег Іванович", + "старшому сержанту\xa0медичної служби Коваль Олегу Івановичу", + "полковнику медичної служби у\xa0відставці Петренку Івану Івановичу", + "капітан\tюстиції Коваль Олег Іванович", + "головний сержант Коваль Олег Іванович\nголовний сержант Петренко Іван Іванович", + ]) + def test_roundtrip_keeps_separators(self, text): + masked, _ = _rt(text) + assert ("\xa0" in masked) == ("\xa0" in text) and ("\t" in masked) == ("\t" in text) + + def test_bases(self): + assert _spaced_rank_bases("старший\xa0сержант Коваль") == frozenset({"старший сержант"}) + assert _spaced_rank_bases("сержант\xa0медичної служби Коваль") == frozenset({"сержант"}) + assert _spaced_rank_bases("старший сержант Коваль\nсолдат Петренко") == frozenset() + assert _cfg.SPACED_RANK_BASES == frozenset() + + +class TestLineBreaks: + @pytest.mark.parametrize("text", [ + "Призначити старшого\nсержанта Петренка Івана Івановича на посаду.", + "Призначити старшого сержанта\nПетренка Івана Івановича на посаду.", + "Призначити старшого сержанта Петренка\nІвана Івановича на посаду.", + "Призначити старшого сержанта Петренка Івана\nІвановича на посаду.", + "Призначити Петренка\nІвана Івановича на посаду.", + "Призначити Петренка\r\nІвана Івановича на посаду.\r\n", + "полковнику Олегу Петровичу\nПетренку оголосити подяку", + "Шановний Віталію\nРомановичу! полковник медичної служби у відставці", + "капітан медичної\nслужби в запасі Коваль Олег Петрович", + ]) + def test_masked_and_restored(self, text): + masked, _ = _rt(text) + _absent(masked, "Петренк", "Іван", "Олег", "Петрович", "Віталі", "Романович", "Коваль") + assert masked.count("\n") == text.count("\n") + + @pytest.mark.parametrize("prev, nxt", [ + ("1. солдат Петренко Іван Іванович", "2. солдат Коваль Олег Петрович"), + ("солдат Петренко Іван Іванович", "Коваль Олег Петрович"), + ("Командир військової частини", "полковник Петренко Іван Іванович"), + ("НАКАЗУЮ:", "Петренко Іван Іванович"), + ("Шановний Олегу Петровичу!", "Петренко повідомив про виконання."), + ("перший рядок", "другий рядок"), + ]) + def test_complete_lines_not_joined(self, prev, nxt): + assert not _pib_crosses_break(prev, nxt) + + def test_public_normalizer_unchanged(self): + assert normalize_broken_ranks("старшого\nсержанта") == "старшого сержанта" + + def test_list_lines_keep_structure(self): + text = "1. солдат Петренко Іван Іванович\n2. солдат Коваль Олег Петрович" + masked, _ = _rt(text) + assert masked.startswith("1. ") and "\n2. " in masked + + +class TestPibBeforeRank: + @pytest.mark.parametrize("text, kept", [ + ("Петренко Іван Іванович доповів капітану про виконання.", " доповів капітану про виконання."), + ("Олег Петрович доповів капітану про виконання.", " доповів капітану про виконання."), + ("Петренко Іван Іванович, 1990 р.н., солдат за контрактом", ", 1990 р.н., солдат за контрактом"), + ("Коваль Тетяна Сергіївна, рядовий склад роти, прибула.", ", рядовий склад роти, прибула."), + ("Шановний Олегу Петровичу! Прошу надати відпустку полковнику медичної служби", + "! Прошу надати відпустку полковнику медичної служби"), + ]) + def test_masked(self, text, kept): + masked, _ = _rt(text) + assert masked.endswith(kept), masked + _absent(masked, "Петренко", "Іван", "Олег", "Петрович", "Коваль", "Тетяна") + + def test_rank_pib_and_pib_before_it(self): + text = "Петренко Іван Іванович доповів капітану Коваленку Олегу Петровичу про виконання." + masked, mapping = _rt(text) + _absent(masked, "Петренко", "Коваленк", "Іван", "Олег") + assert set(mapping["mappings"]["rank"]) == {"капітан"} + + def test_rank_words_are_not_anchors(self): + assert parse_hybrid_line("Капітан Петренко Іван Іванович доповів")[:2] == ( + "Капітан", "Петренко Іван Іванович") + assert mask("Командир роти капітан доповів про виконання")[0] == "Командир роти капітан доповів про виконання" + + +class TestHeadingsAndAdjectives: + @pytest.mark.parametrize("text, surname", [ + ("ВИТЯГ З НАКАЗУ Коваль Сергій Васильович доповів", "Коваль"), + ("ВИТЯГ З НАКАЗУ Білий Василь Васильович доповів", "Білий"), + ("ВИТЯГ З НАКАЗУ БІЛИЙ Тарас Васильович доповів", "БІЛИЙ"), + ("Рапорт Білого Ярослава Миколайовича про відпустку.", "Білого"), + ]) + def test_surname_masked(self, text, surname): + masked, mapping = _rt(text) + assert surname in mapping["mappings"]["surname"], mapping["mappings"]["surname"] + _absent(masked, surname, "Васильович", "Миколайович") + assert masked.split()[0] == text.split()[0] + + def test_position_title_still_untouched(self): + text = "на посаду МАСКУВАЛЬНИКОМ ІМІТАЦІЙНОГО ВІДДІЛЕННЯ ІНЖЕНЕРНО-МАСКУВАЛЬНОГО ВЗВОДУ" + assert mask(text)[0] == text + + def test_feminine_adjective_genitive_shares_stem(self): + assert split_surname("Білої") == ("біл", "ої", "а") + masked, mapping = _rt("Біла Людмила Юріївна вибула.\nРапорт Білої Людмили Юріївни.\n" + "Надати Білій Людмилі Юріївні.\nз Білою Людмилою Юріївною") + masks = {v["masked_as"] for k, v in mapping["mappings"]["surname"].items()} + stems = {split_surname(m)[0] for m in masks} + assert len(stems) == 1, masks + + +class TestVocativeLyubov: + def test_analyze(self): + assert analyze_name("Любове")[:3] == ("любов", VOCATIVE, "female") + assert analyze_name("Любове", "female").known + + def test_address(self): + masked, mapping = _rt("Шановна Любове Андріївно!") + assert masked.startswith("Шановна ") and not mapping["mappings"]["surname"] + pat = masked.split()[2].rstrip("!") + assert analyze_patronymic(pat)[1:] == (VOCATIVE, "female"), masked + + +class TestInitials: + @pytest.mark.parametrize("text, words", [ + ("ПЕТРЕНКО-КОВАЛЬ І.М. прибув", ("ПЕТРЕНКО", "КОВАЛЬ")), + ("О.П. ПЕТРЕНКО-КОВАЛЬ підпис", ("ПЕТРЕНКО", "КОВАЛЬ")), + ("І.М. ДОРОШЕНКО-ҐУДЗЬ прибув", ("ДОРОШЕНКО", "ҐУДЗЬ")), + ("ПИЛИП'ЮК О.І. прибув", ("ПИЛИП", "'ЮК")), + ("О.І. ПИЛИП'ЮК прибув", ("ПИЛИП", "'ЮК")), + ("ознайомлений. Л.І.\nКоваль паспорт", ("Коваль",)), + ("підпис Петренко\nО.П. дата", ("Петренко",)), + ("О.І.\nПИЛИП'ЮК Дата народження 01.01.1990", ("ПИЛИП",)), + ]) + def test_surname_masked(self, text, words): + masked, _ = _rt(text) + _absent(masked, *words) + + @pytest.mark.parametrize("text", [ + "Видати квиток капітан-лейтенанта Мороз Ольги Богданівни Л.А. Мороз", + "Видати квиток сержанта Хмари Юрія Юрійовича О.Ю. ХМАРІ паспорт", + ]) + def test_patronymic_before_initials(self, text): + masked, mapping = _rt(text) + assert "Богданівни" not in mapping["mappings"]["surname"] + assert "Юрійовича" not in mapping["mappings"]["surname"] + + @pytest.mark.parametrize("text", ["Додаток А.\nПетренко прибув", "М.П.\nКомандир частини"]) + def test_not_initials(self, text): + assert mask(text)[0] == text + + def test_patronymic_shaped_surname_still_masked(self): + masked, _ = _rt("наказ Петрович О.П.") + assert "Петрович" not in masked + + +class TestOpenServiceRankNotAMask: + def test_open_rank_with_service(self): + text = "Командир роти капітан медичної служби\nпідполковник Петренко Іван Іванович" + masked, mapping = _rt(text) + assert masked.startswith("Командир роти капітан медичної служби\n") + assert mapping["mappings"]["rank"]["підполковник"]["masked_as"] != "капітан" + + +class TestBrigadeAcrossLines: + @pytest.mark.parametrize("nl", ["\n", "\r\n"]) + def test_rest_of_name_kept(self, nl): + text = f"у складі 60 окремої{nl}механізованої бригади прибув" + masked, _ = _rt(text) + assert masked.endswith(f" окремої{nl}механізованої бригади прибув") and "60" not in masked + + +class TestInstanceOrderInLine: + @pytest.mark.parametrize("text", [ + "Шановний Іване Дмитровичу! Надати відпустку генералу Зінченку Івану Дмитровичу з 01 по 10 число.", + "Шановна Тетяно Петрівно! Видати сержанту Коваль Тетяни Петрівни квиток та Коваль Тетяні Петрівні", + ]) + def test_roundtrip(self, text): + _rt(text) + + +class TestRankPibAdjacency: + """Звання й ПІБ — пара упритул, а не перше входження ПІБ у рядку. До + виправлення перевірка переносу рядка на «М.О. Ткачук Адмірал\\nТкачук + М.О.» дублювала фрагмент на кожній ітерації і зависала.""" + + def test_no_hang(self): + text = ("вих. № 633/60-дск від\n06.04.2029 №БР-289/52-К\nпаспорт 460628459, ІПН\n" + "3643164026 Командир військової\nчастини Згідно\xa0із Законом\nУкраїни від 08.03.2019 №\n" + "5855-IX М.О. Ткачук Адмірал\nТкачук М.О. — ознайомлений\nЗгідно із Законом України від\n" + "21.03.2026 № 970-IX Про\nпризначення на посаду та\nприсвоєння військових звань") + masked, _ = _rt(text) + assert "Ткачук" not in masked + # результат не важливий — лише що виклик завершується + _pib_crosses_break("5855-IX М.О. Ткачук Адмірал", "Ткачук М.О. — ознайомлений") + + def test_rank_goes_with_adjacent_surname(self): + masked, mapping = _rt("Ткачук доповів, а солдат Ткачук Іван Іванович прибув") + assert "Ткачук" not in masked and set(mapping["mappings"]["rank"]) == {"солдат"} + assert set(mapping["mappings"]["name"]) == {"Іван"} + + +class TestChainedInitials: + """«Ю.А. Зінченко Н.І. Савчук»: кандидати перекриваються; береться набір + із найбільшою кількістю ПІБ (раніше — найдовший перший: «Зінченко Н.І.», + і «Ю.А.» та «Савчук» лишались відкритими).""" + + @pytest.mark.parametrize("text", [ + "Ю.А. Зінченко Н.І. Савчук підписали", + "Ю.А. ЗІНЧЕНКО Н.І. САВЧУК підписали", + "на поточний період. Ю.А. ЗІНЧЕНКО Н.І.\nСАВЧУК майстер-сержантом Ткачук О.І. —\nпідпис", + ]) + def test_all_masked(self, text): + masked, mapping = _rt(text) + _absent(masked, "Ю.А.", "Зінченко", "ЗІНЧЕНКО", "Савчук", "САВЧУК", "Н.І.") + assert len(mapping["mappings"]["surname"]) >= 2 + + @pytest.mark.parametrize("text, kept", [ + ("Кім О.П. Петренко", "Кім "), # один збіг — як раніше, довший + ("Петренко\nО.П. Коваль", "Петренко\n"), # у межах рядка — перед переносом + ]) + def test_single_overlap_unchanged(self, text, kept): + masked, _ = _rt(text) + assert masked.startswith(kept), masked