130 lines
6.4 KiB
Python
130 lines
6.4 KiB
Python
#!/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
|
||
|
||
|