#!/usr/bin/env python3 """Детерминированное выравнивание zh↔ru предложениями — $0, БЕЗ моделей и без torch. Вынесено из `qe_segment.py` по находке самопроверки: выравнивание нужно и батарее Ф0.5, и QE-детектору, но батарея не должна тянуть за собой 4.6 ГБ весов и отдельный venv. Модуль импортируется обоими и проверяется `selfcheck.py` в базовом окружении полигона. Сюда же вынесен генератор ИНВЕРСИЙ ПОЛЯРНОСТИ: он тоже детерминированный, тоже $0 и тоже нужен двум разным потребителям (замеру мощности QE и арму починки Ф2б). Первая редакция держала его в `qe_power.py`, который тянет torch, — и арм починки падал на импорте 4.6 ГБ весов, которые ему не нужны вовсе. Тот же класс дефекта, что уже был пойман самопроверкой на выравнивании. """ from __future__ import annotations import re import pymorphy3 _MORPH = pymorphy3.MorphAnalyzer() def _is_finite_verb(word: str) -> bool: for p in _MORPH.parse(word.lower()): if p.is_known and p.tag.POS == "VERB": return True return False # Терминаторы предложений: китайские полноширинные и латинские. Многоточие 。。。/…… не разрывается. _ZH_SPLIT = re.compile(r"(?<=[。!?;…])(?![。!?;…])") _RU_SPLIT = re.compile(r"(?<=[.!?…])\s+(?=[«\"(\[А-ЯЁA-Z—-])") def split_zh(t: str) -> list[str]: return [s.strip() for s in _ZH_SPLIT.split(t) if s.strip()] def split_ru(t: str) -> list[str]: out = [] for line in t.split("\n"): line = line.strip() if not line: continue out.extend(s.strip() for s in _RU_SPLIT.split(line) if s.strip()) return out def align(src: list[str], dst: list[str]) -> list[tuple[list[int], list[int]]]: """Монотонное выравнивание по длине (Гейл–Чёрч). Возвращает пары индексов. Стоимость пары — квадрат нормированного отклонения фактического отношения длин от среднего по паре текстов. Среднее берётся ИЗ САМИХ ТЕКСТОВ, а не константой 2.2 из exp07: константа снята на другом материале, и подставлять её сюда значит тащить чужую калибровку в новый замер. """ if not src or not dst: return [] ratio = sum(len(d) for d in dst) / max(1, sum(len(s) for s in src)) INF = float("inf") n, m = len(src), len(dst) cost = [[INF] * (m + 1) for _ in range(n + 1)] back: dict[tuple[int, int], tuple[int, int]] = {} cost[0][0] = 0.0 def pair_cost(si: int, sj: int, di: int, dj: int) -> float: ls = sum(len(x) for x in src[si:sj]) * ratio ld = sum(len(x) for x in dst[di:dj]) if ls + ld == 0: return 0.0 # Пропуск (1-0 / 0-1) штрафуется как полное расхождение — иначе выравнивание охотно # «теряет» предложения, и потерянный сегмент никогда не будет пред'явлен детектору. base = ((ls - ld) ** 2) / max(ls + ld, 1.0) return base + (6.0 if (sj == si or dj == di) else 0.0) for i in range(n + 1): for j in range(m + 1): if cost[i][j] == INF: continue for ds, dd in ((1, 1), (1, 2), (2, 1), (1, 0), (0, 1), (2, 2)): ni, nj = i + ds, j + dd if ni > n or nj > m or (ds == 0 and dd == 0): continue c = cost[i][j] + pair_cost(i, ni, j, nj) if c < cost[ni][nj]: cost[ni][nj] = c back[(ni, nj)] = (i, j) out: list[tuple[list[int], list[int]]] = [] cur = (n, m) while cur != (0, 0): prev = back.get(cur) if prev is None: return [] out.append((list(range(prev[0], cur[0])), list(range(prev[1], cur[1])))) cur = prev return out[::-1] def flip_polarity(sent: str) -> str | None: """Инвертирует полярность предложения: снимает «не» при глаголе либо вставляет его. Работает только по ЛИЧНОМУ глаголу или глаголу прошедшего времени — причастия и деепричастия исключены сознательно: «не» при них меняет смысл иначе и часто даёт неграмматичный результат, а неграмматичность ранкер поймает не как инверсию, а как порчу формы (другой класс). """ m = re.search(r"\bне\s+([А-Яа-яЁё]+)", sent) if m and _is_finite_verb(m.group(1)): return sent[:m.start()] + m.group(1) + sent[m.end():] for m in re.finditer(r"\b([А-Яа-яЁё]{3,})\b", sent): w = m.group(1) if sent[max(0, m.start() - 4):m.start()].strip().endswith("не"): continue # ⚠ Пропускаем глагол с ПРОПИСНОЙ: он открывает предложение или реплику, и вставка даёт # «не Стой» — неграмматичный текст. Ранкер оценил бы его как порчу ФОРМЫ, то есть замер # мерил бы другой класс дефекта. Поймано проверкой генератора до прогона. if w[0].isupper(): continue if _is_finite_verb(w): return sent[:m.start()] + "не " + sent[m.start():] return None def _is_finite_verb(word: str) -> bool: for p in _MORPH.parse(word.lower()): if not p.is_known: continue if p.tag.POS == "VERB": return True return False