antimoda1 commited on
Commit ·
c1c2970
1
Parent(s): 0c818cc
a lot of refactor
Browse files- tests/test_lemmatization.py +6 -19
- tests/test_vocabular.py +20 -0
- vocabulary/parse_vocabulary.py +64 -59
- vocabulary/vocabulary.md +2 -2
tests/test_lemmatization.py
CHANGED
|
@@ -11,7 +11,7 @@ from dataclasses import dataclass
|
|
| 11 |
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 12 |
|
| 13 |
from lemmatizer import RussianLemmatizer
|
| 14 |
-
|
| 15 |
|
| 16 |
@dataclass
|
| 17 |
class TestSearch:
|
|
@@ -48,14 +48,13 @@ TESTS_LETTER_E = [
|
|
| 48 |
]
|
| 49 |
|
| 50 |
|
| 51 |
-
def lemmas_in_sentence(query: str, sentence: str
|
| 52 |
"""
|
| 53 |
Проверяет, есть ли леммы из query в sentence после лемматизации.
|
| 54 |
|
| 55 |
Args:
|
| 56 |
query: поисковое слово
|
| 57 |
sentence: предложение для проверки
|
| 58 |
-
lemmatizer: инстанс лемматизатора
|
| 59 |
|
| 60 |
Returns:
|
| 61 |
True если есть пересечение лемм
|
|
@@ -67,7 +66,7 @@ def lemmas_in_sentence(query: str, sentence: str, lemmatizer: RussianLemmatizer)
|
|
| 67 |
return len(query_lemmas & sentence_lemmas) > 0
|
| 68 |
|
| 69 |
|
| 70 |
-
def run_test_suite(test_set_name: str, test_set: list[TestSearch]
|
| 71 |
"""Запускает набор тестов и возвращает (пройдено, провалено)"""
|
| 72 |
|
| 73 |
print("\n" + "-"*70)
|
|
@@ -113,10 +112,7 @@ def test_lemmatization():
|
|
| 113 |
print("\n" + "="*70)
|
| 114 |
print("ТЕСТ ЛЕММАТИЗАЦИИ для русского языка")
|
| 115 |
print("="*70)
|
| 116 |
-
|
| 117 |
-
# Инициализируем лемматизатор один раз
|
| 118 |
-
lemmatizer = RussianLemmatizer()
|
| 119 |
-
|
| 120 |
total_passed = 0
|
| 121 |
total_failed = 0
|
| 122 |
|
|
@@ -127,7 +123,7 @@ def test_lemmatization():
|
|
| 127 |
("ОБРАБОТКА Е/Ё", TESTS_LETTER_E),
|
| 128 |
]:
|
| 129 |
if test_set: # Только если есть тесты
|
| 130 |
-
passed, failed = run_test_suite(test_name, test_set
|
| 131 |
total_passed += passed
|
| 132 |
total_failed += failed
|
| 133 |
|
|
@@ -140,13 +136,4 @@ def test_lemmatization():
|
|
| 140 |
|
| 141 |
|
| 142 |
if __name__ == "__main__":
|
| 143 |
-
|
| 144 |
-
success = test_lemmatization()
|
| 145 |
-
sys.exit(0 if success else 1)
|
| 146 |
-
except Exception as e:
|
| 147 |
-
print("\n" + "="*70)
|
| 148 |
-
print("✗ ОШИБКА ПРИ ВЫПОЛНЕНИИ ТЕСТОВ")
|
| 149 |
-
print("="*70)
|
| 150 |
-
import traceback
|
| 151 |
-
traceback.print_exc()
|
| 152 |
-
sys.exit(1)
|
|
|
|
| 11 |
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 12 |
|
| 13 |
from lemmatizer import RussianLemmatizer
|
| 14 |
+
lemmatizer = RussianLemmatizer()
|
| 15 |
|
| 16 |
@dataclass
|
| 17 |
class TestSearch:
|
|
|
|
| 48 |
]
|
| 49 |
|
| 50 |
|
| 51 |
+
def lemmas_in_sentence(query: str, sentence: str) -> bool:
|
| 52 |
"""
|
| 53 |
Проверяет, есть ли леммы из query в sentence после лемматизации.
|
| 54 |
|
| 55 |
Args:
|
| 56 |
query: поисковое слово
|
| 57 |
sentence: предложение для проверки
|
|
|
|
| 58 |
|
| 59 |
Returns:
|
| 60 |
True если есть пересечение лемм
|
|
|
|
| 66 |
return len(query_lemmas & sentence_lemmas) > 0
|
| 67 |
|
| 68 |
|
| 69 |
+
def run_test_suite(test_set_name: str, test_set: list[TestSearch]) -> tuple[int, int]:
|
| 70 |
"""Запускает набор тестов и возвращает (пройдено, провалено)"""
|
| 71 |
|
| 72 |
print("\n" + "-"*70)
|
|
|
|
| 112 |
print("\n" + "="*70)
|
| 113 |
print("ТЕСТ ЛЕММАТИЗАЦИИ для русского языка")
|
| 114 |
print("="*70)
|
| 115 |
+
|
|
|
|
|
|
|
|
|
|
| 116 |
total_passed = 0
|
| 117 |
total_failed = 0
|
| 118 |
|
|
|
|
| 123 |
("ОБРАБОТКА Е/Ё", TESTS_LETTER_E),
|
| 124 |
]:
|
| 125 |
if test_set: # Только если есть тесты
|
| 126 |
+
passed, failed = run_test_suite(test_name, test_set)
|
| 127 |
total_passed += passed
|
| 128 |
total_failed += failed
|
| 129 |
|
|
|
|
| 136 |
|
| 137 |
|
| 138 |
if __name__ == "__main__":
|
| 139 |
+
test_lemmatization()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_vocabular.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Тест поиска терминов с учётом падежей"""
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 6 |
+
from vocabulary.parse_vocabulary import VOCABULARY_MANAGER
|
| 7 |
+
|
| 8 |
+
TESTS_CASES = {
|
| 9 |
+
'Газель газель Газель газели Газелей': set(('ГАЗель', )),
|
| 10 |
+
'Икаруса-280 Икарус-260 Икарусом-260 Икарусов-280': set(('Икарус-260', 'Икарус-280')),
|
| 11 |
+
'Купили ещё автобусов': set(),
|
| 12 |
+
'Икарус-2600': set(),
|
| 13 |
+
'Иккарус-260': set(),
|
| 14 |
+
'Ока': set(('Ока', )),
|
| 15 |
+
'Окой': set(('Ока', )),
|
| 16 |
+
'Окружная дорога': set(),
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
for text, terms in TESTS_CASES.items():
|
| 20 |
+
assert VOCABULARY_MANAGER.find_terms(text) == set((el.lower() for el in terms)), breakpoint()
|
vocabulary/parse_vocabulary.py
CHANGED
|
@@ -1,18 +1,50 @@
|
|
|
|
|
| 1 |
from typing import Optional
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
class ParserVocabulary:
|
|
@@ -26,10 +58,10 @@ class ParserVocabulary:
|
|
| 26 |
Определение термина
|
| 27 |
|
| 28 |
- vocabulary: термин (без скобок) -> определение
|
| 29 |
-
-
|
| 30 |
"""
|
| 31 |
self.vocabulary: dict[str, str] = {}
|
| 32 |
-
self.
|
| 33 |
|
| 34 |
with open(filepath, 'r', encoding='utf-8') as f:
|
| 35 |
lines = f.readlines()
|
|
@@ -71,38 +103,10 @@ class ParserVocabulary:
|
|
| 71 |
i += 1
|
| 72 |
|
| 73 |
def __parse_term_raw(self, term_raw, definition):
|
| 74 |
-
|
| 75 |
-
self.vocabulary[
|
|
|
|
| 76 |
|
| 77 |
-
# Сохраняем информацию о корне для гибкого поиска
|
| 78 |
-
if stem and ending is not None:
|
| 79 |
-
if stem.lower() not in self.stems:
|
| 80 |
-
self.stems[stem.lower()] = {
|
| 81 |
-
'canonical': term_lemma,
|
| 82 |
-
'ending': ending
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
def extract_stem(self, word: str):
|
| 86 |
-
"""
|
| 87 |
-
Проверяет, совпадает ли слово с каким-то корнем из stems_dict
|
| 88 |
-
с допустимым окончанием (может отличаться на 0-2 буквы)
|
| 89 |
-
|
| 90 |
-
Args:
|
| 91 |
-
word: слово для проверки (в нижнем регистре)
|
| 92 |
-
|
| 93 |
-
Returns:
|
| 94 |
-
Канонический вид термина или None
|
| 95 |
-
"""
|
| 96 |
-
for stem, info in self.stems.items():
|
| 97 |
-
if word.startswith(stem):
|
| 98 |
-
# Вычисляем окончание в слове
|
| 99 |
-
word_ending = word[len(stem):]
|
| 100 |
-
expected_ending = info['ending']
|
| 101 |
-
|
| 102 |
-
if abs(len(word_ending) - len(expected_ending)) <= 2:
|
| 103 |
-
return info['canonical']
|
| 104 |
-
return None
|
| 105 |
-
|
| 106 |
def find_terms(self, text: str) -> set[str]:
|
| 107 |
"""Возвращает список терминов в тексте (учитывая словоформы)
|
| 108 |
|
|
@@ -112,15 +116,20 @@ class ParserVocabulary:
|
|
| 112 |
Returns:
|
| 113 |
set: Множество найденных терминов из vocabulary в порядке их появления
|
| 114 |
"""
|
| 115 |
-
|
| 116 |
|
| 117 |
-
|
|
|
|
| 118 |
|
| 119 |
-
for word in
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
def wrap_prompt(self, retrieved_text: str, query_text: str):
|
| 126 |
tokens_from_query = self.find_terms(query_text)
|
|
@@ -148,12 +157,8 @@ class ParserVocabulary:
|
|
| 148 |
ОТВЕТ:"""
|
| 149 |
|
| 150 |
|
|
|
|
| 151 |
import os
|
| 152 |
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 153 |
vocab_file = os.path.join(current_dir, 'vocabulary.md')
|
| 154 |
VOCABULARY_MANAGER = ParserVocabulary(vocab_file)
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
if __name__ == '__main__':
|
| 158 |
-
print(VOCABULARY_MANAGER.vocabulary)
|
| 159 |
-
print(VOCABULARY_MANAGER.stems)
|
|
|
|
| 1 |
+
import re
|
| 2 |
from typing import Optional
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass
|
| 7 |
+
class TermPattern:
|
| 8 |
+
"""Класс для представления шаблона термина с окончанием"""
|
| 9 |
+
raw_pattern: str
|
| 10 |
+
|
| 11 |
+
def __post_init__(self):
|
| 12 |
+
"""Разбор шаблон при создании"""
|
| 13 |
+
self.base_part, self.ending_part, self.suffix = self._parse_pattern(self.raw_pattern)
|
| 14 |
+
|
| 15 |
+
def lemma(self):
|
| 16 |
+
return self.raw_pattern.replace('[', '').replace(']', '')
|
| 17 |
+
|
| 18 |
+
def _parse_pattern(self, pattern: str) -> tuple[str, Optional[str], str, bool]:
|
| 19 |
+
"""Разбирает шаблон на составные части.
|
| 20 |
+
Возвращает (основа, окончание, суффикс, был_ли_дефис)
|
| 21 |
+
"""
|
| 22 |
+
# Ищем окончание в квадратных скобках
|
| 23 |
+
match = re.search(r'\[(.*?)\]', pattern)
|
| 24 |
+
|
| 25 |
+
if match:
|
| 26 |
+
ending = match.group(1)
|
| 27 |
+
start, end = match.span()
|
| 28 |
+
prefix = pattern[:start]
|
| 29 |
+
suffix = pattern[end:]
|
| 30 |
+
return prefix.lower(), ending.lower(), suffix.lower()
|
| 31 |
+
return pattern, None, ''
|
| 32 |
+
|
| 33 |
+
def matches(self, word: str) -> bool:
|
| 34 |
+
"""Проверяет, соответствует ли слово шаблону"""
|
| 35 |
+
word = word.lower()
|
| 36 |
+
if self.ending_part is None:
|
| 37 |
+
# Если окончания нет, слово должно точно совпадать
|
| 38 |
+
return word == self.lemma().lower()
|
| 39 |
+
if not word.startswith(self.base_part):
|
| 40 |
+
return False
|
| 41 |
+
word_without_base = word[len(self.base_part):]
|
| 42 |
+
if not self.suffix:
|
| 43 |
+
return abs((len(word_without_base)-len(self.ending_part))) <= 2
|
| 44 |
+
if not word_without_base.endswith(self.suffix):
|
| 45 |
+
return False
|
| 46 |
+
word_without_base_and_suffix = word_without_base[:-len(self.suffix)]
|
| 47 |
+
return abs((len(word_without_base_and_suffix)-len(self.ending_part))) <= 2
|
| 48 |
|
| 49 |
|
| 50 |
class ParserVocabulary:
|
|
|
|
| 58 |
Определение термина
|
| 59 |
|
| 60 |
- vocabulary: термин (без скобок) -> определение
|
| 61 |
+
- patterns: список объектов TermPattern для проверки словоформ
|
| 62 |
"""
|
| 63 |
self.vocabulary: dict[str, str] = {}
|
| 64 |
+
self.patterns: list[TermPattern] = [] # список шаблонов для проверки
|
| 65 |
|
| 66 |
with open(filepath, 'r', encoding='utf-8') as f:
|
| 67 |
lines = f.readlines()
|
|
|
|
| 103 |
i += 1
|
| 104 |
|
| 105 |
def __parse_term_raw(self, term_raw, definition):
|
| 106 |
+
pattern = TermPattern(term_raw)
|
| 107 |
+
self.vocabulary[pattern.lemma().lower()] = definition
|
| 108 |
+
self.patterns.append(pattern)
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
def find_terms(self, text: str) -> set[str]:
|
| 111 |
"""Возвращает список терминов в тексте (учитывая словоформы)
|
| 112 |
|
|
|
|
| 116 |
Returns:
|
| 117 |
set: Множество найденных терминов из vocabulary в порядке их появления
|
| 118 |
"""
|
| 119 |
+
found_lemmas = set() # Для отслеживания дубликатов
|
| 120 |
|
| 121 |
+
# Очищаем текст от знаков препинания и разбиваем на слова
|
| 122 |
+
words = re.findall(r'\b\w+(?:[.-]\w+)*\b', text.lower())
|
| 123 |
|
| 124 |
+
for word in words:
|
| 125 |
+
# проверяем по паттернам
|
| 126 |
+
for pattern in self.patterns:
|
| 127 |
+
if pattern.matches(word):
|
| 128 |
+
lemma = pattern.lemma().lower()
|
| 129 |
+
assert lemma in self.vocabulary, breakpoint()
|
| 130 |
+
found_lemmas.add(lemma)
|
| 131 |
+
break # Слово найдено, переходим к следующему
|
| 132 |
+
return found_lemmas
|
| 133 |
|
| 134 |
def wrap_prompt(self, retrieved_text: str, query_text: str):
|
| 135 |
tokens_from_query = self.find_terms(query_text)
|
|
|
|
| 157 |
ОТВЕТ:"""
|
| 158 |
|
| 159 |
|
| 160 |
+
# Создаём VOCABULARY_MANAGER, чтобы импортировать его при импортах модуля
|
| 161 |
import os
|
| 162 |
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 163 |
vocab_file = os.path.join(current_dir, 'vocabulary.md')
|
| 164 |
VOCABULARY_MANAGER = ParserVocabulary(vocab_file)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
vocabulary/vocabulary.md
CHANGED
|
@@ -118,10 +118,10 @@
|
|
| 118 |
### Икарус[]-180
|
| 119 |
Автобус особо большого класса (с гармошкой).
|
| 120 |
|
| 121 |
-
### Икарус[]-260
|
| 122 |
Автобус производства венгерской фирмы Ikarus второго поколения.
|
| 123 |
|
| 124 |
-
### Икарус[]-280, Икарус[]
|
| 125 |
Автобус особо большого класса (с гармошкой), долгое время эксплуатировавшийся в Рязани и ставший символом рязанского автобуса.
|
| 126 |
|
| 127 |
### ЛиАЗ[]-5292, ЛиАЗ[]
|
|
|
|
| 118 |
### Икарус[]-180
|
| 119 |
Автобус особо большого класса (с гармошкой).
|
| 120 |
|
| 121 |
+
### Икарус[]-260
|
| 122 |
Автобус производства венгерской фирмы Ikarus второго поколения.
|
| 123 |
|
| 124 |
+
### Икарус[]-280, Икарус[]
|
| 125 |
Автобус особо большого класса (с гармошкой), долгое время эксплуатировавшийся в Рязани и ставший символом рязанского автобуса.
|
| 126 |
|
| 127 |
### ЛиАЗ[]-5292, ЛиАЗ[]
|