From 3359ea2f868b21e3595eec9c5eaf71dd938a2999 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 12 Oct 2026 01:39:44 +0300 Subject: [PATCH 1/2] fix: restrict council assessment branches by VAK specialty --- app/data/k1_specialties.json | 70 +++++++++++++++++++++++++------ app/services/council.py | 9 +++- app/static/council.js | 8 ++++ app/templates/council.html | 8 ++-- app/version.py | 6 +-- docs/council-evidence.md | 3 ++ pyproject.toml | 2 +- scripts/extract_k1_specialties.py | 6 ++- tests/browser_council.py | 15 +++++++ tests/test_api.py | 2 +- tests/test_council.py | 6 ++- tests/test_council_assessment.py | 48 +++++++++++++++++---- 12 files changed, 150 insertions(+), 33 deletions(-) diff --git a/app/data/k1_specialties.json b/app/data/k1_specialties.json index 8553634..c0bc4d7 100644 --- a/app/data/k1_specialties.json +++ b/app/data/k1_specialties.json @@ -9,51 +9,97 @@ "specialties": [ { "code": "1.2.2", - "name": "Математическое моделирование, численные методы и комплексы программ" + "name": "Математическое моделирование, численные методы и комплексы программ", + "branches": [ + "технические", + "физико-математические" + ] }, { "code": "2.2.2", - "name": "Электронная компонентная база микро- и наноэлектроники, квантовых устройств" + "name": "Электронная компонентная база микро- и наноэлектроники, квантовых устройств", + "branches": [ + "технические", + "физико-математические" + ] }, { "code": "2.3.6", - "name": "Методы и системы защиты информации, информационная безопасность" + "name": "Методы и системы защиты информации, информационная безопасность", + "branches": [ + "технические", + "физико-математические" + ] }, { "code": "2.3.1", - "name": "Системный анализ, управление и обработка информации, статистика" + "name": "Системный анализ, управление и обработка информации, статистика", + "branches": [ + "технические", + "физико-математические" + ] }, { "code": "2.3.7", - "name": "Компьютерное моделирование и автоматизация проектирования" + "name": "Компьютерное моделирование и автоматизация проектирования", + "branches": [ + "технические", + "физико-математические" + ] }, { "code": "1.3.8", - "name": "Физика конденсированного состояния" + "name": "Физика конденсированного состояния", + "branches": [ + "технические", + "физико-математические" + ] }, { "code": "2.2.15", - "name": "Системы, сети и устройства телекоммуникаций" + "name": "Системы, сети и устройства телекоммуникаций", + "branches": [ + "технические", + "физико-математические" + ] }, { "code": "1.1.2", - "name": "Дифференциальные уравнения и математическая физика" + "name": "Дифференциальные уравнения и математическая физика", + "branches": [ + "физико-математические" + ] }, { "code": "2.2.13", - "name": "Радиотехника, в том числе системы и устройства телевидения" + "name": "Радиотехника, в том числе системы и устройства телевидения", + "branches": [ + "технические", + "физико-математические" + ] }, { "code": "2.3.2", - "name": "Вычислительные системы и их элементы" + "name": "Вычислительные системы и их элементы", + "branches": [ + "технические" + ] }, { "code": "2.2.14", - "name": "Антенны, СВЧ-устройства и их технологии" + "name": "Антенны, СВЧ-устройства и их технологии", + "branches": [ + "технические", + "физико-математические" + ] }, { "code": "2.3.5", - "name": "Математическое и программное обеспечение вычислительных систем, комплексов и компьютерных сетей" + "name": "Математическое и программное обеспечение вычислительных систем, комплексов и компьютерных сетей", + "branches": [ + "технические", + "физико-математические" + ] } ], "journals": [ diff --git a/app/services/council.py b/app/services/council.py index 758aecc..5e4f37a 100644 --- a/app/services/council.py +++ b/app/services/council.py @@ -261,6 +261,10 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs specialties = _k1_specialties()["specialties"] if specialty and specialty not in {s["code"] for s in specialties}: raise CouncilInputError("Неизвестная специальность ВАК") + branches = list(dict.fromkeys(name for s in specialties if not specialty or s["code"] == specialty + for name in s["branches"])) + if branch and branch not in branches: + raise CouncilInputError("Отрасль оценки не относится к выбранным специальностям ВАК") specialty_name = next((s["name"] for s in specialties if s["code"] == specialty), "") degree_sections = Employee.current_data["sections"] if db.get_bind().dialect.name == "postgresql": @@ -421,13 +425,14 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs limit = 25 offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0 criteria_groups = {} - for name, (minimum, monographs) in BRANCHES.items(): + for name in branches: + minimum, monographs = BRANCHES[name] criteria_groups.setdefault((minimum, monographs), []).append(name) return {"criteria_groups": [{"names": ", ".join(names), "minimum": minimum, "monographs": monographs} for (minimum, monographs), names in criteria_groups.items()], "journal_names": sorted({name for c in candidates for a in c["articles"] for name in a["journals"]}), "revision": revision, "candidates": candidates[offset:offset + limit] if paginate else candidates, "start": start, "end": end, "total": total, "specialties": specialties, "specialty": specialty, "specialty_name": specialty_name, - "branch": branch, "branches": list(BRANCHES), + "branch": branch, "branches": branches, "catalog_metadata": catalog()["metadata"], "catalog_issues": catalog()["issues"], "category_metadata": [{"file": path.name, **{key: data[key] for key in ("source", "source_sha256", "source_rows")}} for path in (K1_JOURNALS, K2_JOURNALS) diff --git a/app/static/council.js b/app/static/council.js index 880c2aa..06acf4b 100644 --- a/app/static/council.js +++ b/app/static/council.js @@ -1,4 +1,12 @@ (() => { + const specialty = document.querySelector('#council-specialty'); + const branch = document.querySelector('#council-branch'); + specialty?.addEventListener('change', () => { + const names = JSON.parse(specialty.selectedOptions[0].dataset.branches); + const previous = branch.value; + branch.replaceChildren(...names.map(name => new Option(name[0].toUpperCase() + name.slice(1), name))); + if (names.includes(previous)) branch.value = previous; + }); const dialog = document.querySelector('[data-council-dialog]'); if (!dialog) return; const form = dialog.querySelector('[data-edit-form]'); diff --git a/app/templates/council.html b/app/templates/council.html index a2f8e8a..fb40436 100644 --- a/app/templates/council.html +++ b/app/templates/council.html @@ -5,7 +5,7 @@

Диссертационный совет

-

Подбор сотрудников в диссертационный совет по техническим наукам. Публикации за пять календарных лет, включая текущий год, сопоставляются с данными о квартилях и категориях журналов. Для доктора наук минимум зависит от отрасли оценки; для кандидата наук или PhD — 20 работ. Предварительный подсчёт и подтверждённые работы показаны отдельно.

+

Подбор сотрудников в диссертационный совет по техническим и физико-математическим наукам. Публикации за пять календарных лет, включая текущий год, сопоставляются с данными о квартилях и категориях журналов. Для доктора наук минимум зависит от отрасли оценки; для кандидата наук или PhD — 20 работ. Предварительный подсчёт и подтверждённые работы показаны отдельно.

@@ -26,7 +26,7 @@

Q1, Q2, К1, К2 и RSCI — предварительные показатели. Квартили определяются по данным ВШЭ, применимым данным каталогов и проверенным основаниям; категории К1/К2 — по сопоставлению журнала со справочниками ВАК. RSCI учитывается при наличии отдельной записи об этом основании; отметка РИНЦ сама по себе его не подтверждает. Сведения из профиля сотрудника дополняются внешними источниками.

ВСЕГО — число уникальных работ с основаниями Q1/Q2/К1/RSCI; для доктора наук учитывается также К2. Пересечения между основаниями не увеличивают итог. Для кандидата наук или PhD одной категории К2 недостаточно. Известные другие виды материалов не входят в этот подсчёт без отдельного подтверждения их допустимости.

«Подтверждено» — работы с проверенными видом материала, периодом и допустимым основанием. Известная статья автоматически засчитывается по Q1/Q2, если источник связывает её с журналом, квартиль применим к году публикации и нет конфликтов идентичности. Отдельное ручное подтверждение уже известных типа статьи и квартиля не требуется. Точная дата публикации не требуется; текущая категория журнала не подтверждает его категорию в год выхода работы. «Требует проверки» означает, что для вывода не хватает подтверждений: например, степени, года или основания зачёта работы. Это не означает, что у сотрудника нет нужных публикаций. Соответствие публикационному минимуму не означает автоматического включения в совет.

-

Порог зависит от отрасли науки, по которой сотрудник включается в совет. Сейчас расчёт выполняется по отрасли «{{ branch }}». Код специальности журнала сам по себе не меняет отрасль оценки.

+

Порог зависит от отрасли науки, по которой сотрудник включается в совет. Сейчас расчёт выполняется по отрасли «{{ branch }}». Доступные отрасли ограничены выбранной специальностью ВАК.

Связь журнала со специальностью можно изменить в редакторе; она применяется ко всем статьям этого журнала. Это связь тематики журнала, а не заключение о содержании каждой статьи.

Фильтр «Специальность ВАК» подбирает сотрудников с хотя бы одной публикацией за пять лет в подходящем журнале К1 либо журнале с ручной тематической привязкой. Связь определяется по журналу из Перечня ВАК на 15.09.2026, а не по содержанию статьи. Квартильные каталоги содержат ограниченные подборки и метрики 2025 года; они не подтверждают другие годы или текущую индексацию. Аналитическая связь журнала со специальностью и отдельное рассмотрение статьи показаны в публикациях.

@@ -41,9 +41,9 @@ diff --git a/app/version.py b/app/version.py index 3463e77..9491a92 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "1.2.1" -FRONTEND_VERSION = "1.2.1" -BACKEND_VERSION = "1.2.1" +APP_VERSION = "1.2.2" +FRONTEND_VERSION = "1.2.2" +BACKEND_VERSION = "1.2.2" diff --git a/docs/council-evidence.md b/docs/council-evidence.md index 9fb91e0..80bff8d 100644 --- a/docs/council-evidence.md +++ b/docs/council-evidence.md @@ -78,3 +78,6 @@ Для обновления К2 используйте существующий экстрактор: `python scripts/extract_k1_journals.py "app/data/ВАК категории.pdf" --category К2 --output app/data/k2_journals.json`. Строки, даты, алиасы и SHA-256 сохраняются, К1 не изменяется. После изменения JSON/PDF пересоберите образ и пересоздайте API: справочники и подтверждения кешируются в процессе. Ручные тематические связи журнала задаются в редакторе публикаций и хранятся отдельно от парсера, в `council_corrections.data.specialties`. Они заменяют справочную связь для фильтра и XLSX, но не являются рассмотрением каждой статьи по паспорту и не меняют отрасль оценки. + + +Отрасли оценки ограничены двенадцатью специальностями сайта: 1.1.2 допускает только физико-математические науки, 2.3.2 — только технические; остальные десять допускают обе отрасли. Основание — поле «Наименование отрасли науки, по которой присуждаются ученые степени» в сохранённых PDF-паспортах `app/data/scientific-specialty-passports/pdf/`. Список хранится в `k1_specialties.json` и сохраняется при регенерации справочника. При выборе «Все специальности» доступны обе отрасли; при смене специальности форма сохраняет подходящую отрасль либо выбирает первую допустимую. Сервер отклоняет несовместимые пары для списка, карточек и XLSX. diff --git a/pyproject.toml b/pyproject.toml index ce52de6..4b3d39e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "1.2.1" +version = "1.2.2" description = "MIEM employees parser, admin API, and web admin" requires-python = ">=3.11" dependencies = [ diff --git a/scripts/extract_k1_specialties.py b/scripts/extract_k1_specialties.py index 9b73ebc..9f7b121 100644 --- a/scripts/extract_k1_specialties.py +++ b/scripts/extract_k1_specialties.py @@ -170,7 +170,11 @@ if __name__ == "__main__": result = {"schema_version": 1, "source": args.source.name, "source_sha256": hashlib.sha256(args.source.read_bytes()).hexdigest(), "as_of": args.as_of, "k1_source_sha256": hashlib.sha256(args.k1.read_bytes().replace(b"\r\n", b"\n")).hexdigest(), "k1_source_hash_eol": "LF", "source_rows": len(journals), - "specialties": [{"code": code, "name": name} for code, name in SPECIALTIES], "journals": entries} + "specialties": [{"code": code, "name": name, + "branches": ["физико-математические"] if code == "1.1.2" else + ["технические"] if code == "2.3.2" else + ["технические", "физико-математические"]} + for code, name in SPECIALTIES], "journals": entries} args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n") from collections import Counter print(len(entries), dict(Counter(j['status'] for j in entries))) diff --git a/tests/browser_council.py b/tests/browser_council.py index be39a5b..e1f16a8 100644 --- a/tests/browser_council.py +++ b/tests/browser_council.py @@ -92,6 +92,21 @@ def main(): assert "Без степени" not in page.content() and "Только звание" not in page.content() expect(page.locator("#council-date")).to_have_count(0) assert "Монографии" not in page.content() + branches = page.locator('#council-branch') + specialty = page.locator('#council-specialty') + expect(branches.locator('option')).to_have_count(2) + specialty.select_option('1.1.2') + expect(branches.locator('option')).to_have_count(1) + expect(branches).to_have_value('физико-математические') + specialty.select_option('2.3.2') + expect(branches.locator('option')).to_have_count(1) + expect(branches).to_have_value('технические') + specialty.select_option('1.2.2') + expect(branches.locator('option')).to_have_count(2) + branches.select_option('физико-математические') + specialty.select_option('') + expect(branches).to_have_value('физико-математические') + branches.select_option('технические') page.get_by_role("button", name="Применить", exact=True).click() expect(page.get_by_role("link", name="Вход в систему")).to_be_visible() page.get_by_role("link", name="Далее").click() diff --git a/tests/test_api.py b/tests/test_api.py index de38e60..021e80b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,7 +18,7 @@ def test_health_returns_versions(): response = TestClient(app).get("/api/health") assert response.status_code == 200 - assert response.json()["backend_version"] == "1.2.1" + assert response.json()["backend_version"] == "1.2.2" def test_api_employees_and_stats_require_admin_session(): diff --git a/tests/test_council.py b/tests/test_council.py index 62335a0..4bf152b 100644 --- a/tests/test_council.py +++ b/tests/test_council.py @@ -223,7 +223,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export): assert "javascript:" not in fragment.text and "private@example.org" not in fragment.text assert "javascript:" not in response.text and "private@example.org" not in response.text assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text - assert "Backend 1.2.1 · Frontend 1.2.1" in response.text + assert "Backend 1.2.2 · Frontend 1.2.2" in response.text assert "Вход в систему" in response.text assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403) assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text @@ -501,7 +501,9 @@ def test_specialty_page_pagination_search_and_export_agree(db_session, export): client = TestClient(app) response = client.get("/admin/council?specialty=1.2.2&sort=full_name&direction=asc") assert response.status_code == 200 - assert '