From a9d3f4d5cf46db1de0ac4888b3c9f642426d8c60 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Sat, 10 Oct 2026 13:36:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B0=D1=80=D1=81=D0=B5=D1=80:=20=D0=A4?= =?UTF-8?q?=D0=98=D0=9E=20=D0=B8=D0=B7=20=D1=82=D1=80=D1=91=D1=85=20=D1=81?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=BC=D0=B5=D1=82=D0=B0,=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=8F=D0=B4=D0=BE=D0=BA=20=C2=AB=D0=98=D0=BC=D1=8F=20?= =?UTF-8?q?=D0=9E=D1=82=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=BE=20=D0=A4=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8=D0=BB=D0=B8=D1=8F=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В субботу у ПИЭ-41/42БО в название курса попадало «Денис»: правило разбиения забирало в преподавателя только два последних слова, а здесь их три («Основы тестирования веб-приложений Денис Сергеевич Верховцев (Сигма)»). - правило ФИО в конце строки теперь берёт 2–3 слова и использует нежадный префикс, поэтому первые слова предмета не «съедаются»; - добавлена проверка, что это действительно человек: для трёх слов нужно отчество (-вич/-вна), для двух — «фамильное» окончание, иначе название вроде «Введение в Машинное Обучение» в преподаватели не уедет; - ключ преподавателя собирается в обоих порядках («Верховцев Денис Сергеевич» и «Денис Сергеевич Верховцев») -> «Верховцев Д.С.»; организация вроде «ПравоТех» больше не теряется. Проверено на живой таблице: предмет чистый, в списке преподавателей появился «Верховцев Д.С.», подозрительных ФИО по всем группам нет. Тесты: 168. --- src/backend/schedule/lessons.py | 70 ++++++++++++++++++++++++++------- tests/test_lessons.py | 25 ++++++++++++ 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/backend/schedule/lessons.py b/src/backend/schedule/lessons.py index 60a8707..ff47805 100644 --- a/src/backend/schedule/lessons.py +++ b/src/backend/schedule/lessons.py @@ -159,6 +159,25 @@ def _line_is_room(line: str) -> bool: ) +_PATRONYMIC_END = re.compile(r"(вич|вна|ична|инична)$", re.I) +_SURNAME_END = re.compile(r"(ов|ев|ёв|ин|ын|ский|цкий|ая|ко|ук|юк)$", re.I) + + +def _looks_like_person(text: str) -> bool: + """Похоже ли «Имя Отчество Фамилия» на ФИО (а не на название предмета). + + Для трёх слов требуем отчество (-вич/-вна), для двух — «фамильное» + окончание хотя бы у одного слова: иначе «Машинное Обучение» уедет + в преподаватели. + """ + parts = [part for part in text.split() if part] + if len(parts) >= 3: + return any(_PATRONYMIC_END.search(part) for part in parts[1:]) + if len(parts) == 2: + return bool(_SURNAME_END.search(parts[0]) or _SURNAME_END.search(parts[1])) + return False + + def _classify_line(line: str) -> _LineRoles: roles = _LineRoles() @@ -259,11 +278,13 @@ def _classify_line(line: str) -> _LineRoles: if m: roles.subject, roles.teacher = m.group(1).strip(), m.group(2).strip() return roles - # «Предмет Денис Верховцев (Сигма)» — ФИО без отчества в конце строки + # «Предмет Денис Верховцев (Сигма)» / «Предмет Денис Сергеевич Верховцев (Сигма)» — + # ФИО в конце строки, может быть из двух или трёх слов m = re.search( - r"^(.*\s)([А-ЯЁ][а-яё]{2,}\s+[А-ЯЁ][а-яё]{2,})(\s*\([^()]*\))?\s*$", subject + r"^(.*?\s)([А-ЯЁ][а-яё]{2,}(?:\s+[А-ЯЁ][а-яё]{2,}){1,2})(\s*\([^()]*\))?\s*$", + subject, ) - if m and len(subject.split()) >= 4: + if m and len(subject.split()) >= 4 and _looks_like_person(m.group(2)): teacher = _squish(m.group(2) + " " + (m.group(3) or "")) roles.subject, roles.teacher = m.group(1).strip(), teacher return roles @@ -579,6 +600,34 @@ def is_teacher_name(text: str) -> bool: return bool(_NAME_INITIALS_RE.search(text) or _FULL_NAME_RE.match(text.strip())) +# 2-3 слова с большой буквы — ФИО в любом порядке (без «НПО Криста»: там Криста с большой, +# но первое слово «НПО» — аббревиатура и не подходит под [А-ЯЁ][а-яё]{2,}) +_FULL_NAME_WORDS_RE = re.compile(r"^[А-ЯЁ][а-яё]{2,}(?:\s+[А-ЯЁ][а-яё]{2,}){1,2}$") + + +def _short_from_parts(parts: list[str]) -> str: + """ФИО -> «Фамилия И.О.» с учётом порядка слов. + + В таблице встречаются оба порядка: «Верховцев Денис Сергеевич» и + «Денис Сергеевич Верховцев». Фамилию определяем по отчеству (-вич/-вна), + а для двух слов — по «фамильному» окончанию. + """ + if len(parts) >= 3 and _PATRONYMIC_END.search(parts[1]): + surname, given, patronymic = parts[2], parts[0], parts[1] + elif len(parts) == 2 and _SURNAME_END.search(parts[1]) and not _SURNAME_END.search(parts[0]): + surname, given, patronymic = parts[1], parts[0], "" + else: + surname = parts[0] + given = parts[1] if len(parts) > 1 else "" + patronymic = parts[2] if len(parts) > 2 else "" + short = surname + if given: + short += f" {given[0].upper()}." + if patronymic: + short += f"{patronymic[0].upper()}." + return short + + def extract_teacher_names(teacher: str) -> list[str]: """Строка преподавателя -> список коротких имён «Фамилия И.О.» (ключи индекса). @@ -590,16 +639,9 @@ def extract_teacher_names(teacher: str) -> list[str]: names = [m.strip() for m in _NAME_INITIALS_RE.findall(teacher) if m.strip()] if not names: full = re.sub(r"\s*\([^()]*\)", "", teacher).strip() - full_m = _FULL_NAME_RE.match(full) + full_m = _FULL_NAME_RE.match(full) or _FULL_NAME_WORDS_RE.match(full) if full_m: - parts = full_m.group(0).split() - short = parts[0] + " " + parts[1][0].upper() + "." - if len(parts) > 2: - short += parts[2][0].upper() + "." - return [short] - if ( - not re.search(r"\d|\s", teacher) - or (len(teacher) <= 30 and " " in teacher) - ) and not re.search(r"[а-яё][А-ЯЁ]", teacher): - return [teacher] # организация одним словом или «НПО Криста» + return [_short_from_parts(full_m.group(0).split())] + if len(teacher) <= 30 and not re.search(r"\d", teacher): + return [teacher] # организация: «ПравоТех», «НПО Криста» return names diff --git a/tests/test_lessons.py b/tests/test_lessons.py index 9f60af1..f64c82e 100644 --- a/tests/test_lessons.py +++ b/tests/test_lessons.py @@ -384,6 +384,14 @@ def test_inline_lesson_after_room_no_blank_line(): assert ls[1]["room"] == "ауд. 231" +def test_full_name_three_words_after_subject(): + """«Предмет Денис Сергеевич Верховцев (Сигма)» — ФИО не должно попасть в предмет.""" + result = one("Основы тестирования веб-приложений Денис Сергеевич Верховцев (Сигма)\nонлайн") + assert result["subject"] == "Основы тестирования веб-приложений" + assert result["teacher"] == "Денис Сергеевич Верховцев (Сигма)" + assert result["room"] == "онлайн" + + def test_plain_time_paren_line(): result = one("Параллельное программирование (пр)\nМещеряков И.А.\nауд. 213\n(начало пары в 12:30)") assert result["note"] == "начало пары в 12:30" @@ -420,3 +428,20 @@ def test_inline_note_stays_with_its_lesson(): ls = lessons(raw) assert ls[0]["note"] == "с 7.09 по 20.09 пары не будет" assert ls[1]["note"] == "" + + +@pytest.mark.parametrize( + "teacher, expected", + [ + ("Верховцев Денис Сергеевич", ["Верховцев Д.С."]), + ("Денис Сергеевич Верховцев", ["Верховцев Д.С."]), + ("Денис Верховцев (Сигма)", ["Верховцев Д."]), + ("Лабеев Игорь Владимирович", ["Лабеев И.В."]), + ("ПравоТех", ["ПравоТех"]), + ], +) +def test_teacher_keys_order(teacher, expected): + """Ключ преподавателя — «Фамилия И.О.» в любом порядке ФИО в таблице.""" + from schedule.lessons import extract_teacher_names + + assert extract_teacher_names(teacher) == expected