универсальная версия: без хардкода имён моделей, pick_text по ключевым словам

This commit is contained in:
Maksim Totmin 2026-06-23 10:47:32 +07:00
parent 9b78a5a1bb
commit 6e48b5da68
2 changed files with 136 additions and 184 deletions

View File

@ -1,6 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""SEE model autotest — проверяет все загруженные модели после обновления. """SEE model autotest — проверяет все загруженные модели после обновления.
Универсальный: не содержит хардкода имён моделей, подходит для любой SEE-инсталляции.
Текст запроса подбирается динамически по имени модели.
Для точных проверок можно подключить asserts-файл.
Использование: Использование:
python3 test_see_models.py python3 test_see_models.py
python3 test_see_models.py --host 10.101.60.26 python3 test_see_models.py --host 10.101.60.26
@ -11,20 +15,13 @@
{ {
"asserts": [ "asserts": [
{ {
"model": "fio", // подстрока для поиска модели "model": "fio", // подстрока для поиска модели
"type": "entity|classifier", "text": "Иванов Иван Иванович", // текст запроса (опционально)
"text": "Иванов Иван Иванович", // текст для запроса
"checks": { "checks": {
// для entity: "min_found": 1, // мин. количество сущностей/классов
"min_found": 1, // мин. количество найденных сущностей "text_contains": "иван", // подстрока в text или class
"text_contains": "иванов", // подстрока в text первой сущности "calculated_contains": "Ф:", // подстрока в calculated
"calculated_contains": "Ф:", // подстрока в calculated "confidence_min": 0.5 // мин. confidence
"confidence_min": 0.5, // мин. confidence
// для classifier:
"min_classes": 1, // мин. количество классов
"top_class_contains": "Yes", // подстрока в top классе
"confidence_min": 0.5 // мин. confidence топ-класса
} }
} }
] ]
@ -35,7 +32,6 @@
import argparse import argparse
import json import json
import os
import sys import sys
import unittest import unittest
from urllib.parse import quote from urllib.parse import quote
@ -50,80 +46,74 @@ DEFAULT_HOST = "10.101.60.25"
DEFAULT_PORT = 6184 DEFAULT_PORT = 6184
BASE_PATH = "/see" BASE_PATH = "/see"
PRETRAINED_TESTS = { TEXT_RULES = [
"fio": {"text": "Иванов Иван Иванович", "hint": "ФИО"}, # (keyword, text) — первое совпадение побеждает
"phone": {"text": "8 913 456 78 90", "hint": "телефон"},
"number": {"text": "пятьсот двадцать три", "hint": "число"},
"date": {"text": "на завтра", "hint": "дата"},
"uuid": {"text": "550e8400-e29b-41d4-a716-446655440000", "hint": "UUID"},
"time": {"text": "в пять часов вечера", "hint": "время"},
}
HANDLER_TESTS = {
"fio2": {"text": "Иванов Иван Иванович", "type": "entity"},
"phone2": {"text": "8 913 456 78 90", "type": "entity"},
"record_date": {"text": "на завтра", "type": "entity"},
"record_time": {"text": "в пять часов вечера", "type": "entity"},
"yesno": {"text": "да", "type": "classifier"},
"yesno_obzv": {"text": "да", "type": "classifier"},
"weekday": {"text": "в пятницу", "type": "classifier"},
"end_repeat": {"text": "все, до свидания", "type": "classifier"},
"operatorcheck": {"text": "соедините с оператором", "type": "classifier"},
"see_grade": {"text": "пять", "type": "classifier"},
"other_payment": {"text": "мобилизационные выплаты", "type": "classifier"},
"mobilizac": {"text": "мобилизационные", "type": "classifier"},
"matcap_direct": {"text": "на улучшение жилищных условий", "type": "classifier"},
"new_minsoc_subsidii": {"text": "субсидия на жкх", "type": "classifier"},
"new_trud": {"text": "потерял работу", "type": "classifier"},
"rating_med": {"text": "отлично", "type": "classifier"},
"zags": {"text": "загс центрального района", "type": "classifier"},
"zags_handler": {"text": "загс на нижегородской", "type": "classifier"},
"filial": {"text": "в мошковский филиал", "type": "classifier"},
"offices_mfc_see": {"text": "родники", "type": "classifier"},
"service_mfc_see": {"text": "хочу получить водительское удостоверение", "type": "classifier"},
"nsk_fillials": {"text": "в филиал на родниках", "type": "classifier"},
}
CUSTOM_MODEL_MATCHES = [
("record_date", "на завтра"),
("record_time", "в пять часов вечера"),
("fio", "Иванов Иван Иванович"), ("fio", "Иванов Иван Иванович"),
("phone", "8 913 456 78 90"), ("phone", "8 913 456 78 90"),
("date", "на завтра"),
("time", "в пять часов вечера"),
("uuid", "550e8400-e29b-41d4-a716-446655440000"),
("number", "пятьсот двадцать три"), ("number", "пятьсот двадцать три"),
("filial", "в филиал на родниках"), ("date", "на завтра"),
("record_date", "на завтра"),
("time", "в пять часов вечера"),
("record_time", "в пять часов вечера"),
("uuid", "550e8400-e29b-41d4-a716-446655440000"),
("zags", "загс центрального района"), ("zags", "загс центрального района"),
("nsk_fillials", "в филиал на родниках"),
("filial", "в филиал на родниках"),
("offices_mfc", "родники"),
("возраст", "мне 25 лет"),
("загран", "хочу загранпаспорт"),
("yesno", "да"), ("yesno", "да"),
("weekday", "в пятницу"), ("weekday", "в пятницу"),
("grade", "пять"), ("дни недели", "в пятницу"),
("obzv", "да"),
("operator", "соедините с оператором"), ("operator", "соедините с оператором"),
("repeat", "все, до свидания"), ("end_repeat", "все, до свидания"),
("matcap", "на улучшение жилищных условий"), ("повтор", "все, до свидания"),
("other_payment", "мобилизационные выплаты"),
("payment", "мобилизационные выплаты"),
("mobilizac", "мобилизационные"), ("mobilizac", "мобилизационные"),
("trud", "потерял работу"), ("trud", "потерял работу"),
("subsidii", "субсидия на жкх"), ("subsidii", "субсидия на жкх"),
("rating", "отлично"), ("minsoc", "субсидия на жкх"),
("payment", "мобилизационные выплаты"), ("matcap", "на улучшение жилищных условий"),
("service_mfc", "хочу получить паспорт"), ("маткап", "на улучшение жилищных условий"),
("offices_mfc", "родники"), ("паспорт", "хочу получить паспорт"),
("паспорт", "хочу получить загранпаспорт"), ("пособие", "пособие по рождению"),
("ЕПГУ", "проверить статус заявления на госуслугах"),
("лаврентьев", "как получить лаврентьевскую карту"),
("возраст", "мне 25 лет"),
("загран", "хочу сделать загранпаспорт"),
("вернуться", "вернуться в главное меню"),
("не знаю код", "не знаю код подразделения"),
("оценк", "оцениваю обслуживание на пять"),
("несогласи", "я не согласен с этим"),
("начало диалог", "здравствуйте"), ("начало диалог", "здравствуйте"),
("пособие", "какое пособие по рождению"), ("граждан", "я гражданин россии"),
("УК", "проблема с управляющей компанией"), ("выплат", "ежемесячные выплаты"),
("статус", "проверить статус заявления"),
("суперсервис", "рождение ребенка"),
("рождение", "рождение ребенка"),
("лаврентьев", "лаврентьевская карта"),
("несогласи", "я не согласен"),
("отказ", "отказываюсь"),
("оценк", "пять"),
("grade", "пять"),
("rating", "пять"),
("исключение", "это не относится"),
("режим", "режим работы мфц"),
("жкх", "режим работы мфц"),
("вернуться", "вернуться в главное меню"),
("не знаю", "не знаю код подразделения"),
("service_mfc", "хочу получить услугу"),
("service mfc", "хочу получить услугу"),
("mfc", "записаться в мфц"),
("мфц", "записаться в мфц"),
("тест", "тестовое сообщение"),
("test", "test message"),
("заглушк", "тестовый запрос"),
("stub", "test request"),
("конкретик", "конкретный вопрос"),
("да-нет", "да"),
] ]
FALLBACK_TEXT = "Здравствуйте, хочу записаться на приём в МФЦ для получения паспорта, запись на завтра"
def pick_text(model_id):
name_lower = model_id.lower()
for keyword, text in TEXT_RULES:
if keyword in name_lower:
return text
return "Здравствуйте, хочу записаться на приём в МФЦ"
def load_asserts(path): def load_asserts(path):
@ -146,7 +136,7 @@ class SeeAPI:
url = f"{self.base}/entities/{encoded}?text={quote(text)}&similarity={similarity}" url = f"{self.base}/entities/{encoded}?text={quote(text)}&similarity={similarity}"
return json.loads(self._request(url)) return json.loads(self._request(url))
def _request(self, url, timeout=15): def _request(self, url, timeout=30):
try: try:
with urlreq.urlopen(url, timeout=timeout) as r: with urlreq.urlopen(url, timeout=timeout) as r:
return r.read().decode("utf-8") return r.read().decode("utf-8")
@ -163,10 +153,10 @@ class TestSeeModels(unittest.TestCase):
cls.asserts = load_asserts(cls.args.asserts) cls.asserts = load_asserts(cls.args.asserts)
data = cls.api.get_models() data = cls.api.get_models()
TestSeeModels.models = data TestSeeModels.models = data
total = len(data["models"]) + len(data["handlers"]) + len(data["pretrained"]) total = len(data.get("models", [])) + len(data.get("handlers", [])) + len(data.get("pretrained", []))
print(f"\n models: {len(data['models'])}, " print(f"\n models: {len(data.get('models', []))}, "
f"handlers: {len(data['handlers'])}, " f"handlers: {len(data.get('handlers', []))}, "
f"pretrained: {len(data['pretrained'])}") f"pretrained: {len(data.get('pretrained', []))}")
if cls.asserts: if cls.asserts:
print(f" asserts loaded: {len(cls.asserts)}") print(f" asserts loaded: {len(cls.asserts)}")
if total == 0: if total == 0:
@ -216,15 +206,6 @@ class TestSeeModels(unittest.TestCase):
self.assertGreaterEqual(first.get("confidence", 0), checks["confidence_min"], self.assertGreaterEqual(first.get("confidence", 0), checks["confidence_min"],
f"{model_id}: confidence < {checks['confidence_min']}") f"{model_id}: confidence < {checks['confidence_min']}")
def _text_for_model(self, model_id):
a = self._find_assert(model_id)
if a and "text" in a:
return a["text"]
for substr, text in CUSTOM_MODEL_MATCHES:
if substr in model_id:
return text
return FALLBACK_TEXT
def _is_empty(self, result): def _is_empty(self, result):
if not result or result == {}: if not result or result == {}:
return True return True
@ -232,13 +213,6 @@ class TestSeeModels(unittest.TestCase):
isinstance(v, list) and len(v) == 0 for v in result.values() isinstance(v, list) and len(v) == 0 for v in result.values()
) )
def test_01_list_models(self):
"""Получение списка моделей"""
data = self.models
self.assertIn("models", data)
self.assertIn("handlers", data)
self.assertIn("pretrained", data)
def _fmt_result(self, model_id, result): def _fmt_result(self, model_id, result):
print(f" >> {model_id}") print(f" >> {model_id}")
if isinstance(result, dict) and "classes" in result: if isinstance(result, dict) and "classes" in result:
@ -257,64 +231,15 @@ class TestSeeModels(unittest.TestCase):
if len(entities) > 3: if len(entities) > 3:
print(f" ... ({len(entities)} entities)") print(f" ... ({len(entities)} entities)")
def test_02_pretrained_models(self): def _test_all_models(self, models, category):
"""Проверка pretrained-моделей"""
errors = [] errors = []
for model_id, td in PRETRAINED_TESTS.items(): for model_id in models:
if model_id not in self.models.get("pretrained", []): if not model_id:
errors.append(f" {model_id}: нет в списке")
continue continue
try: text = pick_text(model_id)
result = self.api.test_entity(model_id, td["text"])
if self._is_empty(result):
errors.append(f" {model_id}: пустой результат")
else:
self._check_assert(model_id, result)
self._fmt_result(model_id, result)
except AssertionError:
raise
except Exception as e:
errors.append(f" {model_id}: {e}")
if errors:
self.fail("Ошибки pretrained:\n" + "\n".join(errors))
def test_03_handler_models(self):
"""Проверка handler-моделей"""
errors = []
handlers = self.models.get("handlers", [])
if not handlers:
self.skipTest("Нет handler моделей")
for model_id in handlers:
text = FALLBACK_TEXT
for key, td in HANDLER_TESTS.items():
if key in model_id:
text = td["text"]
break
a = self._find_assert(model_id) a = self._find_assert(model_id)
if a and "text" in a: if a and "text" in a:
text = a["text"] text = a["text"]
try:
result = self.api.test_entity(model_id, text)
if self._is_empty(result):
print(f" [!] {model_id}: пустой результат")
else:
self._check_assert(model_id, result)
self._fmt_result(model_id, result)
except AssertionError:
raise
except Exception as e:
errors.append(f" {model_id}: {e}")
if errors:
self.fail("Ошибки handler:\n" + "\n".join(errors))
def test_04_custom_models(self):
"""Проверка кастомных моделей"""
errors = []
models = self.models.get("models", [])
if not models:
self.skipTest("Нет кастомных моделей")
for model_id in models:
text = self._text_for_model(model_id)
try: try:
result = self.api.test_entity(model_id, text) result = self.api.test_entity(model_id, text)
if self._is_empty(result): if self._is_empty(result):
@ -326,35 +251,42 @@ class TestSeeModels(unittest.TestCase):
raise raise
except Exception as e: except Exception as e:
errors.append(f" {model_id}: {e}") errors.append(f" {model_id}: {e}")
return errors
def test_01_list_models(self):
"""Получение списка моделей"""
data = self.models
self.assertIn("models", data)
self.assertIn("handlers", data)
self.assertIn("pretrained", data)
def test_02_pretrained_models(self):
"""Проверка pretrained-моделей"""
pretrained = self.models.get("pretrained", [])
if not pretrained:
self.skipTest("Нет pretrained моделей")
errors = self._test_all_models(pretrained, "pretrained")
if errors:
self.fail("Ошибки pretrained:\n" + "\n".join(errors))
def test_03_handler_models(self):
"""Проверка handler-моделей"""
handlers = self.models.get("handlers", [])
if not handlers:
self.skipTest("Нет handler моделей")
errors = self._test_all_models(handlers, "handler")
if errors:
self.fail("Ошибки handler:\n" + "\n".join(errors))
def test_04_custom_models(self):
"""Проверка кастомных моделей"""
models = self.models.get("models", [])
if not models:
self.skipTest("Нет кастомных моделей")
errors = self._test_all_models(models, "custom")
if errors: if errors:
self.fail("Ошибки кастомных моделей:\n" + "\n".join(errors)) self.fail("Ошибки кастомных моделей:\n" + "\n".join(errors))
def test_05_handler_pretrained_coverage(self):
"""Проверка handler моделей phone/uuid/number/fio"""
errors = []
handlers = self.models.get("handlers", [])
for htype, td in [("phone", PRETRAINED_TESTS["phone"]),
("uuid", PRETRAINED_TESTS["uuid"]),
("number", PRETRAINED_TESTS["number"]),
("fio", PRETRAINED_TESTS["fio"])]:
matching = [h for h in handlers if htype in h.lower()]
if not matching:
continue
for mid in matching[:2]:
try:
result = self.api.test_entity(mid, td["text"])
if self._is_empty(result):
print(f" [!] {mid} ({td['hint']})")
else:
self._check_assert(mid, result)
self._fmt_result(mid, result)
except AssertionError:
raise
except Exception as e:
errors.append(f" {mid}: {e}")
if errors:
self.fail("Ошибки handler coverage:\n" + "\n".join(errors))
def main(): def main():
parser = argparse.ArgumentParser(description="SEE model autotest") parser = argparse.ArgumentParser(description="SEE model autotest")

View File

@ -11,13 +11,12 @@
{ {
"asserts": [ "asserts": [
{ {
"model": "МФЦ НСК", // подстрока для поиска модели "model": "МФЦ НСК",
"type": "classifier",
"text": "записаться на приём", // опционально, свой текст "text": "записаться на приём", // опционально, свой текст
"checks": { "checks": {
"min_classes": 1, // мин. количество классов "min_found": 1, // мин. количество классов
"top_class_contains": "МФЦ", // подстрока в top классе "text_contains": "Запись", // подстрока в top классе
"confidence_min": 0.5 // мин. confidence топ-класса "confidence_min": 0.5 // мин. confidence топ-класса
} }
} }
] ]
@ -42,7 +41,28 @@ DEFAULT_HOST = "10.101.60.27"
DEFAULT_PORT = 6181 DEFAULT_PORT = 6181
BASE_PATH = "/smc" BASE_PATH = "/smc"
DEFAULT_TEXT = "Здравствуйте, хочу записаться на приём в МФЦ для получения паспорта" TEXT_RULES = [
("мфц", "записаться в мфц"),
("mfc", "записаться в мфц"),
("филиал", "какой филиал работает"),
("ekc", "записаться в екц"),
("екц", "записаться в екц"),
("minsoc", "какие выплаты"),
("минсоц", "какие выплаты"),
("техническая", "не работает сайт"),
("поддержка", "не работает сайт"),
("offices", "какие офисы работают"),
("тест", "тестовый запрос"),
("test", "test query"),
]
def pick_text(model_id):
name_lower = model_id.lower()
for keyword, text in TEXT_RULES:
if keyword in name_lower:
return text
return "Здравствуйте, хочу записаться на приём"
def load_asserts(path): def load_asserts(path):
@ -124,7 +144,7 @@ class TestSmcModels(unittest.TestCase):
for mid in models: for mid in models:
if not mid: if not mid:
continue continue
text = DEFAULT_TEXT text = pick_text(mid)
a = self._find_assert(mid) a = self._find_assert(mid)
if a and "text" in a: if a and "text" in a:
text = a["text"] text = a["text"]
@ -158,7 +178,7 @@ class TestSmcModels(unittest.TestCase):
for mid in handlers: for mid in handlers:
if not mid: if not mid:
continue continue
text = DEFAULT_TEXT text = pick_text(mid)
a = self._find_assert(mid) a = self._find_assert(mid)
if a and "text" in a: if a and "text" in a:
text = a["text"] text = a["text"]
@ -181,7 +201,7 @@ class TestSmcModels(unittest.TestCase):
except Exception as e: except Exception as e:
errors.append(f" {mid}: {e}") errors.append(f" {mid}: {e}")
if errors: if errors:
self.fail("Ошибки internal:\n" + "\n".join(errors)) self.fail("Ошибки handler:\n" + "\n".join(errors))
def main(): def main():