-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathdental_vocab.py
More file actions
251 lines (229 loc) · 20.6 KB
/
Copy pathdental_vocab.py
File metadata and controls
251 lines (229 loc) · 20.6 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
"""
Стоматологический словарь триажа — единственный источник истины.
Словарь и распознавание терминов лежат здесь, а не в assistant.py, потому что
их используют минимум два независимых потребителя: триаж ассистента и фильтр
сообщений для дайджеста (summarizer). Раньше у summarizer был свой список на
80 корней, и клинические реплики, чьих терминов в этом коротком списке не было,
из дайджеста молча выпадали. Замер на 4000 живых сообщений: 166 из них —
осмысленное клиническое содержание, выброшенное фильтром («Периодонтит тоже не
всегда заканчивается заживлением», «Бонд хим отверждения», «Ретрит файлы и
протейперы»). Из 227 терминов ассистента 169 не имели в том списке ни одного
соответствия.
Здесь же — НЕ в config.py: config.py перечислен в .gitignore как файл с
секретами, и однажды вынесенный туда словарь не попал в репозиторий и был
потерян, из-за чего модуль перестал импортироваться и бот не поднимался вообще.
Клиническая логика не секрет и должна лежать под контролем версий.
"""
import re
# config импортируется мягко: он читает .env и падает, если TG_BOT_TOKEN не
# задан, а сам словарь к секретам отношения не имеет. Жёсткий импорт делал
# модуль неимпортируемым из любого инструмента без .env — в том числе из
# distiller.py, которому нужен только has_dental_term.
try:
import config
except Exception: # noqa: BLE001 — причина не важна, важно не утащить за собой словарь
config = None
DENTAL_KEYWORDS = {
"зуб", "канал", "уступ", "бор", "файл", "цемент", "коронка", "коронок", "имплант",
"активация", "винил", "преп", "эндо", "гнатол", "окклюз", "сустав", "внчс",
"сплинт", "капп", "слеп", "оттис", "трансфер", "абатм", "циркон", "пмма", "pmma",
"керам", "ультразвук", "эйтис", "петл", "спредер", "визуали", "микроскоп",
"пескоструй", "коффердам", "раббердам", "кламп", "плавиков", "силан", "бонд",
"травлен", "адгезив", "композит", "клинич", "диагно", "анестез", "артикаин",
"мепивакаин", "убистезин", "ультракаин", "лидокаин", "пульп", "апекс", "периодонт",
"периодонтит", "пульпит", "кариес", "гингивит", "пародонт", "пародонтоз", "рецесс",
"десна", "десны", "десневой", "костн", "альвеол", "синус", "остеот", "мембран",
"шовн", "викрил", "пролен", "монофил", "хирург", "удалени", "экстракц", "лунк",
"кюрет", "остеоинтегр", "формировател", "заглушк", "крошк", "биоосс", "bio-oss",
"аллоплант", "ксенотрансп", "брекет", "элайнер", "ретейнер", "дистализ", "мезиализ",
"дуга", "дуги", "лигатур", "ортодонт", "ортопед", "терапевт", "кт", "клкт", "оптг",
"визиограф", "рентген", "снимок", "снимка", "бинокуляр", "лупы", "эндомотор",
"апекслокатор", "автоклав", "стерилиз", "дентин", "эмаль", "эмали", "челюст",
"прикус", "резец", "резц", "клык", "премоляр", "моляр", "реципрок", "протейпер",
"мту", "mtwo", "пасс-файл", "гипохлорит", "хлоргексидин", "эдта", "edta",
"гуттаперч", "силер", "обтурац", "латеральн", "вертикальн", "распломбиров",
"анкерн", "штифт", "платок", "ирригац", "мост", "протез", "вкладк", "накладк",
"оверлей", "рондоклип", "сканмаркер", "ложка", "артикулятор", "депрограмматор",
"коис", "миостимуляц", "шина", "емакс", "e.max", "e-max", "полевошпат", "каркас",
"полимеризац", "фотополимер", "клиновидн", "абфракц", "стираемост", "бруксизм",
"флюороз", "гипоплази", "фиссур", "герметизац", "карман", "грейси", "gracey",
"скалер", "чистк", "налет", "камень", "сст", "сдг", "трансплантат", "вестибулопласт",
"микроимплант", "тяга", "пломб", "девитал", "мышьяк", "резекц", "цистэкт",
"гранулем", "кист", "киста", "фистул", "свищ", "перфорац", "полость", "полости",
"кариозн", "поддеснев", "наддеснев", "шейка", "верхушка", "апикальн", "дентальный",
"стоматолог", "эндодонт", "пародонтолог", "кофердам", "остеопласт", "винир",
"синуслифт", "костнаяпласт", "аугмент", "регенер", "остеосинтез", "стекловолокн",
"свш", "металлокерам", "ортодонтич", "обтуратор", "термафил", "thermafil",
"airflow", "air-flow", "пазух", "гайморов", "мандибул", "ментальн", "подбородоч",
"альвеолярн", "остеотоми",
# Добавлено по сравнению фильтров на живых данных: этих корней не было ни
# в словаре триажа, ни в списке summarizer, и реплики с ними выпадали.
# "удал" из старого списка сознательно НЕ добавлен: он матчит "удалось" и
# "удалить сообщение", то есть тянул бы в клинику обычную речь.
"кость", "корн", "корен", "фиксаци", "ретракци", "седаци", "интактн", "шлиф",
# Добавлены как самостоятельные термины: иначе после ужесточения обратной
# проверки они перестали бы распознаваться («скан» покрывает лишь 40%
# «сканмаркера», «импл» — 57% «имплант»).
"скан", "верти", "импл", "металл",
# Проверка ядровых терминов профессии против словаря на живой базе (32 883
# сообщения): 57 из 71 проверенного корня словарь не знал. Ниже те, у
# которых все слова-носители в чате оказались клиническими.
"реставрац", "гипс", "силикон", "восков", "воспален", "примерк", "матриц",
"осложнен", "отек", "лазер", "шприц", "карпул", "ретрит", "глазур",
"лоскут", "гладилк", "анамнез", "альгинат", "диоксид", "анестетик",
"абсцесс", "артикуляц", "отбеливан", "некроз", "зиркон", "диастем",
"реминерализац", "кондилограф", "аксиограф", "штопфер", "слюноотсос",
"экскаватор", "ретроград", "жалоб", "игл", "паковк",
# «культя» — опора под коронку, 155 упоминаний. Известная плата: корень
# цепляет «культуры», «культовой», «культурном» — три случая на 158.
"культ",
# НЕ добавлены сознательно:
# "воск" — это ещё и «воскресенье»; взят «восков» (восковка, восковой);
# "пациент" — 1365 упоминаний, но половина про запись и поток, а не про
# клинику: гейт начал бы цеплять разговоры о маркетинге;
# "клиник" — про бизнес («в клинике дорого»), клиническое покрыто "клинич";
# "доктор" — обращение, а не тема.
}
# Совместимость: локальный config.py может переопределить/дополнить словарь.
_config_keywords = getattr(config, "DENTAL_KEYWORDS", None) if config else None
if _config_keywords:
DENTAL_KEYWORDS = set(DENTAL_KEYWORDS) | set(_config_keywords)
# Бытовые слова, начинающиеся с клинического корня. Ровно тот класс дефекта,
# который в этом репозитории ловили уже восемь раз («рот» внутри «оборота»):
# в словаре есть четырёхбуквенные корни «преп» и «скан», и проверка по началу
# слова принимает за клинику «преподаватель», «препятствие», «скандал».
#
# Замер на живом архиве (107 316 реплик с текстом): 28 реплик признаны
# клиническими ТОЛЬКО из-за такого слова — «Мне по нраву то как он преподносит
# инфу», «Без скандалов, интриг, расследований», «вспоминаю любимую фразу
# одного преподавателя». Ни одна из 28 не про лечение.
#
# Список закрытый и проверен против клинической лексики: ни «препарирование»,
# ни «препарат», ни «сканер», ни «сканмаркер», ни «вертипреп», ни «отсканить»
# под эти префиксы не попадают. Расширять только новым таким же замером.
NON_CLINICAL_PREFIXES = frozenset({
"преподав", # преподаватель, преподавания
"преподн", # преподносит, преподнести
"препятств", # препятствие, беспрепятственно
"препир", # препирательства
"скандал", # скандал, скандальный
"сканди", # скандинавский
})
def is_non_clinical_word(word):
"""
Слово выглядит клиническим по корню, но им не является. См. NON_CLINICAL_PREFIXES.
Проверка на ВХОЖДЕНИЕ, а не только на начало: «беспрепятственно» приставкой
сдвигает «препятств» внутрь слова, и там его ловит INNER_ROOTS через «преп».
Для этого закрытого списка вхождение безопасно — ни один клинический термин
ни одну из шести подстрок не содержит.
"""
w = (word or "").lower()
return any(p in w for p in NON_CLINICAL_PREFIXES)
# Клинические термины короче 4 символов: "зуб", "бор", "кт", "мту", "свш"...
SHORT_DENTAL_TERMS = frozenset(t for t in DENTAL_KEYWORDS if len(t) < 4)
# Корни, которые в русском уходят ВНУТРЬ слова из-за приставок и словосложения:
# «отпрепарировать», «вертипреп», «запротезировать», «пришлифовать»,
# «дебондинг», «праймскан», «расцементировка», «заполировать», «периимплантит».
# Проверка по началу слова теряет их целиком — приставка сдвигает корень, и
# «отпрепал» перестаёт быть клиникой.
#
# Внутри слова эти корни однозначны. Замер на 40 000 живых сообщений: среди
# слов-носителей нет ни одного бытового. Единственное пограничное —
# «полоскание» (три раза, из-за «скан»), и оно тоже про рот.
#
# Список закрытый и расширяется только по такому же замеру. Короткие и
# многозначные корни сюда не годятся: «бор» сидит в «выборе» и «обороте»,
# «кт» — в «кто», «эффективно» и «комплекте», «мост» — в «стоимости».
INNER_ROOTS = frozenset({
"протез", "препар", "преп", "шлиф", "бонд", "скан", "имплант",
"пломб", "цемент", "коронк", "фрез", "полир",
})
# Какую долю термина должно покрывать слово, чтобы обратная проверка префикса
# сработала. 0.6 подобрано по замеру на архиве: сохраняет «коронк», «десн»,
# «эмал», «кост», «фиксац», отсекает «вести», «фото», «форм», «верх».
_REVERSE_MATCH_MIN_RATIO = 0.6
def is_dental_keyword(word):
"""
Относится ли слово/стем к стоматологической лексике.
Прямая проверка `термин in слово` промахивается на 14% словаря, потому что
extract_keywords РЕЖЕТ окончания, и стем оказывается КОРОЧЕ словарного
термина: "коронка" -> "коронк", и "коронка" уже не подстрока "коронк".
Так теряются "зуб", "коронка", "десна", "эмали", "верхушка", "шейка".
Поэтому сверяем префиксы в обе стороны.
"""
if not word:
return False
w = word.lower()
# Бытовые слова, которые начинаются с клинического корня, отсекаются ДО
# перебора: «преподаватель» проходил как клиника через корень «преп»,
# «скандал» — через «скан». Замер: 28 реплик архива из 107 316.
if is_non_clinical_word(w):
return False
for term in DENTAL_KEYWORDS:
if len(term) <= 2:
# Двухбуквенные термины — это аббревиатуры ("кт"), склонений у них
# нет, поэтому сверяем целиком. Как префикс "кт" цеплялся к «кто»,
# и любая реплика с этим словом проходила как клиническая.
if w == term:
return True
continue
if len(term) < 4:
# Короткие термины ("зуб", "бор") — как начало слова, чтобы
# поймать "зуба"/"бором", но не случайный мусор.
if w.startswith(term):
return True
continue
if w.startswith(term):
return True
# Обратная проверка — слово короче термина — нужна, потому что
# extract_keywords режет окончания: «коронка» становится «коронк».
# Но без ограничения по доле совпадения она принимает за клинику любой
# короткий префикс длинного термина: «вести» проходило из-за
# «вестибулопласт», «фото» из-за «фотополимер», «форм» из-за
# «формировател». Требуем, чтобы слово покрывало заметную часть термина.
if term.startswith(w) and len(w) >= len(term) * _REVERSE_MATCH_MIN_RATIO:
return True
return False
# Суффиксы для наивного стемминга. Держим здесь же: распознавание термина без
# стемминга ломается на обычных склонениях — в словаре "коронка", а в чате
# "коронки", и ни одно не является префиксом другого дальше корня "коронк".
RU_SUFFIXES = ["ами", "ями", "ыми", "ом", "ем", "ам", "ям", "ах", "ях", "ых", "их",
"ов", "ев", "ие", "ия", "ию", "ии", "ей", "ой", "а", "у", "е", "ы", "и", "о"]
def stem_word(word):
"""Отрезает окончание, если от слова остаётся не меньше четырёх символов."""
for suffix in RU_SUFFIXES:
if word.endswith(suffix) and len(word) - len(suffix) >= 4:
return word[:-len(suffix)]
return word
_WORD_RE = re.compile(r"[\w-]+", re.UNICODE)
def has_dental_term(text):
"""
Содержит ли текст клинический термин.
Слова короче 4 символов сверяются ТОЛЬКО со списком коротких терминов.
Без этого правила обратная проверка префикса в is_dental_keyword цепляется
за мусор: «ок» — префикс «окклюз», и реплика «Ну ок)» проходит как
клиническая. В ассистенте это не проявлялось, потому что там
is_dental_keyword получает уже отфильтрованные extract_keywords слова от
четырёх символов; при вызове по сырому тексту правило нужно повторить, и
его правильное место — рядом со словарём, а не у каждого потребителя.
"""
for token in _WORD_RE.findall((text or "").lower()):
# Дефисные термины словаря ("e-max", "air-flow", "bio-oss") проверяем и
# целиком, и по частям: в тексте встречается и то и другое написание.
for word in ([token] + token.split("-") if "-" in token else [token]):
if len(word) < 4:
if word in SHORT_DENTAL_TERMS:
return True
continue
if is_non_clinical_word(word):
# Проверяем здесь тоже, а не только в is_dental_keyword: до
# INNER_ROOTS дело дошло бы всё равно — «беспрепятственно»
# содержит «преп», и реплика прошла бы как клиническая.
continue
if is_dental_keyword(stem_word(word)):
return True
# Приставка сдвигает корень внутрь слова, и проверка по началу его
# не видит. См. INNER_ROOTS: список закрытый и замеренный.
if any(root in word for root in INNER_ROOTS):
return True
return False