-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpunct_rules.py
More file actions
265 lines (222 loc) · 15.5 KB
/
Copy pathpunct_rules.py
File metadata and controls
265 lines (222 loc) · 15.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
"""Запятые по правилам. Ноль зависимостей, доли миллисекунды.
Зачем. Модель по умолчанию (GigaAM v2 CTC) отдаёт сплошную строчную строку без
единого знака. Ставить их через Ollama оказалось дороже самого распознавания -
0.34 с против 0.24 с, - то есть полировка стала главным тормозом конвейера.
И вторая беда, менее очевидная: без запятых **перестаёт работать фильтр
бубнежа**. Половина его правил ищет паразитов, размеченных запятыми
(«Ну вот, короче, я думаю»), а в тексте без запятых им не за что зацепиться.
Поэтому запятые надо ставить ДО него, а не после.
Что здесь есть и чего нет. Это правила, а не понимание: они ставят запятую там,
где она нужна почти всегда, и молчат везде, где бывает по-разному. Лишняя
запятая раздражает сильнее недостающей, поэтому во всех спорных случаях -
молчим:
ставим: что, чтобы, который, если, когда, но, а, однако, зато...
не ставим: и, или - слишком много ложных срабатываний;
деепричастные и причастные обороты - их границы правилами
не берутся, нужен разбор предложения.
Настоящую расстановку делает модель уровня 2 (см. настройки «Знаки
препинания»). Эти правила остаются под ней запасным вариантом и работают,
когда модель не установлена.
"""
import re
# Многословные союзы - первыми, иначе «потому что» получит запятую перед
# «что», а не перед «потому», и выйдет «Не пришёл потому, что заболел» вместо
# «Не пришёл, потому что заболел».
_MULTI = (
"потому что", "так как", "так что", "как будто", "в то время как",
"несмотря на то что", "для того чтобы", "из-за того что",
)
# Однословные подчинительные союзы и союзные слова.
_SINGLE = (
"что", "чтобы", "если", "когда", "пока", "хотя", "поэтому",
"где", "куда", "откуда", "зачем", "почему",
)
# Противительные.
_ADVERSATIVE = ("но", "однако", "зато", "а")
# Предлоги, которые встают перед «который»: запятая нужна ПЕРЕД предлогом.
# «вот дом, В КОТОРОМ я живу», а не «вот дом в, котором я живу».
_PREPS = (
"в", "во", "на", "о", "об", "обо", "с", "со", "к", "ко", "из", "от",
"для", "при", "по", "за", "под", "над", "до", "у", "про", "через", "без",
"между", "перед",
)
# Вводные слова в начале фразы: после них запятая.
#
# Список намеренно короткий, и вот чего в нём НЕТ: «короче», «в общем»,
# «вообще», «правда». Все они бывают обычными словами - «короче путь»,
# «в общем виде», «правда жизни», - и запятая после них меняет смысл.
#
# Хуже того, она его УНИЧТОЖАЕТ: фильтр бубнежа опознаёт паразита по запятым
# вокруг, и «короче путь через двор» с нашей запятой превращался в «Путь через
# двор» - слово пропадало. Поймано тестом на живом конвейере.
#
# Эти слова разбирает _mark_lead, и разбирает осторожно: одно такое слово в
# начале не трогает вовсе, отделяет только связку из двух и более.
_INTRO = (
"кстати", "например", "наверное", "конечно", "во-первых", "во-вторых",
"в-третьих", "кажется", "по-моему", "к сожалению", "к счастью", "итак",
)
_ENDS = ".!?…"
# Бубнёж в начале фразы: «ну вот короче я думаю» -> «Ну вот, короче, я думаю».
#
# Запятые здесь не украшение. Фильтр бубнежа в cleaner.py опознаёт паразита НЕ
# по слову, а по запятым вокруг него - и это его главный предохранитель:
# «вот дом» и «короче путь» обычные фразы, и вырезать из них слово нельзя.
# Значит, расставляя запятые правилами, мы изготавливаем ту самую улику, по
# которой фильтр потом удалит слово. Ошибиться здесь - потерять текст.
#
# Поэтому правило узкое:
# - слово из _SURE («ну», «э-э», «угу») отсекаем и одно: другого смысла у
# него в начале фразы нет;
# - слово из _MAYBE («вот», «короче») - только в связке с соседним таким же.
# Одно «Вот дом» останется нетронутым, а «ну вот короче» - связка из трёх,
# и случайностью это быть не может.
_LEAD_SURE = ("ну вот", "ну", "э", "эм", "мм", "угу", "ага", "типа",
"как бы", "это самое")
_LEAD_MAYBE = ("вот", "короче", "в общем", "в принципе", "собственно",
"значит", "так сказать")
def _alt(words) -> str:
return "|".join(re.escape(w) for w in sorted(words, key=len, reverse=True))
# Общий вид правила: слева слово (а не начало строки и не знак), дальше пробел,
# дальше союз. Запятую ставим перед союзом.
_RE_MULTI = re.compile(
rf"(?<=[а-яёa-z0-9])(\s+)(?={_alt(_MULTI)}\b)", re.IGNORECASE)
_RE_SINGLE = re.compile(
rf"(?<=[а-яёa-z0-9])(\s+)(?={_alt(_SINGLE)}\b)", re.IGNORECASE)
_RE_ADVERS = re.compile(
rf"(?<=[а-яёa-z0-9])(\s+)(?={_alt(_ADVERSATIVE)}\b)", re.IGNORECASE)
# «который» в любом падеже и роде, с необязательным предлогом перед ним.
_RE_WHICH = re.compile(
rf"(?<=[а-яёa-z0-9])(\s+)(?=(?:(?:{_alt(_PREPS)})\s+)?котор\w+\b)",
re.IGNORECASE)
_RE_INTRO = re.compile(rf"^\s*({_alt(_INTRO)})\b(?![\s]*[,])", re.IGNORECASE)
# «что» внутри «что-то», «что-нибудь», «кое-что» союзом не является. Дефис
# после слова ловится границей \b, поэтому исключаем отдельно.
_NOT_UNION = re.compile(r",(\s+)(что|где|куда|как)(?=-)", re.IGNORECASE)
# Первые слова многословных союзов. Нужны, чтобы правило для «что» не влезло
# внутрь «потому что»: без этого выходило «Не пришёл, потому, что заболел» -
# две запятые вместо одной, потому что правило для «что» не знает, что перед
# ним половина союза.
#
# Проверяем предыдущим словом, а не просмотром назад в регулярке: просмотр
# назад в Python обязан быть фиксированной ширины, а слова у нас разной.
_MULTI_HEADS = frozenset(w.split()[-2] for w in _MULTI if len(w.split()) > 1)
_RE_LEAD_ANY = re.compile(
rf"^\s*(?:(?:{_alt(_LEAD_SURE + _LEAD_MAYBE)})\s*,?\s+)+", re.IGNORECASE)
_RE_LEAD = re.compile(
rf"^\s*((?:(?:{_alt(_LEAD_SURE + _LEAD_MAYBE)})\s+)+)", re.IGNORECASE)
def _mark_lead(text: str) -> str:
"""Отделить запятыми бубнёж в начале фразы. Подробности - у _LEAD_SURE."""
m = _RE_LEAD.match(text)
if not m:
return text
run, rest = m.group(1).strip(), text[m.end():]
run = run.rstrip(",")
if not rest:
return text # фраза целиком из бубнежа - не трогаем
# Разбираем связку на слова-паразиты, длинные раньше коротких.
words, pos = [], 0
low = run.lower()
while pos < len(run):
for w in sorted(_LEAD_SURE + _LEAD_MAYBE, key=len, reverse=True):
if low.startswith(w, pos) and (pos + len(w) == len(run)
or run[pos + len(w)].isspace()):
words.append(run[pos:pos + len(w)])
pos += len(w)
break
else:
return text # разобрать не вышло - лучше не трогать
while pos < len(run) and run[pos].isspace():
pos += 1
sure = words[0].lower() in _LEAD_SURE
if len(words) < 2 and not sure:
return text # одно неоднозначное слово - «Вот дом», не трогаем
return ", ".join(words) + ", " + rest
def normalize_lead(text: str) -> str:
"""Привести начало фразы к нашему виду. Зовётся ПОСЛЕ модели уровня 2.
Модель расставляет запятые хорошо, но ничего не знает про наш фильтр
бубнежа - а он удаляет слово, увидев запятую после него. На проверке это
вышло дважды и в обе стороны:
«короче путь через двор»
модель: «Короче, путь через двор.»
фильтр: «Путь через двор.» <- СЛОВО ПОТЕРЯНО
«ну вот короче я думаю что надо сделать»
модель: «Ну вот короче, я думаю...»
фильтр: не тронул ничего <- паразиты остались
Оба случая - про начало фразы, и решать их должны наши правила, а не
модель: у правил есть то, чего у неё нет, - знание, что запятая здесь
равносильна команде «удали это слово».
Поэтому: срезаем запятые, которые модель поставила внутри начальной связки,
и расставляем их заново по своему, осторожному правилу. Всё остальное в
предложении - работа модели, её не трогаем.
"""
t = (text or "").strip()
if not t:
return text
m = _RE_LEAD_ANY.match(t)
if not m:
return text
run, rest = m.group(0), t[m.end():]
if not rest:
return text
# Начальную связку разбираем заново из чистых слов, без запятых модели.
plain = re.sub(r"\s*,\s*", " ", run).strip()
fixed = _mark_lead(plain + " " + rest.lstrip())
return fixed if fixed else text
def _prev_word(text: str, pos: int) -> str:
return re.split(r"[^\w-]+", text[:pos].strip())[-1].lower() if text[:pos].strip() else ""
def _has_comma_before(text: str, pos: int) -> bool:
"""Есть ли знак прямо перед этим местом - тогда второй не нужен."""
left = text[:pos].rstrip()
return bool(left) and left[-1] in ",;:—-"
def _insert(text: str, rx, skip_after=frozenset()) -> str:
"""Вставить запятые по правилу. skip_after - слова, после которых не ставим."""
out, last = [], 0
for m in rx.finditer(text):
if _has_comma_before(text, m.start()):
continue
if skip_after and _prev_word(text, m.start()) in skip_after:
continue
out.append(text[last:m.start()])
out.append(",")
last = m.start()
out.append(text[last:])
return "".join(out)
def apply(text: str) -> str:
"""Расставить запятые, заглавную и точку. Пусто на входе - пусто на выходе.
Если в тексте уже есть знаки препинания, не трогаем ничего: значит их
поставила модель распознавания (вариант e2e умеет) или человек диктовал
знаки голосом, и наши правила там только навредят.
"""
t = (text or "").strip()
if not t:
return text
# Двоеточие и точку с запятой в этой проверке НЕ учитываем: «10:00» -
# это время, а не расставленная пунктуация, и из-за него вся фраза
# оставалась без единой запятой.
if "," in t or any(c in t for c in _ENDS):
return text
# Заглавные буквы - тоже признак, что знаки ставила модель. Эта проверка
# была у basic_punctuation, и при замене её едва не потеряли: без неё
# правила брались бы за текст от e2e-модели, если он короткий и точки в
# нём ещё нет. Имена собственные ею не ловятся - первую букву фразы,
# которую мы сами и поднимаем, из проверки исключаем.
if any(c.isupper() for c in t[1:]):
return text
# Порядок важен: многословные раньше однословных, «который» раньше общих
# правил (у него своя логика с предлогом).
t = _insert(t, _RE_MULTI)
# После предлога запятую не ставим: она уже стоит ПЕРЕД ним, и вторая дала
# бы «вот дом, в, котором я живу».
t = _insert(t, _RE_WHICH, skip_after=frozenset(_PREPS))
t = _insert(t, _RE_SINGLE, skip_after=_MULTI_HEADS)
t = _insert(t, _RE_ADVERS)
t = _NOT_UNION.sub(lambda m: m.group(1) + m.group(2), t)
t = _RE_INTRO.sub(lambda m: m.group(1) + ",", t)
t = _mark_lead(t)
t = re.sub(r"\s+,", ",", t)
t = re.sub(r",{2,}", ",", t)
t = t[0].upper() + t[1:]
if t[-1] not in _ENDS:
t += "."
return t