feat: implement sourced council publication assessment
CI / test (pull_request) Successful in 9m24s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped

This commit is contained in:
codex committed 2026-10-08 18:05:32 +03:00
1 parent 44fadd7289
commit 24d07706e1
37 files changed
+72554 -72

No files matched your search

+7
View File
@@ -1,5 +1,12 @@
# Changelog
## 0.16.0
- Проверка публикационных критериев совета расширена на кандидатов наук и PhD. Выбирается высшая степень из истории; сотрудники без публикаций остаются видимыми. Добавлены дата ходатайства, отрасль оценки, пороги пунктов 5–6 и отдельный минимум монографий.
- Предварительные числа отделены от подтверждённых работ и итогового заключения. Недостаток доказательств, пограничные даты и конфликты требуют проверки. Документальные подтверждения хранятся отдельно от результатов парсинга; без подтверждения степени и обязательного контекста положительное заключение не формируется.
- Добавлены справочник К2, отдельное основание RSCI, два тематических каталога Q1/Q2 и проверки целостности паспортов. К2 самостоятельно допустим только для доктора. Снимки метрик не распространяются на другие годы; аналитическая связь журнала и рассмотрение статьи показываются отдельно.
- Страница и XLSX используют единый расчёт, сохраняют контекст при поиске и переходах и показывают пороги, подтверждения, источники и причины проверки. Подбор специальности по К1 обозначен как вспомогательный; версии backend/frontend повышены до 0.16.0.
## 0.15.4
- Исправлена аналогичная ошибка SHA-256 в справочнике специальностей К1: хеш исходного JSON рассчитывается с LF, оба генератора справочников записывают LF, формат закреплён в Git. Добавлена проверка одинаковой генерации из LF/CRLF; журналы и связи со специальностями не изменены.
+10 -10
View File
@@ -88,21 +88,21 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
## Кандидаты в диссертационный совет
Страница `/admin/council` доступна без авторизации. Она показывает сотрудников с докторской степенью в профиле и хотя бы одной публикацией в CSV или таблице `employee_publications`; минимальный порог не применяется. Уволенные сотрудники отмечены отдельно. Доступны поиск по ФИО, сортировка по каждому счётчику и страницы по 25 сотрудников. XLSX по `/admin/council/export.xlsx` содержит всех найденных сотрудников с выбранной сортировкой, их публикации и параметры расчёта.
Страница `/admin/council` доступна без авторизации. Показывает докторов, кандидатов наук и PhD, включая сотрудников без доступных публикаций; уволенные отмечены отдельно. Высшая степень выбирается по всей истории: доктор выше кандидата/PhD, кандидат и PhD имеют одинаковый уровень. Неподтверждённого доктора нельзя заменить подтверждённым кандидатом. Звание профессора или доцента не заменяет степень.
Счётчики **Q1**, **Q2**, **К1** и **ВСЕГО** учитывают уникальные публикации за последние пять лет по году. Q1/Q2 берутся из отметок Scopus или Web of Science в CSV и сохранённом CSV-обогащении БД. К1 определяется по совпадению нормализованного названия журнала с итоговой категорией в предоставленном справочнике ВАК. ВСЕГО — объединение Q1 ИЛИ Q2 ИЛИ К1: работа в нескольких категориях учитывается один раз. При расхождении годов источников каждый критерий проверяется по году своей записи; неизвестный год наследуется только при единственном согласованном годе. Счётчики «В БД» и «Все публикации» включают все годы независимо от категорий.
Форма принимает дату ходатайства и отрасль предполагаемого включения; отрасль не выводится автоматически из степени или специальности. Пункт 5: для доктора порог по отрасли — 7, 8, 9 либо 11 работ, для части отраслей нужна одна монография. Пункт 6: кандидат/PhD — 20 работ; самостоятельного К2 недостаточно. Период — пять лет перед указанной датой. Без даты показывается явно предварительный период относительно текущей даты Москвы. Некорректные дату, отрасль и специальность отклоняет HTTP 422.
Подготовленный JSON `app/data/council_articles.json` сопоставляется по полному ФИО только при единственном совпадении. Публикации объединяются по DOI, затем по нормализованным названию и году, если нет конфликтующих DOI. Импорт CSV в БД не выдаётся за данные сайта. Для К1 поддерживаются необязательные колонки CSV «Название журнала» или «Журнал»; в исходном CSV их нет. В БД название берётся из поля `journal` в `raw_data` или короткого описания ВШЭ (`description.short`/`shortLeft`, название перед годом). Название самой публикации для определения журнала не используется. Неизвестный или несовпавший журнал не считается К1; перевод и нечёткое сопоставление не применяются. Явно указанное в PDF прежнее название журнала сохраняется как проверенный алиас; служебное примечание о переименовании хранится отдельно от названия.
Отдельно показаны предварительные Q1/Q2/К1/К2/RSCI, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет.
Справочник `app/data/k1_journals.json` содержит 773 журнала К1, номера строк, даты категорирования и SHA256 исходного «ВАК категории.pdf». При извлечении проверены все 3134 строки; для записей с историей смены категории взята последняя категория. PDF при запросах не читается. Для обновления справочника разово установите `pdfplumber` в среду разработки и выполните:
Сервис читает подготовленный `app/data/council_articles.json` и таблицу `employee_publications`. Справочник преобразуется из CSV скриптом `scripts/prepare_council_articles.py`, проверяет схему и индекс авторов; импорт/парсинг CSV не объявляется доказательством выполнения критериев. Сопоставление CSV по полному ФИО разрешено только при единственном совпадении. Дедупликация использует DOI либо нормализованные название и год при отсутствии неоднозначных DOI. Источники с разными годами не переносят категории между периодами.
```powershell
python scripts/extract_k1_journals.py "app/data/ВАК категории.pdf"
```
К1/К2 сопоставляются по точным нормализованным названиям журнала и документированным алиасам; название статьи, переводы и нечёткое совпадение не используются. Справочник К1 содержит 773 журнала, К2 — 1539; экстрактор проверяет все 3134 строки, даты и SHA-256 источника. Это текущие категории, а не историческое доказательство. Q1/Q2 читаются также из двух тематических JSON-каталогов (схемы 1.0.0/1.1.0, 229 уникальных журналов): ISSN/eISSN, категории CiteScore/JCR, источники и версии паспортов. Снимок метрик 2025 не распространяется автоматически на другие годы; текущая индексация не проверена. Каталоги не являются белым списком. Аналитические прямые/условные связи журнала показываются отдельно от рассмотрения статьи по паспорту.
После замены CSV или JSON нужно пересобрать Docker-образ и пересоздать API: данные включены в образ и кешируются в процессе.
Подтверждения хранятся отдельно от перезаписываемых парсером данных, в `app/data/council_evidence.json`; по умолчанию файл пустой. Формат, привязка к сотруднику/работе, правила конфликта, монографии и нормативные уточнения: [docs/council-evidence.md](docs/council-evidence.md). Непубличные документы и контакты на страницу не выводятся. РИНЦ или поле `hse_list` не подтверждают RSCI. Неизвестные виды, пограничные даты и неподтверждённые основания не создают положительного заключения.
Счётчики предварительные: для пограничных лет нужно проверить точную дату, для неизвестного типа — подтвердить, что это статьи. Материалы с известным другим типом не входят в Q1/Q2/К1 и ВСЕГО. К1 означает итоговую категорию в предоставленном справочнике, а не историческую категорию на дату публикации. Отсутствие отметок не доказывает недостаточную активность. Публичная страница не открывает контакты и служебные операции.
Доступны поиск, сортировка, страницы по 25 сотрудников и XLSX всех найденных сотрудников. Дата, отрасль и остальные параметры сохраняются при переходах и экспорте. Листы «Сотрудники», «Публикации» и «Параметры» используют тот же расчёт, включают причины проверки и источники; внешние ссылки проверяются, строки не выполняются как формулы Excel. Фильтр специальности по журналам К1 остаётся отдельным вспомогательным подбором с ограниченным охватом, а не условием соответствия пунктам 5–6.
После изменения JSON/PDF пересоберите образ и пересоздайте API: данные включены в образ и кешируются в процессе.
## Парсинг
@@ -155,7 +155,7 @@ node --check app/static/admin.js
Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
Версия сервиса: `0.15.4`. Админка всегда показывает версии backend и frontend в footer.
Версия сервиса: `0.16.0`. Админка всегда показывает версии backend и frontend в footer.
## Разовый импорт публикаций
+11 -3
View File
@@ -19,7 +19,7 @@ from app.services.admin_data import (
)
from app.services.crawl_control import get_running_run, run_crawl_if_idle
from app.services.crawler import refresh_dismissed_status, refresh_employee
from app.services.council import council_candidates
from app.services.council import CouncilInputError, council_candidates
from app.services.council_export import council_xlsx
from app.version import BACKEND_VERSION, FRONTEND_VERSION
@@ -136,9 +136,13 @@ def directory(
@router.get("/council", response_class=HTMLResponse)
def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", specialty: str = "",
petition_date: str = "", branch: str = "",
db: Session = Depends(get_db)):
try:
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty)
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty,
petition_date=petition_date, branch=branch)
except CouncilInputError as exc:
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503)
@@ -147,10 +151,14 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_c
@router.get("/council/export.xlsx")
def export_council(request: Request, q: str = "", sort: str = "total_count", direction: str = "desc", specialty: str = "",
petition_date: str = "", branch: str = "",
db: Session = Depends(get_db)):
try:
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty)
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty,
petition_date=petition_date, branch=branch)
content = council_xlsx(result)
except CouncilInputError as exc:
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
return _render(request, "council.html", {"error": True, "public_page": True, "export_error": True}, status_code=503)
+59
View File
@@ -0,0 +1,59 @@
# Инструкция по выбору критериев для кандидата в диссертационный совет
Источник: [npa8.pdf](npa8.pdf), приложение № 1, пункты 5–6, страницы 3–4. Рекомендация от 07.07.2023 № 19/11-разн на странице 5 устанавливает дату введения критериев — 01.01.2024.
## 1. Установить учёную степень
Определить документально подтверждённую степень сотрудника: доктор наук, кандидат наук или PhD. Учёное звание профессора или доцента не заменяет степень. Если степень отсутствует или не подтверждена, по этим критериям заключение не формировать.
## 2. Определить период оценки
Проверять публикации за последние пять лет, предшествующих дате подачи ходатайства. Использовать дату ходатайства, а не автоматически текущую дату. Если известен только год публикации, для пограничных лет потребуется уточнение даты.
## 3. Выбрать критерии по степени
| Учёная степень | Применяемый пункт | Требование к публикациям | Допустимые издания |
|---|---|---|---|
| Доктор наук | Пункт 5 | Порог зависит от отрасли науки — см. таблицу ниже | К1 или К2 Перечня ВАК, либо RSCI, либо Q1/Q2 в предусмотренных международных базах |
| Кандидат наук | Пункт 6 | Не менее 20 | К1 Перечня ВАК, либо RSCI, либо Q1/Q2 в предусмотренных международных базах |
| PhD | Пункт 6 | Не менее 20 | К1 Перечня ВАК, либо RSCI, либо Q1/Q2 в предусмотренных международных базах |
Привязка докторов наук к пункту 5 следует из структуры документа: пункт 6 отдельно устанавливает критерии для кандидатов наук и PhD; заголовок пункта 5 формулирует общие критерии для кандидатов в члены советов.
Для доктора наук выбрать порог по отрасли, по которой предполагается включение в совет:
| Отрасль науки | Минимум публикаций | Требование к монографиям |
|---|---:|---|
| Биологические, географические, геолого-минералогические, медицинские, фармацевтические, физико-математические, химические | 11 | Справочно |
| Архитектура, ветеринарные, сельскохозяйственные, технические | 9 | Справочно |
| Исторические, педагогические, политические, психологические, социологические, филологические, философские, экономические, теология | 8 | Не менее 1 |
| Юридические | 8 | Справочно |
| Искусствоведение, культурология | 7 | Справочно |
«Справочно» означает отсутствие обязательного минимального количества монографий в таблице. В пункте 6 отдельное требование к монографиям не приведено. Единого порога «10 публикаций» документ не устанавливает.
## 4. Проверить основания зачёта публикаций
Проверить период публикации и подтверждение хотя бы одного допустимого основания по выбранному пункту. Для кандидата наук и PhD принадлежность только к К2 недостаточна.
Международные базы, перечисленные в документе на странице 1: Web of Science, Scopus, PubMed, MathSciNet, zbMATH, Chemical Abstracts, Springer, GeoRef.
При подготовке расчёта одну публикацию учитывать один раз, даже если она соответствует нескольким основаниям. Это правило предотвращения дублей в расчёте; отдельной формулировки о дедупликации в документе нет.
## 5. Зафиксировать результат
Указать:
- подтверждённую учёную степень;
- отрасль науки;
- дату подачи ходатайства и период оценки;
- применённый пункт документа;
- требуемое и подтверждённое количество публикаций;
- сведения о монографиях, включая обязательный минимум там, где он установлен;
- неподтверждённые сведения и причины необходимости дополнительной проверки.
При недостатке подтверждений указать «требует проверки». При выполнении требований итог формулировать как «соответствует публикационным критериям пункта 5/6».
## Пределы инструкции
Инструкция составлена по предоставленному файлу `npa8.pdf`. Последующие изменения документа и остальные условия членства в диссертационном совете не проверены. Соответствие публикационным критериям не означает автоматического включения в совет.
+135
View File
@@ -0,0 +1,135 @@
# Работа с каталогами журналов Q1 и Q2
Используйте каталоги для подбора журналов по выбранным научным специальностям и проверки сохранённых квартильных данных. Тематическое соответствие в файлах относится к профилю журнала. При оценке публикации отдельно сопоставляйте содержание и научный результат конкретной статьи с паспортом специальности.
**Каталоги являются тематическими подборками. Отсутствие журнала в файле не означает, что он не имеет Q1/Q2 или не подходит для выбранной специальности.** Журналы, не включённые в подборку, в этих файлах не оценены.
Структура и значения полей проверены 08.10.2026.
## Файлы и период данных
Пути в таблице указаны относительно папки `app/data`.
| Файл | Версия схемы | Период метрик | Критерий включения |
|---|---|---|---|
| [q1-journals/catalog.json](q1-journals/catalog.json) | `1.0.0` | 2025 | Хотя бы одна категория Q1 в Scopus и хотя бы одна категория Q1 в JCR/JIF |
| [q2-journals/catalog.json](q2-journals/catalog.json) | `1.1.0` | 2025 | Хотя бы одна категория Q2 в Scopus и хотя бы одна категория Q2 в JCR/JIF |
Scopus представлен метрикой **CiteScore**, Web of Science — **JIF-квартилями Journal Citation Reports**. Значение CiteScore и квартиль являются разными показателями. SJR в качестве критерия отбора этих подборок не использован.
Для CiteScore Q1 соответствует перцентилю от 75 включительно; Q2 — от 50 включительно до 75 исключительно. Квартиль JCR берите из сохранённого поля `quartile` соответствующей категории.
Предметные категории двух систем могут различаться. Принадлежность к подборке не означает, что журнал имеет указанный квартиль во всех своих категориях или именно в категории, наиболее близкой к рассматриваемой статье.
## Разделы catalog.json
| Раздел | Содержание и применение |
|---|---|
| `metadata` | Область охвата, правила отбора, годы, версия схемы и ограничения |
| `journals` | Названия, идентификаторы, тематика, квартильные данные и ссылки на источники |
| `specialty_links` | Подробные связи между журналами и специальностями: уровень, обоснования, условия, пункты паспортов |
| `assessments` | Оценки каждой пары «включённый журнал — выбранная специальность», включая отсутствие установленного соответствия |
| `specialties` | Названия специальностей, версии паспортов и направления исследований с номерами пунктов |
| `sources` | Источники квартильных таблиц, годы, даты проверки и контрольные суммы |
Связывайте записи по идентификаторам:
- `specialty_links[].journal_id` и `assessments[].journal_id` ссылаются на `journals[].id`;
- `specialty_code` ссылается на `specialties[].code`;
- `passport_references[].passport_id` ссылается на `specialties[].passports[].id`;
- `point_numbers` ссылаются на `directions[].number` соответствующего паспорта;
- `scopus.source_id` и `wos.source_id` ссылаются на `sources[].id`.
Ключ журнала имеет вид `scopus:<source ID>`. Название используйте для отображения и поиска; связи храните по `id`. Поле `journals[].name` содержит исходное название, преимущественно английское. Русскими являются описания тематики и тематических связей. Перевод названия для отображения должен сохранять связь с исходным названием и идентификаторами.
`journals[].scopus_source_id` — числовой идентификатор самого журнала в Scopus. Вложенные `scopus.source_id` и `wos.source_id` обозначают источники подтверждения квартилей из раздела `sources`.
Относительные пути `specialties[].passports[].local_pdf` разрешайте от папки соответствующего `catalog.json`. Поэтому `../scientific-specialty-passports/pdf/...` из папки Q1 или Q2 ведёт к сохранённым паспортам в `app/data/scientific-specialty-passports`.
## Уровни тематического соответствия
| Значение | Значение для читателя | Как действовать |
|---|---|---|
| `direct` | Устойчивое профильное соответствие журнала специальности | Рассматривать журнал как профильного кандидата; проверять конкретную статью по указанным пунктам паспорта |
| `conditional` | Соответствие установлено при определённом содержании или результате статьи | Прочитать `conditions` и проверить, выполняются ли относящиеся к статье условия |
| `not_established` | По использованным источникам самостоятельное соответствие не установлено | Не считать пару подтверждённой; при необходимости провести отдельное тематическое рассмотрение |
`direct` и `conditional` представлены в `specialty_links[].match_level`. Все три значения встречаются в `assessments[].assessment`.
Для каждой найденной связи читайте:
- `rationales` — тематическое обоснование;
- `conditions` — текстовые условия и оговорки;
- `passport_references` — конкретные версии паспортов и номера пунктов;
- `scope_source_url` — источник описания темы журнала;
- `assessment_kind` — характер оценки; в текущих файлах используется `analytical`.
Условия могут присутствовать и у связи с уровнем `direct`. Пустой массив условий также не подтверждает соответствие любой статьи. Массивы `rationales` и `conditions` содержат текстовые пояснения, а не формальные логические выражения: машинная логика AND/OR для них схемой не задана.
`journals[].specialty_codes` объединяет специальности с прямыми **и** условными связями. Это поле удобно для предварительного поиска, но уровня соответствия в нём нет. Для решения по конкретной специальности обращайтесь к `specialty_links`.
Если журнал вообще отсутствует в `journals`, для него нельзя вывести `not_established`: такой вывод описывает оценку пары в каталоге, а не любой журнал за его пределами.
## Проверка конкретной публикации
1. Найдите журнал по ISSN/eISSN и исходному названию. При расхождении названий проверьте, относятся ли они к одному изданию. Переводная версия, переименование и совпадение отдельных слов требуют проверки идентификаторов.
2. Выберите научную специальность и найдите связь по паре `journal_id` + `specialty_code`. При отсутствии связи посмотрите соответствующую запись `assessments`.
3. Прочитайте уровень, обоснования и условия. Сопоставьте цель, методы и научный результат статьи с указанными направлениями исследований. Одного совпадения ключевых слов или тематики журнала недостаточно.
4. Откройте нужную версию паспорта через `specialties[].passports` и проверьте номера пунктов. Для 1.3.8 технический и физико-математический паспорта имеют раздельную нумерацию. Учитывайте `metadata.passport_note`, включая примечание к 1.2.2 о диссертационном исследовании.
5. Проверьте квартиль в каждой системе, категорию и год. Для другого года потребуется соответствующее подтверждение; данные за 2025 год сами по себе не устанавливают квартиль за иной период.
6. Если требуется актуальный статус индексации или подбор площадки подачи, отдельно проверьте текущую индексацию, описание тематики, принимаемые типы статей и редакционные правила.
7. Зафиксируйте результат проверки статьи отдельно от исходной связи журнала: выбранную специальность, пункты паспорта, выполненные условия, квартильные категории и годы, подтверждающие источники и дату рассмотрения.
Тематическая оценка каталога не является официальным присвоением журналу специальности и не заменяет решение по конкретной публикации.
## Квартильные данные и источники
| Поля | Как читать |
|---|---|
| `scopus.metric`, `scopus.year`, `scopus.value` | Название метрики CiteScore, её год и числовое значение |
| `scopus.categories[]` | Квартиль, перцентиль, ранг и размер списка для каждой категории Scopus |
| `scopus.categories[].source_row` | Номер строки опубликованной таблицы, на которую опирается запись |
| `wos.metric_year`, `wos.release_year` | Год метрики JCR/JIF и год выпуска отчёта; в этих данных — 2025 и 2026 соответственно |
| `wos.categories[]` | Квартиль, предметная категория и редакция индекса (`edition`) |
| `wos.categories[].source_pdf_page` | Номер страницы опубликованной таблицы JCR |
| `scopus.q1_verified`, `wos.q1_verified` | Подтверждение критерия Q1 по сохранённым таблицам; поля используются в каталоге Q1 |
| `scopus.q2_verified`, `wos.q2_verified` | Подтверждение критерия Q2 по сохранённым таблицам; поля используются в каталоге Q2 |
| `live_indexing_status_verified` | Проверялся ли отдельно текущий статус индексации; `false` означает отсутствие такой проверки, а не отсутствие индексации |
В каталогах сохранены все доступные категории включённых журналов, в том числе категории с другими квартилями. Для подтверждения основания выбирайте конкретную категорию и соответствующий ей квартиль.
`metadata.generated_on` — дата формирования подборки, а не год метрики. Даты `checked_on` относятся к проверке сохранённого подтверждения и не подменяют актуальную проверку индексации.
Квартильные данные основаны на открыто опубликованных копиях таблиц Scopus и JCR. Учитывайте `metadata.quartile_evidence`, сведения в `sources`, а также ссылки на первичные карточки Scopus. Подтверждение по такой таблице не следует представлять как результат текущего авторизованного сеанса Scopus/JCR.
Для тематики читайте `journals[].scope.summary_ru`, `source_url`, `evidence_type` и `verification_note`. Архивное или воспроизведённое описание может обосновывать профиль журнала, но не подтверждает текущие ограничения подачи. При выборе журнала также учитывайте `article_type_note`.
## Пересечения Q1 и Q2
Подборки могут пересекаться: один журнал может иметь категории Q1 и Q2 одновременно. В Q2 отбор выполнен по наличию категории Q2 в обеих системах, а не по требованию, чтобы лучший квартиль был Q2.
В каталоге Q2 предусмотрены дополнительные поля:
| Поле | Значение |
|---|---|
| `scopus.best_quartile`, `wos.best_quartile` | Лучший из сохранённых квартилей соответствующей системы |
| `also_meets_q1_rule` | Журнал имеет хотя бы одну категорию Q1 в каждой из двух систем |
| `in_local_q1_catalog` | Журнал фактически присутствует в ранее сохранённой тематической подборке Q1 |
Эти два признака пересечения различаются: журнал может выполнять квартильный критерий Q1, но отсутствовать в локальной подборке Q1 из-за её ограниченного охвата.
При объединении файлов учитывайте журнал один раз по `journals[].id`, сохраняя оба основания отбора. Связи со специальностями сопоставляйте по паре `journal_id` + `specialty_code`; сохраняйте относящиеся к ним обоснования, условия и источники. Совпадение журнала в двух каталогах не образует две разные публикации и не удваивает число связанных специальностей.
## Различия схем и чтение полей
В текущем Q1 отсутствуют поля `metadata.target_quartile` и `metadata.selection_rule`; правило находится в `metadata.q1_rule`. В Q2 используются `target_quartile`, `q2_rule` и `selection_rule`.
Сначала прочитайте `metadata.schema_version`. Правило отбора берите из `selection_rule`, если оно есть, иначе из `q1_rule` или `q2_rule`. Дополнительные поля Q2 не следует требовать от файла Q1. Отсутствующее поле и значение `false` имеют разный смысл.
При обработке данных проверяйте, что идентификаторы журналов и пар «журнал — специальность» уникальны, все ссылки разрешаются и номера пунктов существуют в соответствующих паспортах. Повреждённую или неполную запись возвращайте на проверку, сохраняя причину; не превращайте её автоматически в положительный или отрицательный вывод о соответствии.
## Сопоставление с К1 и обновление
Категория К1 ВАК и квартили Q1/Q2 являются самостоятельными основаниями. Принадлежность к одному списку не устанавливает принадлежность к другому. Для сопоставления с [k1_journals.json](k1_journals.json) используйте ISSN/eISSN и проверенные варианты названий; дополнительные идентификаторы доступны в [k1_specialties.json](k1_specialties.json). Перед использованием такого сопоставления проверяйте его связь с актуальными исходными файлами.
При обновлении каталогов заново сверяйте квартильные категории за выбранный год, идентификаторы, источники тематики и связи с паспортами. Изменение квартиля, описания журнала или паспорта может потребовать пересмотра условий и уровня соответствия. Сохраняйте год, дату проверки, источник и версию схемы; исключение журнала из обновлённой подборки само по себе не является отрицательной оценкой его тематики.
+5
View File
@@ -0,0 +1,5 @@
{
"schema_version": 1,
"policy": {},
"employees": {}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+85 -12
View File
@@ -1,6 +1,7 @@
"""Public publication evidence from the council export and employee database."""
import calendar
import hashlib
import json
import re
from collections import Counter, defaultdict
@@ -17,17 +18,24 @@ from sqlalchemy.orm import Session, selectinload
from app.models import Employee
from app.services.academic_degrees import academic_degrees
from app.services.council_source import _doi, _journal_names, _name, _quartiles, load_reference
from app.services.council_catalog import catalog, publication_catalog
from app.services.council_assessment import BRANCHES, assess_candidate, degree_level, evidence, iso_date, publication_proofs
EXPORT = Path(__file__).resolve().parents[1] / "data" / "council_articles.json"
K1_JOURNALS = EXPORT.with_name("k1_journals.json")
K1_SPECIALTIES = EXPORT.with_name("k1_specialties.json")
K2_JOURNALS = EXPORT.with_name("k2_journals.json")
DOCTOR = re.compile(r"^доктор\b.+\bнаук$", re.IGNORECASE)
TYPES = {"ARTICLE": "Статья", "BOOK": "Книга", "CHAPTER": "Глава книги",
"BOOK_CHAPTER": "Глава книги", "REPORT": "Доклад", "PATENT": "Патент",
"PREPRINT": "Препринт", "CONFERENCE_PAPER": "Материал конференции"}
class CouncilInputError(ValueError):
"""Invalid public assessment context, as opposed to damaged source data."""
def _safe_url(value: str | None) -> str | None:
try:
url = urlsplit(value or "")
@@ -63,7 +71,26 @@ def _k1_journals() -> dict[str, str]:
@lru_cache(maxsize=1)
def _k1_specialties() -> dict:
data = json.loads(K1_SPECIALTIES.read_text(encoding="utf-8"))
return {**data, "by_journal": {j["name"]: j["specialty_codes"] for j in data["journals"]}}
if hashlib.sha256(K1_JOURNALS.read_bytes().replace(b"\r\n", b"\n")).hexdigest() != data["k1_source_sha256"]:
raise ValueError("K1 specialty source checksum mismatch")
return {**data, "by_journal": {j["name"]: j["specialty_codes"] for j in data["journals"]},
"journal_issns": {j["name"]: [entry["issn"] for entry in j.get("vak_entries", []) if entry.get("issn")]
for j in data["journals"] if j.get("status") == "matched"}}
@lru_cache(maxsize=1)
def _k2_journals() -> dict:
data = json.loads(K2_JOURNALS.read_text(encoding="utf-8"))
if data["source_sha256"] != json.loads(K1_JOURNALS.read_text(encoding="utf-8"))["source_sha256"]:
raise ValueError("K2 source differs from K1 source")
result = {}
for journal in data["journals"]:
for name in [journal["name"], *journal.get("aliases", [])]:
key = _journal_key(name)
if key in result and result[key] != journal["name"]:
raise ValueError("Ambiguous K2 journal alias")
result[key] = journal["name"]
return result
def _description_journals(description: dict) -> list[str]:
@@ -100,7 +127,8 @@ def _database_article(publication) -> dict:
return {"title": publication.title, "year": publication.year, "doi": _doi(publication.doi_url or ""),
"types": [TYPES.get(kind.upper(), f"Другой тип: {kind}")] if kind else [],
"quartiles": _quartiles(imported) if isinstance(imported, dict) else [],
"journals": journals,
"journals": journals, "issns": [raw[key] for key in ("issn", "eissn") if isinstance(raw.get(key), str)],
"source_hashes": [publication.source_hash],
"origins": origins or ["БД miem-workers"], "rows": [], "in_csv": False, "in_db": True,
"url": _safe_url(publication.url)}
@@ -132,9 +160,11 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
if key not in unique:
unique[key] = {**article, "doi": identifier,
**{field: list(article[field]) for field in ("types", "quartiles", "journals", "origins", "rows")},
"evidence": []}
"evidence": [], "issns": [], "source_hashes": []}
merged = unique[key]
merged["evidence"].append(article)
for field in ("issns", "source_hashes"):
merged[field] = list(dict.fromkeys([*merged[field], *article.get(field, [])]))
for field in ("types", "quartiles", "journals", "origins", "rows"):
merged[field] = list(dict.fromkeys([*merged[field], *article[field]]))
merged["in_csv"] |= article["in_csv"]
@@ -145,6 +175,7 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
years = sorted({item["year"] for item in evidence if item["year"] is not None})
# Never pick a conflicting source year merely because that row came first.
year = article["year"] = years[0] if len(years) == 1 else None
article["source_years"] = years
article["criteria"] = list(article["quartiles"])
matches = list(dict.fromkeys(k1_journals[key] for name in article["journals"]
if (key := _journal_key(name)) in k1_journals))
@@ -164,6 +195,13 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
article["specialty_codes"] = sorted(set(article["specialty_codes"]).union(
*(specialties.get(name, []) for name in item_journals)))
article["qualifies"] = article["q1"] or article["q2"] or article["k1"]
article["k2"] = any(start.year <= (item["year"] or year or 0) <= end.year and eligible_type and
any(_journal_key(name) in _k2_journals() for name in item["journals"]) for item in evidence)
article["rsci"] = False
article["catalog_records"] = []
article["catalog_issues"] = []
if article["k2"]:
article["criteria"].append("К2 · текущий справочник ВАК; историческая категория требует проверки")
if len(years) > 1:
article["criteria"].append("Годы источников расходятся: " + ", ".join(map(str, years))
+ "; критерии учитываются по году каждой исходной записи")
@@ -184,15 +222,27 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0,
sort: str = "total_count", direction: str = "desc", paginate: bool = True, specialty: str = "") -> dict:
sort: str = "total_count", direction: str = "desc", paginate: bool = True, specialty: str = "",
petition_date: date | str | None = None, branch: str = "") -> dict:
if isinstance(petition_date, str):
try:
petition_date = iso_date(petition_date) if petition_date else None
except ValueError as exc:
raise CouncilInputError("Некорректная дата ходатайства; ожидается ГГГГ-ММ-ДД") from exc
if petition_date and petition_date.year <= 5:
raise CouncilInputError("Дата ходатайства не позволяет построить пятилетний период")
if branch and branch not in BRANCHES:
raise CouncilInputError("Неизвестная отрасль науки")
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
start, end = period(today)
start, end = period(petition_date or today)
specialties = _k1_specialties()["specialties"]
specialty = specialty if specialty in {s["code"] for s in specialties} else ""
if specialty and specialty not in {s["code"] for s in specialties}:
raise CouncilInputError("Неизвестная специальность ВАК")
specialty_name = next((s["name"] for s in specialties if s["code"] == specialty), "")
employees = db.scalars(select(Employee).options(selectinload(Employee.publications))).all()
name_counts = Counter(_name(employee.full_name or "") for employee in employees)
by_author = _articles_by_author()
verified = evidence()
candidates = []
for employee in employees:
author = _name(employee.full_name or "")
@@ -203,24 +253,42 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
degrees = academic_degrees(data)
except (AttributeError, TypeError):
degrees = []
if not any(DOCTOR.fullmatch(degree) for degree in degrees):
employee_evidence = verified["employees"].get(employee.profile_key, {})
if not any(degree_level(degree) for degree in degrees) and not employee_evidence.get("degrees"):
continue
articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end)
if not articles:
continue
for article in articles:
proofs = publication_proofs(employee_evidence, article)
identifiers = [*article["issns"], *(value for proof in proofs for value in proof.get("issns", []))]
for name in article["journals"]:
canonical = _k1_journals().get(_journal_key(name))
identifiers.extend(_k1_specialties()["journal_issns"].get(canonical, []))
article["catalog_records"], article["catalog_issues"] = publication_catalog(identifiers, article["journals"], specialty)
for record in article["catalog_records"]:
for metric in record["metrics"]:
if article["year"] == metric["year"] and start.year <= article["year"] <= end.year and metric["quartile"] in {"Q1", "Q2"} and not article["catalog_issues"] and (not article["types"] or article["types"] == ["Статья"]):
article[metric["quartile"].lower()] = True
for proof in proofs:
for basis in proof.get("bases", []):
if not isinstance(basis, dict) or basis.get("applicable_year") != article["year"] or article["year"] is None or not start.year <= article["year"] <= end.year:
continue
key = {"К1": "k1", "К2": "k2", "Q1": "q1", "Q2": "q2", "RSCI": "rsci"}.get(basis.get("basis"))
if key:
article[key] = True
assessment = assess_candidate(degrees, articles, employee_evidence, petition_date, branch, specialty, start, end)
specialty_count = sum(specialty in a["specialty_codes"] for a in articles) if specialty else 0
for article in articles:
article["matches_specialty"] = bool(specialty and specialty in article["specialty_codes"])
if article["matches_specialty"]:
article["criteria"].append(f"По специальности {specialty}: {specialty_name} (журнал К1, Перечень ВАК)")
candidates.append({"specialty_count": specialty_count,"name": employee.full_name or "ФИО не указано", "degree": "; ".join(degrees) or "Учёная степень не указана",
candidates.append({**assessment, "specialty_count": specialty_count,"name": employee.full_name or "ФИО не указано",
"count": len(articles),
**{f"{key}_count": sum(a[key] for a in articles) for key in ("q1", "q2", "k1")},
**{f"{key}_count": sum(a[key] for a in articles) for key in ("q1", "q2", "k1", "k2", "rsci")},
"total_count": sum(a["qualifies"] for a in articles),
"db_count": sum(a["in_db"] for a in articles), "articles": articles,
"profile_url": _safe_url(employee.canonical_url), "ambiguous": ambiguous,
"dismissed": employee.status == "dismissed"})
sort = sort if sort in {"full_name", "q1_count", "q2_count", "k1_count", "total_count", "db_count", "count"} else "total_count"
sort = sort if sort in {"full_name", "q1_count", "q2_count", "k1_count", "k2_count", "rsci_count", "confirmed_count", "total_count", "db_count", "count"} else "total_count"
direction = direction if direction in {"asc", "desc"} else "desc"
candidates.sort(key=lambda item: _name(item["name"]), reverse=sort == "full_name" and direction == "desc")
if sort != "full_name":
@@ -235,4 +303,9 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
return {"candidates": candidates[offset:offset + limit] if paginate else candidates,
"start": start, "end": end, "total": total,
"specialties": specialties, "specialty": specialty, "specialty_name": specialty_name,
"petition_date": petition_date, "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)
for data in [json.loads(path.read_text(encoding="utf-8"))]],
"matched": matched, "q": q, "offset": offset, "limit": limit, "sort": sort, "direction": direction}
+261
View File
@@ -0,0 +1,261 @@
"""Explicit, sourced council evidence kept outside crawler-owned records."""
import json
import re
from datetime import date
from functools import lru_cache
from pathlib import Path
from app.services.council_catalog import catalog, safe_url
from app.services.council_source import _doi, _name
EVIDENCE = Path(__file__).resolve().parents[1] / "data/council_evidence.json"
BRANCHES = {}
for names, minimum, monographs in [
("биологические|географические|геолого-минералогические|медицинские|фармацевтические|физико-математические|химические", 11, 0),
("архитектура|ветеринарные|сельскохозяйственные|технические", 9, 0),
("исторические|педагогические|политические|психологические|социологические|филологические|философские|экономические|теология", 8, 1),
("юридические", 8, 0), ("искусствоведение|культурология", 7, 0),
]:
for name in names.split("|"):
BRANCHES[name] = (minimum, monographs)
SYSTEMS = {"Web of Science", "Scopus", "PubMed", "MathSciNet", "zbMATH", "Chemical Abstracts", "Springer", "GeoRef"}
def iso_date(value):
if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
raise ValueError("Дата должна иметь формат ГГГГ-ММ-ДД")
return date.fromisoformat(value)
def sourced(record):
if not isinstance(record, dict) or not isinstance(record.get("source"), str) or not record["source"].strip():
return False
try:
iso_date(record.get("checked_on"))
except ValueError:
return False
return True
def degree_level(degree):
if re.fullmatch(r"доктор\b.+\bнаук", degree, re.I):
return 2
if re.fullmatch(r"кандидат\b.+\bнаук|ph\.?\s*d\.?", degree, re.I):
return 1
return 0
@lru_cache(maxsize=1)
def evidence():
data = json.loads(EVIDENCE.read_text(encoding="utf-8"))
if not isinstance(data, dict) or type(data.get("schema_version")) is not int or data["schema_version"] != 1 or not isinstance(data.get("employees"), dict):
raise ValueError("Invalid council evidence schema")
if not isinstance(data.get("policy", {}), dict):
raise ValueError("Invalid council policy")
admitted = data.get("policy", {}).get("publication_types", {})
if not isinstance(admitted, dict) or not isinstance(admitted.get("values", []), list) or not all(
isinstance(v, str) for v in admitted.get("values", [])
):
raise ValueError("Invalid admitted publication types")
for employee in data["employees"].values():
if not isinstance(employee, dict):
raise ValueError("Invalid employee evidence")
for field in ("degrees", "publications"):
if not isinstance(employee.get(field, []), list) or not all(isinstance(r, dict) for r in employee.get(field, [])):
raise ValueError("Invalid council evidence records")
for record in employee.get("publications", []):
for field in ("doi", "source_hash"):
if record.get(field) is not None and not isinstance(record[field], str):
raise ValueError("Invalid publication evidence identifier")
if not (isinstance(record.get("doi"), str) and record["doi"]) and not (
isinstance(record.get("source_hash"), str) and record["source_hash"]
):
raise ValueError("Publication evidence requires a stable identifier")
if record.get("doi") and record["doi"] != _doi(record["doi"]):
raise ValueError("Publication evidence DOI must be normalized")
if record.get("kind") is not None and not isinstance(record["kind"], str):
raise ValueError("Invalid publication kind")
if not isinstance(record.get("bases", []), list) or not all(isinstance(b, dict) for b in record.get("bases", [])):
raise ValueError("Invalid publication bases")
for basis in record.get("bases", []):
for field in ("basis", "system", "category", "metric"):
if basis.get(field) is not None and not isinstance(basis[field], str):
raise ValueError("Invalid publication basis field")
if not isinstance(record.get("issns", []), list) or not all(isinstance(v, str) for v in record.get("issns", [])):
raise ValueError("Invalid publication ISSN")
review = record.get("thematic_review")
if review is not None and (not isinstance(review, dict) or not isinstance(review.get("point_numbers", []), list)
or not all(type(v) is int for v in review.get("point_numbers", []))):
raise ValueError("Invalid thematic review")
if review is not None:
for field in ("specialty_code", "branch", "passport_id", "passport_sha256", "result"):
if review.get(field) is not None and not isinstance(review[field], str):
raise ValueError("Invalid thematic review field")
return data
def publication_proofs(employee_evidence, article):
matches = []
for record in employee_evidence.get("publications", []):
doi_matches = bool(article["doi"] and record.get("doi") == article["doi"])
hash_matches = bool(record.get("source_hash") and record["source_hash"] in article.get("source_hashes", []))
if record.get("doi") and record.get("source_hash"):
if doi_matches != hash_matches:
article["proof_identity_conflict"] = True
matched = doi_matches and hash_matches
else:
matched = doi_matches or hash_matches
if matched:
matches.append(record)
return matches
def assess_article(article, proofs, start, end, level, branch, specialty, policy):
reasons = list(article.get("catalog_issues", []))
years = article["source_years"]
dates = set()
date_conflict = False
for proof in proofs:
if proof.get("publication_date"):
try:
value = iso_date(proof["publication_date"])
if sourced(proof.get("date_evidence")):
dates.add(value)
else:
reasons.append("Источник точной даты не подтверждён")
except ValueError:
reasons.append("Некорректная точная дата")
date_conflict = True
if len(years) > 1 or len(dates) > 1 or (dates and years and next(iter(dates)).year not in years):
reasons.append("Даты публикации в источниках расходятся")
date_conflict = True
exact = next(iter(dates)) if len(dates) == 1 else None
in_period = start < exact < end if exact else len(years) == 1 and start.year < years[0] < end.year
if exact and exact in {start, end}:
boundary = policy.get("boundary_dates", {})
in_period = sourced(boundary) and boundary.get("value") == "inclusive"
if not in_period:
reasons.append("Включение граничной даты не подтверждено")
if not in_period:
reasons.append("Пятилетний период не подтверждён")
types = {proof["kind"] for proof in proofs if proof.get("kind") and sourced(proof.get("kind_evidence"))}
if len(types) != 1:
reasons.append("Вид материала не подтверждён или расходится")
kind = next(iter(types)) if len(types) == 1 else None
type_labels = {"article": "Статья", "monograph": "Книга", "book": "Книга", "chapter": "Глава книги",
"report": "Доклад", "patent": "Патент", "preprint": "Препринт", "conference_paper": "Материал конференции"}
type_conflict = len(types) > 1 or bool(article["types"] and kind and article["types"] != [type_labels.get(kind)])
if type_conflict:
reasons.append("Виды материала в источниках расходятся")
eligible_kind = kind == "article"
if kind and kind != "article":
admissible = policy.get("publication_types", {})
eligible_kind = sourced(admissible) and kind in admissible.get("values", [])
if not eligible_kind:
reasons.append("Зачёт этого вида материала в общий минимум не подтверждён")
bases = []
basis_year = exact.year if exact else article["year"]
for proof in proofs:
for basis in proof.get("bases", []):
if not isinstance(basis, dict):
reasons.append("Некорректное подтверждение основания")
continue
category = basis.get("basis")
valid = sourced(basis) and type(basis.get("applicable_year")) is int and basis["applicable_year"] == basis_year
if category in {"Q1", "Q2"}:
valid = valid and basis.get("system") in SYSTEMS and bool(basis.get("category")) and bool(basis.get("metric")) and type(basis.get("metric_year")) is int
elif category not in {"К1", "К2", "RSCI"}:
valid = False
if valid:
bases.append(basis)
else:
reasons.append("Источник или год применимости основания не подтверждены")
# Different categories may legitimately have different quartiles; conflicting
# claims for the same system/category/year must not be resolved by ordering.
claims = {}
basis_conflict = False
for basis in bases:
key = ("ВАК", basis_year) if basis["basis"] in {"К1", "К2"} else (
basis.get("system", basis["basis"]), basis.get("category"), basis.get("metric_year"), basis_year)
if key in claims and claims[key] != basis["basis"]:
reasons.append("Подтверждённые основания источников расходятся")
basis_conflict = True
claims[key] = basis["basis"]
accepted = [b for b in bases if level == 2 or b["basis"] != "К2"]
if not accepted:
reasons.append("Нет подтверждённого допустимого основания выбранного пункта")
# Missing optional evidence is not a veto on another sufficient independent
# basis. Identity/date/type conflicts, however, invalidate the whole work.
identity_conflict = any("расход" in reason or "идентичности" in reason or "разные журналы" in reason
for reason in article.get("catalog_issues", []))
if article.get("proof_identity_conflict"):
identity_conflict = True
reasons.append("Идентификаторы ручного подтверждения указывают на разные работы")
conflicts = date_conflict or type_conflict or basis_conflict or identity_conflict
article["confirmed"] = bool(in_period and eligible_kind and accepted and not conflicts)
mono_policy = policy.get("monograph_period", {})
article["monograph_confirmed"] = bool(kind == "monograph" and not conflicts and sourced(mono_policy)
and (mono_policy.get("value") == "all_years" or
(mono_policy.get("value") == "five_years" and in_period)))
article["confirmation_reasons"] = list(dict.fromkeys(reasons))
article["publication_date"] = exact
article["confirmed_bases"] = [{**b, "source_url": safe_url(b.get("source_url"))} for b in accepted]
article["thematic_status"] = "Рассмотрение статьи по паспорту не подтверждено" if specialty else "Специальность для рассмотрения статьи не выбрана"
reviews = [p.get("thematic_review") for p in proofs if p.get("thematic_review")]
valid_reviews = []
for review in reviews:
if not isinstance(review, dict):
continue
passport = catalog()["passports"].get(review.get("passport_id"), {})
passport_branch = passport.get("branch", "см. паспорт").removesuffix(" науки")
if (sourced(review) and review.get("specialty_code") == specialty and review.get("branch") == branch and
(passport_branch == "см. паспорт" or passport_branch == branch) and
passport.get("specialty_code") == specialty and review.get("passport_sha256") == passport.get("sha256") and
review.get("point_numbers") and set(review["point_numbers"]) <= {p["number"] for p in passport.get("directions", [])} and
review.get("materials") and isinstance(review.get("checked_conditions"), list) and review.get("result") in {"confirmed", "requires_check"}):
valid_reviews.append(review)
article["thematic_status"] = ("Статья рассмотрена: " + {"confirmed": "соответствие подтверждено", "requires_check": "требует проверки"}[review["result"]] + f"; паспорт {review['passport_id']}; "
f"пункты {review['point_numbers']}; {review['checked_on']}")
if len({r["result"] for r in valid_reviews}) > 1:
article["thematic_status"] = "Результаты рассмотрения статьи расходятся — требуется проверка"
def assess_candidate(degrees, articles, employee_evidence, petition_date, branch, specialty, start, end):
proofs = [r for r in employee_evidence.get("degrees", []) if isinstance(r.get("degree"), str) and sourced(r)]
history = list(dict.fromkeys([*degrees, *(r["degree"] for r in proofs)]))
level = max((degree_level(d) for d in history), default=0)
selected = sorted((d for d in history if degree_level(d) == level and level), key=_name)
degree_confirmed = bool(selected and any(degree_level(r["degree"]) == level for r in proofs))
minimum, monographs = BRANCHES.get(branch, (None, 0)) if level == 2 else (20, 0) if level == 1 else (None, 0)
policy = evidence().get("policy", {})
for article in articles:
assess_article(article, publication_proofs(employee_evidence, article), start, end, level, branch, specialty, policy)
if article["confirmed"]:
for basis in article["confirmed_bases"]:
article[{"К1": "k1", "К2": "k2", "RSCI": "rsci", "Q1": "q1", "Q2": "q2"}[basis["basis"]]] = True
article["qualifies"] = article["q1"] or article["q2"] or article["k1"] or article["rsci"] or (level == 2 and article["k2"])
confirmed = sum(a["confirmed"] for a in articles)
mono_count = sum(a["monograph_confirmed"] for a in articles)
reasons = []
if not level:
reasons.append("Учёная степень для пунктов 5–6 не установлена")
elif not degree_confirmed:
reasons.append("Высшая текущая степень документально не подтверждена")
if not petition_date:
reasons.append("Дата ходатайства не задана: показан предварительный период")
if not branch:
reasons.append("Отрасль предполагаемого включения не задана")
if not articles:
reasons.append("Доступных публикаций нет; полнота сведений не подтверждена")
if minimum is not None and confirmed < minimum:
reasons.append(f"Подтверждено {confirmed} из требуемых {minimum}; полнота перечня требует проверки")
if mono_count < monographs:
reasons.append("Обязательная монография не подтверждена")
clause = 5 if level == 2 else 6 if level == 1 else None
status = f"Соответствует публикационным критериям пункта {clause}" if clause and not reasons else "Требует проверки"
return {"degree": "; ".join(history) or "Учёная степень не указана", "selected_degree": "; ".join(selected),
"degree_confirmed": degree_confirmed, "degree_level": level, "clause": clause, "minimum": minimum,
"monograph_minimum": monographs, "monograph_count": mono_count, "confirmed_count": confirmed,
"assessment_status": status, "assessment_reasons": reasons}
+173
View File
@@ -0,0 +1,173 @@
"""Read the supplied thematic snapshots; never infer article approval from them."""
import hashlib
import json
import re
from collections import defaultdict
from functools import lru_cache
from pathlib import Path
from urllib.parse import urlsplit
DATA = Path(__file__).resolve().parents[1] / "data"
CATALOGS = (DATA / "q1-journals/catalog.json", DATA / "q2-journals/catalog.json")
def safe_url(value):
if not isinstance(value, str):
return None
try:
parsed = urlsplit(value)
if parsed.scheme in {"http", "https"} and parsed.hostname and not parsed.username and not parsed.password:
return value
except ValueError:
pass
return None
def issn(value):
return re.sub(r"[^0-9X]", "", str(value or "").upper())
def citescore_quartile(percentile):
if type(percentile) not in {int, float} or not 0 <= percentile <= 100:
raise ValueError("Invalid CiteScore percentile")
return "Q1" if percentile >= 75 else "Q2" if percentile >= 50 else "Q3" if percentile >= 25 else "Q4"
def _unique(records, key):
result = {}
for record in records:
identifier = key(record)
if identifier in result:
raise ValueError("Duplicate catalog identifier")
result[identifier] = record
return result
@lru_cache(maxsize=1)
def catalog():
journals = defaultdict(list)
passports = {}
metadata = []
issues = []
for path in CATALOGS:
try:
data = json.loads(path.read_text(encoding="utf-8"))
meta = data["metadata"]
if meta["schema_version"] not in {"1.0.0", "1.1.0"}:
raise ValueError("Unsupported catalog schema")
rule = meta.get("selection_rule") or meta.get("q1_rule") or meta["q2_rule"]
sources = _unique(data["sources"], lambda r: r["id"])
specialties = _unique(data["specialties"], lambda r: r["code"])
local_passports = {}
for specialty in specialties.values():
for passport in specialty["passports"]:
pdf = (path.parent / passport["local_pdf"]).resolve()
if not pdf.is_relative_to(DATA.resolve()):
raise ValueError("Passport path outside data directory")
if hashlib.sha256(pdf.read_bytes()).hexdigest() != passport["sha256"]:
raise ValueError("Passport checksum mismatch")
entry = {**passport, "specialty_code": specialty["code"]}
if passport["id"] in local_passports:
raise ValueError("Duplicate passport")
if passport["id"] in passports and passports[passport["id"]] != entry:
raise ValueError("Conflicting passport versions")
local_passports[passport["id"]] = entry
local_journals = _unique(data["journals"], lambda r: r["id"])
links = _unique(data["specialty_links"], lambda r: (r["journal_id"], r["specialty_code"]))
assessments = _unique(data["assessments"], lambda r: (r["journal_id"], r["specialty_code"]))
for (journal_id, code), assessment in assessments.items():
if journal_id not in local_journals or code not in specialties:
raise ValueError("Unresolved assessment")
if assessment["assessment"] not in {"direct", "conditional", "not_established"}:
raise ValueError("Invalid assessment level")
for (journal_id, code), link in links.items():
if journal_id not in local_journals or code not in specialties:
raise ValueError("Unresolved specialty link")
if link["match_level"] not in {"direct", "conditional"}:
raise ValueError("Invalid specialty level")
for field in ("rationales", "conditions"):
if not isinstance(link[field], list) or not all(isinstance(v, str) for v in link[field]):
raise ValueError("Invalid specialty explanations")
if assessments.get((journal_id, code), {}).get("assessment") != link["match_level"]:
raise ValueError("Conflicting assessment")
for reference in link["passport_references"]:
passport = local_passports[reference["passport_id"]]
if passport["specialty_code"] != code or not set(reference["point_numbers"]) <= {
point["number"] for point in passport["directions"]
}:
raise ValueError("Invalid passport points")
prepared = []
for journal in local_journals.values():
if journal["id"] != f"scopus:{journal['scopus_source_id']}":
raise ValueError("Conflicting Scopus journal identifier")
metrics = []
for system, field in (("Scopus", "scopus"), ("Web of Science", "wos")):
metric = journal[field]
source = sources[metric["source_id"]]
year = metric["year"] if field == "scopus" else metric["metric_year"]
if source["metric_year"] != year:
raise ValueError("Conflicting metric year")
for category in metric["categories"]:
quartile = citescore_quartile(category["percentile"]) if field == "scopus" else category["quartile"]
if quartile not in {"Q1", "Q2", "Q3", "Q4"} or category.get("quartile", quartile) != quartile:
raise ValueError("Conflicting quartile")
metrics.append({"system": system, "metric": metric["metric"], "year": year,
"release_year": metric.get("release_year"), "category": category["category"],
"edition": category.get("edition"), "quartile": quartile,
"source": source["title"], "source_url": safe_url(source.get("url")),
"source_row": category.get("source_row"), "source_pdf_page": category.get("source_pdf_page")})
prepared.append({**journal, "metrics": metrics, "catalog": path.parent.name,
"links": {code: link for (jid, code), link in links.items() if jid == journal["id"]},
"assessments": {code: value["assessment"] for (jid, code), value in assessments.items() if jid == journal["id"]}})
# Commit only a fully validated snapshot. Broken data cannot supply evidence.
passports.update(local_passports)
for journal in prepared:
journals[journal["id"]].append(journal)
metadata.append({"catalog": path.parent.name, "schema_version": meta["schema_version"],
"rule": rule, "metric_years": meta["metric_years"], "generated_on": meta["generated_on"]})
except (OSError, ValueError, KeyError, TypeError, AttributeError, IndexError):
issues.append(f"{path.parent.name}: целостность каталога не подтверждена")
by_issn = defaultdict(set)
for identifier, records in journals.items():
for record in records:
for value in (record.get("issn"), record.get("eissn")):
if value:
by_issn[issn(value)].add(identifier)
return {"journals": dict(journals), "by_issn": dict(by_issn), "passports": passports,
"metadata": metadata, "issues": issues}
def publication_catalog(identifiers, names, specialty=""):
data = catalog()
matches = set().union(*(data["by_issn"].get(issn(value), set()) for value in identifiers))
issues = list(data["issues"])
if len(matches) > 1:
return [], [*issues, "ISSN указывают на разные журналы — требуется проверка"]
records = data["journals"].get(next(iter(matches)), []) if matches else []
if records and names and not any(str(name).casefold().strip() == r["name"].casefold() for name in names for r in records):
issues.append("Название журнала и ISSN требуют подтверждения идентичности")
output = []
for record in records:
link = record["links"].get(specialty)
thematic = []
labels = {"direct": "прямая", "conditional": "условная", "not_established": "не установлена"}
if link:
thematic.extend([f"Аналитическая связь журнала: {labels[link['match_level']]}", *link["rationales"], *link["conditions"]])
for ref in link["passport_references"]:
passport = data["passports"][ref["passport_id"]]
thematic.append(f"Паспорт {ref['passport_id']} ({passport['branch']}), SHA-256 {passport['sha256']}; пункты: "
+ ", ".join(map(str, ref["point_numbers"])))
elif specialty:
thematic.append("Связь журнала: " + labels.get(record["assessments"].get(specialty), "не оценена"))
output.append({"id": record["id"], "name": record["name"], "issn": record.get("issn"), "eissn": record.get("eissn"),
"catalog": record["catalog"], "metrics": record["metrics"], "thematic": thematic,
"match_level": link["match_level"] if link else None,
"passport_urls": [safe_url(data["passports"][r["passport_id"]]["source_url"]) for r in link["passport_references"]] if link else []})
# A conflict is preserved; it never resolves by load order.
if len(records) > 1:
for field in ("name", "issn", "eissn", "metrics", "links", "assessments"):
if any(record[field] != records[0][field] for record in records[1:]):
issues.append("Сведения каталогов расходятся: " + field)
return output, issues
+39 -9
View File
@@ -6,6 +6,7 @@ from openpyxl import Workbook
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
from app.services.council_assessment import BRANCHES
def _append(sheet, values):
@@ -27,9 +28,13 @@ def council_xlsx(result: dict) -> bytes:
return "\n".join(label for code, label in specialty_labels.items() if code in codes) or "Не подтверждены"
_append(employees, ["№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "Страница ВШЭ", "Все публикации",
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности"])
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности",
"Текущая степень", "Степень подтверждена", "Пункт", "Отрасль оценки", "Дата ходатайства", "Минимум",
"Подтверждено", "К2 предварительно", "RSCI предварительно", "Монографии подтверждены", "Минимум монографий", "Итог", "Причины проверки"])
_append(publications, ["№ сотрудника", "ФИО", "Название публикации", "Год", "Тип материала", "Источники",
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "Страница ВШЭ", "DOI", "Ссылка на сайте", "Строки CSV", "Специальности ВАК", "По выбранной специальности"])
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "Страница ВШЭ", "DOI", "Ссылка на сайте", "Строки CSV", "Специальности ВАК", "По выбранной специальности",
"К2", "RSCI", "Подтверждённый зачёт", "Дата публикации", "Причины проверки", "Подтверждённые основания",
"Журнал и идентификаторы", "Квартильные категории и источники", "Аналитическая связь журнала", "Рассмотрение статьи"])
for number, candidate in enumerate(result["candidates"], 1):
note = "Совпадение ФИО: публикации CSV не присвоены автоматически" if candidate["ambiguous"] else ""
specialty_codes = {code for article in candidate["articles"] for code in article["specialty_codes"]}
@@ -37,23 +42,38 @@ def council_xlsx(result: dict) -> bytes:
candidate["k1_count"], candidate["total_count"],
candidate["db_count"], candidate["count"],
candidate["profile_url"], note, specialties_text(specialty_codes),
candidate["specialty_count"] if result["specialty"] else None])
candidate["specialty_count"] if result["specialty"] else None,
candidate["selected_degree"], "Да" if candidate["degree_confirmed"] else "Нет", candidate["clause"],
result["branch"], result["petition_date"], candidate["minimum"], candidate["confirmed_count"],
candidate["k2_count"], candidate["rsci_count"], candidate["monograph_count"], candidate["monograph_minimum"],
candidate["assessment_status"], "; ".join(candidate["assessment_reasons"])])
for article in candidate["articles"]:
_append(publications, [number, candidate["name"], article["title"], article["year"], article["type_label"],
"; ".join(article["origins"]), "; ".join(article["criteria"]),
*["Да" if article[key] else "Нет" for key in ("q1", "q2", "k1", "qualifies", "in_db")],
article["doi_url"], article["url"], ", ".join(map(str, article["rows"])), specialties_text(article["specialty_codes"]),
("Да" if article["matches_specialty"] else "Нет") if result["specialty"] else None])
("Да" if article["matches_specialty"] else "Нет") if result["specialty"] else None,
*["Да" if article[key] else "Нет" for key in ("k2", "rsci", "confirmed")], article["publication_date"],
"; ".join(article["confirmation_reasons"]),
"; ".join(f"{b['basis']} · {b.get('system', '')} · {b.get('category', '')} · {b.get('metric', '')} · год метрики {b.get('metric_year', '')} · применимо к {b['applicable_year']} · {b['source']} · {b.get('source_url') or ''}" for b in article["confirmed_bases"]),
"; ".join(f"{r['name']} · {r['id']} · ISSN {r['issn']} / {r['eissn']} · {r['catalog']}" for r in article["catalog_records"]),
"; ".join(f"{m['system']} · {m['metric']} · {m['category']} · {m['edition'] or ''} · {m['quartile']} · год метрики {m['year']} · выпуск {m['release_year'] or ''} · {m['source']} · {m['source_url'] or ''} · строка {m['source_row'] or ''} · страница {m['source_pdf_page'] or ''}" for r in article["catalog_records"] for m in r["metrics"]),
"; ".join(t for r in article["catalog_records"] for t in r["thematic"]), article["thematic_status"]])
_append(parameters, ["Параметр", "Значение"])
for values in [
["Дата ходатайства", result["petition_date"] or "Не задана — предварительный период"],
["Отрасль предполагаемого включения", result["branch"] or "Не выбрана"],
["Статус счётчиков", "Q1/Q2/К1/К2/RSCI и ВСЕГО предварительные; подтверждённый зачёт показан отдельно. К2 самостоятельно допустим только для пункта 5."],
["Предел заключения", "Только публикационные критерии; не автоматическое включение в совет. Отсутствие доказательств не означает отсутствие публикаций."],
["Границы и виды", "Спорные граничные даты, допустимость иных видов и период монографий требуют подтверждения по источнику. Без него соответствующие работы не подтверждают минимум."],
["Специальность ВАК", f"{result['specialty']}. — {result['specialty_name']}" if result["specialty"] else "Все специальности"],
["Подбор по специальности", "Хотя бы одна публикация за пять лет в релевантном журнале К1 по Перечню ВАК на 15.09.2026; содержание статьи и исторические специальности не проверяются."],
["Начало периода", result["start"]], ["Конец периода", result["end"]], ["Поиск по ФИО", result["q"]],
["Сортировать по", {"full_name": "ФИО", "q1_count": "Q1", "q2_count": "Q2", "k1_count": "К1",
["Сортировать по", {"full_name": "ФИО", "q1_count": "Q1", "q2_count": "Q2", "k1_count": "К1", "k2_count": "К2", "rsci_count": "RSCI", "confirmed_count": "Подтверждено",
"total_count": "ВСЕГО", "db_count": "Страница ВШЭ", "count": "Все публикации"}[result["sort"]]],
["Направление", "По возрастанию" if result["direction"] == "asc" else "По убыванию"],
["Сотрудников в выгрузке", result["matched"]], ["Всего сотрудников в списке", result["total"]],
["Отбор по степени", "Доктор наук в профиле; достаточно хотя бы одной публикации из CSV или БД"],
["Отбор по степени", "Высшая степень из истории: доктор выше кандидата/PhD; отсутствие подтверждения доктора не переводит оценку на пункт 6. Отсутствие публикаций не исключает сотрудника."],
["Счётчики Q1/Q2/К1", "Уникальные публикации в пятилетнем периоде по году. Q1/Q2 — отметки Scopus или Web of Science. "
"Для пограничных лет нужно проверить дату, для неизвестного типа — подтвердить, что это статья. "
"Материалы с известным другим типом исключены. К1 — совпадение названия журнала с итоговой категорией "
@@ -61,13 +81,23 @@ def council_xlsx(result: dict) -> bytes:
"Без названия журнала К1 не подтверждён."],
["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — специальности релевантного журнала К1, учитываемые за пять лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
["Работ / По выбранной специальности", "При выбранном фильтре показаны число соответствующих публикаций сотрудника и отметка Да/Нет для каждой работы. Без фильтра ячейки пустые; список специальностей остаётся заполненным."],
["ВСЕГО", "Уникальные публикации, входящие в Q1 ИЛИ Q2 ИЛИ К1; пересечения учитываются один раз."],
["ВСЕГО", "Предварительные уникальные работы Q1/Q2/К1/RSCI либо К2 для доктора; пересечения учитываются один раз."],
["Страница ВШЭ / Все публикации", "Все годы и категории; общий перечень объединяет CSV и БД без дублей. "
"Страница ВШЭ учитывает сохранённые публикации, включая импортированные из CSV; источник указан на листе Публикации."],
]:
_append(parameters, values)
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 12, 20, 22, 45, 55, 80, 28]),
(publications, [16, 34, 65, 12, 40, 45, 65, 10, 10, 10, 12, 20, 45, 45, 22, 80, 28]),
for branch, (minimum, monographs) in BRANCHES.items():
_append(parameters, [f"Пункт 5 · {branch}", f"Публикаций: {minimum}; обязательных монографий: {monographs}"])
_append(parameters, ["Пункт 6 · кандидат/PhD", "20 публикаций; К1/RSCI/Q1/Q2; одного К2 недостаточно"])
_append(parameters, ["Квартильные каталоги", "Ограниченные подборки, метрики 2025. Текущая индексация не проверена; это не означает отсутствие индексации. Тематика журнала не подтверждает статью."])
for meta in result["catalog_metadata"]:
_append(parameters, [meta["catalog"], f"Схема {meta['schema_version']}; сформирован {meta['generated_on']}; {meta['metric_years']}; {meta['rule']}"])
for issue in result["catalog_issues"]:
_append(parameters, ["Проверка каталога", issue])
for meta in result["category_metadata"]:
_append(parameters, [meta["file"], f"Источник {meta['source']}; SHA-256 {meta['source_sha256']}; проверено строк {meta['source_rows']}"])
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 12, 20, 22, 45, 55, 80, 28, 40, 20, 10, 28, 18, 12, 16, 16, 16, 20, 20, 50, 70]),
(publications, [16, 34, 65, 12, 40, 45, 65, 10, 10, 10, 12, 20, 45, 45, 22, 80, 28, 10, 10, 20, 18, 70, 75, 60, 90, 75, 60]),
(parameters, [32, 95])]:
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = sheet.dimensions
+40 -13
View File
@@ -5,26 +5,38 @@
<header class="council__hero">
<div class="council__hero-copy">
<h2 class="council__title" id="council-title">Диссертационный совет</h2>
<p class="council__intro">Публикации сотрудников для проверки состава совета. В списке сотрудники с докторской степенью в профиле и хотя бы одной публикацией в файле CSV или базе miem-workers. Минимальный порог количества работ не применяется.</p>
<p class="council__intro">Проверка публикационных критериев пунктов 5–6 для докторов, кандидатов наук и PhD. Показаны обнаруженные степени, предварительные сведения и отдельно подтверждённые работы. Соответствие этим критериям не означает автоматического включения в совет.</p>
</div>
</header>
{% if error %}
<div class="council__message" role="alert">
<h3>{% if export_error %}Не удалось создать XLSX{% else %}Не удалось загрузить публикации{% endif %}</h3>
<h3>{% if input_error %}Проверьте параметры оценки{% elif export_error %}Не удалось создать XLSX{% else %}Не удалось загрузить публикации{% endif %}</h3>
{% if input_error %}<p>{{ input_error }}</p>{% endif %}
<p>{% if export_error %}Не удалось выгрузить данные. Вернитесь к списку и попробуйте скачать файл позже.{% else %}Данные временно недоступны. Попробуйте обновить страницу позже.{% endif %}</p>
<a class="admin__link" href="/admin/council">Повторить загрузку</a>
</div>
{% else %}
{% if catalog_issues %}<div class="council__message" role="alert"><p>{{ catalog_issues|join('; ') }}. Повреждённые сведения не используются для зачёта.</p></div>{% endif %}
<details class="council__notice">
<summary><strong>Как читать счётчики и критерии</strong></summary>
<p>Q1 и Q2 — уникальные публикации с соответствующей отметкой Scopus или Web of Science. К1 — публикации в журналах, название которых совпало со справочником из «ВАК категории.pdf». ВСЕГО — количество уникальных публикаций, входящих хотя бы в одну из категорий Q1, Q2 или К1. Работа в нескольких категориях учитывается в итоге один раз.</p>
<p>Все четыре счётчика используют период {{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }} по году публикации. Для пограничных лет нужно проверить точную дату, для неизвестного типа — подтвердить, что это статья. Материалы с известным другим типом исключены. К1 определяется по итоговой категории журнала в предоставленном справочнике, а не по его исторической категории на дату публикации. Если название журнала отсутствует или не совпало, К1 не подтверждён. Отсутствие отметки означает недостаток данных.</p>
<p>Предварительные Q1/Q2 включают отметки CSV, применимые снимки каталогов и отдельные подтверждения. Предварительные К1/К2 включают совпадения журнала со справочником ВАК. ВСЕГО — объединение Q1/Q2/К1/RSCI и К2 для доктора наук. Работа учитывается один раз. Подтверждённое число требует самостоятельных доказательств применимости основания к этой работе.</p>
<p>Период {{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }}{% if not petition_date %} предварительный, относительно текущей даты{% endif %}. Подтверждённый итог требует документального подтверждения высшей степени, даты ходатайства, отрасли и работ с проверенными типом, периодом и допустимым основанием. Пограничные даты и годы, неизвестные типы и исторические категории требуют проверки. Предварительные числа сами по себе не подтверждают соответствие порогу. К2 допустим самостоятельно только для доктора наук; РИНЦ не заменяет RSCI.</p>
<p>Q1/Q2-каталоги — ограниченные тематические подборки со снимком метрик 2025 года. Они не подтверждают другие годы и текущую индексацию. Данные основаны на открытых копиях таблиц Scopus/JCR. Аналитическая связь журнала со специальностью не подтверждает содержание конкретной статьи. Порог и тематическое рассмотрение показываются отдельно.</p>
<p>«В БД» и «Все публикации» показывают количество работ за все годы, независимо от категорий. Общий перечень объединяет CSV и БД без дублей. Наличие публикаций не подтверждает соответствие всем требованиям совета.</p>
<p>При выборе специальности показаны сотрудники с хотя бы одной учитываемой за пять лет публикацией в журнале К1, которому эта специальность присвоена в «ВАК Перечень.pdf» на 15.09.2026. Это соответствие по журналу; содержание статьи и исторический перечень специальностей на дату публикации не проверяются.</p>
</details>
<form class="council__search" method="get" action="/admin/council" role="search">
<label class="directory__field" for="council-date">Дата ходатайства
<input class="directory__input" id="council-date" name="petition_date" type="date" value="{{ petition_date.isoformat() if petition_date else '' }}">
</label>
<label class="directory__field" for="council-branch">Отрасль предполагаемого включения
<select class="directory__input" id="council-branch" name="branch">
<option value="">Не выбрана — требуется проверка</option>
{% for value in branches %}<option value="{{ value }}"{% if branch == value %} selected{% endif %}>{{ value|capitalize }}</option>{% endfor %}
</select>
</label>
<label class="directory__field" for="council-specialty">Специальность ВАК
<select class="directory__input" id="council-specialty" name="specialty">
<option value="">Все специальности</option>
@@ -38,7 +50,7 @@
</label>
<label class="directory__field">Сортировать по
<select class="directory__input" name="sort">
{% for value, label in [('full_name', 'ФИО'), ('q1_count', 'Q1'), ('q2_count', 'Q2'), ('k1_count', 'К1'), ('total_count', 'ВСЕГО'), ('db_count', 'Публикации в БД'), ('count', 'Все публикации')] %}
{% for value, label in [('full_name', 'ФИО'), ('confirmed_count', 'Подтверждено'), ('q1_count', 'Q1'), ('q2_count', 'Q2'), ('k1_count', 'К1'), ('k2_count', 'К2'), ('rsci_count', 'RSCI'), ('total_count', 'ВСЕГО'), ('db_count', 'Публикации в БД'), ('count', 'Все публикации')] %}
<option value="{{ value }}"{% if sort == value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
@@ -51,7 +63,7 @@
</label>
<button class="button" type="submit">Применить</button>
<button class="button button--secondary" type="submit" formaction="/admin/council/export.xlsx">Скачать XLSX</button>
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
{% if q %}<a class="admin__link" href="?petition_date={{ petition_date.isoformat() if petition_date else '' }}&amp;branch={{ branch|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
</form>
<div class="council__list-heading">
<h3>Сотрудники</h3>
@@ -64,18 +76,22 @@
{% for candidate in candidates %}
<details class="council__candidate">
<summary class="council__candidate-summary">
<span class="council__candidate-identity"><strong>{{ candidate.name }}</strong><small>{{ candidate.degree }}{% if candidate.dismissed %} · Уволен{% endif %}</small></span>
<span class="council__candidate-identity"><strong>{{ candidate.name }}</strong><small>{{ candidate.degree }}{% if candidate.dismissed %} · Уволен{% endif %}</small><small>{{ candidate.assessment_status }} · подтверждено {{ candidate.confirmed_count }}{% if candidate.minimum is not none %} из {{ candidate.minimum }}{% endif %}</small></span>
<span class="council__counts">
<span class="council__candidate-count"><strong>{{ candidate.q1_count }}</strong><small>Q1</small></span>
<span class="council__candidate-count"><strong>{{ candidate.q2_count }}</strong><small>Q2</small></span>
<span class="council__candidate-count"><strong>{{ candidate.k1_count }}</strong><small>К1</small></span>
<span class="council__candidate-count"><strong>{{ candidate.total_count }}</strong><small>ВСЕГО</small></span>
<span class="council__candidate-count"><strong>{{ candidate.total_count }}</strong><small>ВСЕГО (предв.)</small></span>
<span class="council__candidate-count"><strong>{{ candidate.db_count }}</strong><small>В БД</small></span>
<span class="council__candidate-count"><strong>{{ candidate.count }}</strong><small>Все публикации</small></span>
</span>
<span class="council__candidate-cue" aria-hidden="true"></span>
</summary>
<div class="council__candidate-body">
<p><strong>{{ candidate.assessment_status }}</strong>. Текущая степень: {{ candidate.selected_degree or 'не установлена' }}; документальное подтверждение: {{ 'есть' if candidate.degree_confirmed else 'нет' }}.</p>
<p>Пункт: {{ candidate.clause or 'не определён' }} · Минимум: {{ candidate.minimum if candidate.minimum is not none else 'нужна отрасль' }} · Подтверждено: {{ candidate.confirmed_count }} · Предварительно: {{ candidate.total_count }} · К2: {{ candidate.k2_count }} · RSCI: {{ candidate.rsci_count }} · Монографии: {{ candidate.monograph_count }}{% if candidate.monograph_minimum %} / {{ candidate.monograph_minimum }} обязательно{% else %} (справочно){% endif %}</p>
{% if candidate.assessment_reasons %}<ul class="council__evidence" aria-label="Причины проверки">{% for reason in candidate.assessment_reasons %}<li>{{ reason }}</li>{% endfor %}</ul>{% endif %}
{% if not candidate.articles %}<p class="council__hint">Доступных публикаций нет. Это не доказывает отсутствие работ сотрудника.</p>{% endif %}
{% if specialty %}<p class="council__hint">Публикаций по выбранной специальности: {{ candidate.specialty_count }}</p>{% endif %}
{% if candidate.profile_url %}<a class="admin__link" href="{{ candidate.profile_url }}" target="_blank" rel="noopener noreferrer">Профиль на сайте ВШЭ</a>{% endif %}
{% if candidate.ambiguous %}<p class="council__hint">В базе есть сотрудники с одинаковым ФИО. Публикации из файла не присвоены автоматически; показаны записи, связанные с этим сотрудником в БД.</p>{% endif %}
@@ -94,7 +110,18 @@
{% if article.rows %}<span>Строки CSV: {{ article.rows|join(', ') }}</span>{% endif %}
</div>
<ul class="council__evidence" aria-label="Критерии и подтверждения">
<li>Зачёт в подтверждённое число: {{ 'да' if article.confirmed else 'требует проверки' }}{% if article.publication_date %}; точная дата: {{ article.publication_date.strftime('%d.%m.%Y') }}{% endif %}</li>
<li>{{ article.thematic_status }}</li>
{% for criterion in article.criteria %}<li>{{ criterion }}</li>{% endfor %}
{% for reason in article.confirmation_reasons %}<li>{{ reason }}</li>{% endfor %}
{% for basis in article.confirmed_bases %}<li>{{ basis.basis }} · {{ basis.system or 'издание' }} · {{ basis.category or '' }} · {{ basis.metric or '' }} · применимо к {{ basis.applicable_year }} · {{ basis.source }}{% if basis.source_url %} <a class="admin__link" href="{{ basis.source_url }}" target="_blank" rel="noopener noreferrer">Источник</a>{% endif %}</li>{% endfor %}
{% for record in article.catalog_records %}
<li>{{ record.name }} · {{ record.id }} · ISSN {{ record.issn or '—' }} / {{ record.eissn or '—' }} · {{ record.catalog }}
<ul>{% for metric in record.metrics %}<li>{{ metric.system }} · {{ metric.metric }} · {{ metric.category }}{% if metric.edition %} ({{ metric.edition }}){% endif %} · {{ metric.quartile }} · год метрики {{ metric.year }}{% if metric.release_year %}, выпуск {{ metric.release_year }}{% endif %} · {{ metric.source }}{% if metric.source_row %}, строка {{ metric.source_row }}{% endif %}{% if metric.source_pdf_page %}, страница {{ metric.source_pdf_page }}{% endif %}{% if metric.source_url %} <a class="admin__link" href="{{ metric.source_url }}" target="_blank" rel="noopener noreferrer">Источник</a>{% endif %}</li>{% endfor %}
{% for text in record.thematic %}<li>{{ text }}</li>{% endfor %}
{% for url in record.passport_urls %}{% if url %}<li><a class="admin__link" href="{{ url }}" target="_blank" rel="noopener noreferrer">Паспорт специальности</a></li>{% endif %}{% endfor %}</ul>
</li>
{% endfor %}
</ul>
</li>
{% endfor %}
@@ -105,16 +132,16 @@
</div>
{% if matched > limit %}
<nav class="directory__pagination" aria-label="Страницы сотрудников">
{% if offset > 0 %}<a class="admin__link" href="?q={{ q|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset - limit }}">Назад</a>{% endif %}
{% if offset > 0 %}<a class="admin__link" href="?petition_date={{ petition_date.isoformat() if petition_date else '' }}&amp;branch={{ branch|urlencode }}&amp;q={{ q|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset - limit }}">Назад</a>{% endif %}
<span class="directory__page">{{ offset // limit + 1 }} / {{ (matched + limit - 1) // limit }}</span>
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset + limit }}">Далее</a>{% endif %}
{% if offset + limit < matched %}<a class="admin__link" href="?petition_date={{ petition_date.isoformat() if petition_date else '' }}&amp;branch={{ branch|urlencode }}&amp;q={{ q|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset + limit }}">Далее</a>{% endif %}
</nav>
{% endif %}
{% else %}
<div class="council__message">
<h4>{% if q or specialty %}Сотрудники не найдены{% else %}Сотрудники с докторской степенью и публикациями пока не найдены{% endif %}</h4>
<p>{% if q or specialty %}Попробуйте другую фамилию или специальность. Без названия журнала, года или подтверждённого соответствия журналу К1 публикация не подтверждает специальность.{% else %}Для включения в список нужны докторская степень в профиле и хотя бы одна публикация, однозначно связанная с сотрудником.{% endif %}</p>
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
<h4>{% if q or specialty %}Сотрудники не найдены{% else %}Сотрудники с учёной степенью пока не найдены{% endif %}</h4>
<p>{% if q or specialty %}Попробуйте другую фамилию или специальность. Вспомогательный подбор по журналам К1 может скрывать сотрудников с другими основаниями и не является условием соответствия пунктам 5–6.{% else %}Для списка требуется обнаруженная либо документально подтверждённая степень доктора, кандидата наук или PhD.{% endif %}</p>
{% if q %}<a class="admin__link" href="?petition_date={{ petition_date.isoformat() if petition_date else '' }}&amp;branch={{ branch|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
</div>
{% endif %}
{% endif %}
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "0.15.4"
FRONTEND_VERSION = "0.15.4"
BACKEND_VERSION = "0.15.4"
APP_VERSION = "0.16.0"
FRONTEND_VERSION = "0.16.0"
BACKEND_VERSION = "0.16.0"
+78
View File
@@ -0,0 +1,78 @@
# Подтверждения публикационных критериев совета
Страница и XLSX читают один результат сервиса. Правила и ограничения исходного материала описаны в `app/data/COUNCIL_CRITERIA.md`, каталогов — в `app/data/Q1_Q2_CATALOG_GUIDE.md`. Порог зависит от **отрасли предполагаемого включения**, а не от отрасли степени или автоматически выбранной специальности.
По умолчанию `app/data/council_evidence.json` содержит пустой набор подтверждений. Это намеренно: профиль, CSV и текущие списки журналов не заменяют документы. Без достаточных подтверждений вывод — «Требует проверки». Источники считаются неполными; отрицательное заключение из малого количества найденных работ не формируется.
## Хранение и привязка
Файл предназначен для проверенных администратором записей и не обслуживается как публичный файл. Он отделён от `current_data` и `raw_data`, которые перезаписывает парсер; миграция БД не требуется. Перед заменой заполненного файла сохраните его резервную копию. Не помещайте в репозиторий сканы дипломов, контакты, секреты и текст непубличных документов. В `source` храните короткую ссылку на запись проверки или публичный источник. Подтверждения степени целиком и их внутренние ссылки не выводятся публично; основания публикаций и ссылки `source_url` видны на странице и в XLSX.
Ключ сотрудника — точный `employees.profile_key`, не ФИО. Публикация привязывается внутри сотрудника по нормализованному DOI (`10.…`, без URL, в нижнем регистре) либо точному `employee_publications.source_hash`. Если записаны оба идентификатора, они должны обозначать одну работу. После изменения исходных записей проверяйте актуальность привязки; ФИО и нечёткое название не используются для ручных подтверждений. Несколько записей сохраняются как независимые доказательства; существенные конфликты дат, видов, категорий и идентичности требуют проверки.
Минимальный пример структуры (иллюстративные значения, не подтверждение реального сотрудника):
```json
{
"schema_version": 1,
"policy": {},
"employees": {
"staff:123": {
"degrees": [
{"degree": "Доктор технических наук", "source": "Запись проверки диплома", "checked_on": "2026-10-08"}
],
"publications": [
{
"doi": "10.1234/example",
"issns": ["0006-3835"],
"kind": "article",
"kind_evidence": {"source": "Карточка издателя", "checked_on": "2026-10-08"},
"publication_date": "2024-06-15",
"date_evidence": {"source": "Выходные данные статьи", "checked_on": "2026-10-08"},
"bases": [
{
"basis": "Q2",
"system": "Scopus",
"metric": "CiteScore",
"metric_year": 2024,
"category": "Applied Mathematics",
"applicable_year": 2024,
"source": "Проверенная таблица и идентичность журнала",
"source_url": "https://example.org/evidence",
"checked_on": "2026-10-08"
}
]
}
]
}
}
}
```
Для подтверждений обязательны непустой `source` и дата `checked_on` в формате `ГГГГ-ММ-ДД`. `kind` — `article`, `monograph` либо иной явно проверенный вид. `BOOK` или глава сами по себе монографию не подтверждают. Точную дату можно опустить: единственный согласованный внутренний год пятилетнего периода подтверждает период; оба пограничных года требуют точной даты. Некорректная дата и расхождения источников не разрешаются порядком записей. Ссылка `source_url` необязательна, разрешены только безопасные HTTP(S)-адреса без встроенных credentials.
`bases[].basis` — `К1`, `К2`, `RSCI`, `Q1` или `Q2`. `applicable_year` должен совпадать с подтверждаемым годом публикации. Для Q1/Q2 обязательны система, метрика, категория и год метрики. Допустимые системы: Web of Science, Scopus, PubMed, MathSciNet, zbMATH, Chemical Abstracts, Springer, GeoRef. Заполнение названия системы не устанавливает наличие квартильной методики в ней: администратор должен проверить применимость конкретной метрики и основания по инструкции. Простая индексация, РИНЦ, `hse_list` и числовой CiteScore не подтверждают основание. Если год метрики отличается от года публикации, `source` должен обосновывать применимость: сервис не распространяет снимок автоматически.
Для степени выбирается максимальный уровень по всему профилю и документально проверенным записям: доктор выше кандидата/PhD; последние имеют равный уровень. История остаётся видимой. Подтверждение кандидата не разрешает заменить неподтверждённого доктора пунктом 6. Подтверждения добавляют только после проверки документов выбранного уровня.
## Нормативные уточнения
До получения источника спорные границы, иные виды работ и период зачёта монографии не считаются подтверждёнными. После проверки исходного документа можно явно задать следующие записи в `policy`, каждая с `source` и `checked_on`:
- `boundary_dates`: `value: "inclusive"` разрешает обе точные граничные даты. По умолчанию подтверждаются только даты строго между ними. Если источник устанавливает разные режимы начала и конца, этот формат не подходит: требуется отдельная правка, границы пока оставляют на проверке.
- `monograph_period`: `value: "five_years"` либо `"all_years"` определяет период отдельного зачёта подтверждённых монографий. Без него монографический минимум не подтверждается.
- `publication_types`: `values: ["monograph", "chapter", ...]` разрешает явно проверенные дополнительные виды в общий публикационный минимум. Статьи учитываются при подтверждённом виде и основании. Не считайте исключение других видов нормативным запретом: допустимость пока не установлена. Монография отдельно и в общем числе может учитываться одновременно только после такого уточнения.
## Тематическое рассмотрение
Прямая и условная связь журнала — аналитическая подсказка, не подтверждение любой статьи. Текст условий не вычисляется по ключевым словам и не трактуется как AND/OR. Отсутствие журнала в подборке означает «не оценён», а `not_established` относится к конкретной оценённой паре и не запрещает междисциплинарную статью.
К записи публикации можно добавить `thematic_review` с полями `specialty_code`, `branch` (значение отрасли из формы), `passport_id`, `passport_sha256`, `point_numbers` (числовой массив), `materials` (ссылки/описание проверенных материалов), `checked_conditions` (массив результатов проверки условий, допускается пустой массив), `result` (`confirmed` или `requires_check`), `source`, `checked_on`. Сервис проверяет выбранную специальность, отрасль, версию паспорта и номера направлений. Для 1.3.8 технический и физико-математический паспорта имеют отдельные ID и нумерацию; подмена отрасли не допускается. Оговорка 1.2.2 о названии диссертации не применяется автоматически к статье. Результат рассмотрения показывается отдельно и не вводит дополнительный числовой критерий.
## Каталоги и обновление
Читаются только JSON Q1/Q2 со схемами 1.0.0/1.1.0; журналы объединяются по ID, все категории и источники обеих подборок сохраняются. Сопоставление — по ISSN/eISSN; название служит проверкой идентичности, несовпадение требует рассмотрения. Перцентиль CiteScore: Q1 от 75, Q2 от 50 до 75; в JCR используется квартиль категории. Год метрики 2025 и год выпуска 2026 различаются. Снимок не распространяется на другие годы и не является белым списком: отдельное внешнее подтверждение допускается и вне каталога. Значение «текущая индексация не проверена» не означает отсутствия индексации.
Проверяются уникальность ID и пар, источники, паспорта, пункты, SHA-256 локальных PDF и безопасность относительных путей. Повреждённый снимок не даёт оснований и показывается как требующий проверки; второй исправный снимок сохраняется. Связь К1 со справочником специальностей проверяется по SHA-256. К1 и К2 остаются предварительными снимками категории; ручное основание должно иметь свой применимый год и источник. Подбор специальности по К1 остаётся вспомогательным и может скрывать сотрудников с иными основаниями; он не является условием пунктов 5–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: справочники и подтверждения кешируются в процессе.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "0.15.4"
version = "0.16.0"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+8 -5
View File
@@ -13,7 +13,9 @@ import re
import pdfplumber
def extract(source: Path) -> dict:
def extract(source: Path, category: str = "К1") -> dict:
if category not in {"К1", "К2"}:
raise ValueError("Unsupported category")
rows = []
with pdfplumber.open(source) as document:
for page in document.pages:
@@ -31,7 +33,7 @@ def extract(source: Path) -> dict:
raise ValueError("Journal numbering is incomplete or duplicated")
journals = []
for row in rows:
if row[2] != "К1":
if row[2] != category:
continue
name = " ".join(row[1].split())
journal = {"name": name, "categorized_on": row[3], "source_row": int(row[0])}
@@ -42,7 +44,7 @@ def extract(source: Path) -> dict:
journal.update(name=rename[1], note=rename[2], aliases=[rename[3]])
journals.append(journal)
if not journals:
raise ValueError("No K1 journals found")
raise ValueError(f"No {category} journals found")
return {"source": source.name, "source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
"source_rows": len(rows), "category_counts": dict(Counter(row[2] for row in rows)),
"journals": journals}
@@ -52,7 +54,8 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("--output", type=Path, default=Path("app/data/k1_journals.json"))
parser.add_argument("--category", choices=["К1", "К2"], default="К1")
args = parser.parse_args()
result = extract(args.source)
result = extract(args.source, args.category)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
print(f"Saved {len(result['journals'])} K1 journals; verified {result['source_rows']} source rows")
print(f"Saved {len(result['journals'])} {args.category} journals; verified {result['source_rows']} source rows")
+18 -3
View File
@@ -38,12 +38,13 @@ def main():
db.flush()
db.add(EmployeePublication(employee_id=person.id, title="Исследование методов передачи данных в фотонных интегральных схемах",
year=2024, publication_type="ARTICLE", publication_id=f"{number}",
raw_data={"journal": "Бизнес-информатика"} if number == 27 else {},
raw_data={"journal": "Бизнес-информатика"} if number == 27 else
{"journal": "BIT Numerical Mathematics", "issn": "0006-3835"} if number == 25 else {},
url=f"https://publications.hse.ru/view/{number}", source_hash=f"test:{number}"))
if number == 27:
db.add(EmployeePublication(employee_id=person.id, title="Дополнительная статья", year=2024,
publication_type="ARTICLE", source_hash="test:extra"))
for name, degree in [("Без степени", None), ("Кандидат наук", "Кандидат технических наук")]:
for name, degree in [("Без степени", None), ("Только звание", "Профессор")]:
person = Employee(profile_key=name, full_name=name, canonical_url="https://www.hse.ru/staff/test",
current_data={"sections": [{"title": "Учёные степени", "items": [degree] if degree else []}]})
db.add(person)
@@ -81,9 +82,14 @@ def main():
page.on("pageerror", lambda error: errors.append(str(error)))
page.goto("http://miem.test/admin/council")
expect(page.get_by_role("status")).to_have_text("Показаны 1–25 из 28")
assert "Без степени" not in page.content() and "Кандидат наук" not in page.content()
assert "Без степени" not in page.content() and "Только звание" not in page.content()
page.get_by_label("Дата ходатайства").fill("2026-10-08")
page.get_by_label("Отрасль предполагаемого включения").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()
expect(page.get_by_label("Дата ходатайства")).to_have_value("2026-10-08")
expect(page.get_by_label("Отрасль предполагаемого включения")).to_have_value("технические")
expect(page.get_by_role("status")).to_have_text("Показаны 26–28 из 28")
page.get_by_role("link", name="Назад").click()
summary = page.locator(".council__candidate-summary").first
@@ -161,6 +167,15 @@ def main():
assert filtered["Сотрудники"].cell(2, headers["Специальности ВАК"]).value.startswith("1.2.2.")
assert filtered["Сотрудники"].cell(2, headers["Работ по выбранной специальности"]).value == 1
pending_download.value.save_as(str(output / "specialty-export.xlsx"))
page.get_by_label("Специальность ВАК").select_option("")
page.get_by_label("Поиск по ФИО").fill("Сотрудник 25")
page.get_by_role("button", name="Применить", exact=True).click()
page.locator(".council__candidate-summary").click()
expect(page.get_by_text("BIT Numerical Mathematics", exact=False).first).to_be_visible()
for width in (1440, 390, 320):
page.set_viewport_size({"width": width, "height": 900})
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), width
page.screenshot(path=str(output / f"catalog-{width}.png"), full_page=True)
assert not errors, errors
browser.close()
finally:
+1 -1
View File
@@ -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"] == "0.15.4"
assert response.json()["backend_version"] == "0.16.0"
def test_api_employees_and_stats_require_admin_session():
+15 -12
View File
@@ -85,7 +85,7 @@ def test_council_union_counts_provenance_and_types(db_session, export):
assert council.period(date(2024, 2, 29))[0] == date(2019, 2, 28)
def test_only_doctors_with_a_publication_no_count_threshold(db_session, export):
def test_all_degree_levels_and_missing_publications_remain_visible(db_session, export):
export([row(author="Доктор", title="Single qualifying"),
row(author="Без степени", title="No degree"),
row(author="Кандидат", title="Candidate work"),
@@ -103,7 +103,10 @@ def test_only_doctors_with_a_publication_no_count_threshold(db_session, export):
employee(db_session, "Без публикаций", key="empty")
db_session.commit()
items = council.council_candidates(db_session, date(2026, 9, 30))["candidates"]
assert {c["name"] for c in items} == {"Доктор", "Две степени", "Уволен", "Только БД"}
assert {c["name"] for c in items} == {"Доктор", "Две степени", "Уволен", "Только БД", "Кандидат", "PhD", "Без публикаций"}
assert next(c for c in items if c["name"] == "Кандидат")["minimum"] == 20
assert next(c for c in items if c["name"] == "PhD")["clause"] == 6
assert next(c for c in items if c["name"] == "Без публикаций")["assessment_status"] == "Требует проверки"
assert next(c for c in items if c["name"] == "Доктор")["total_count"] == 1
assert next(c for c in items if c["name"] == "Только БД")["db_count"] == 1
assert next(c for c in items if c["name"] == "Две степени")["total_count"] == 0
@@ -209,13 +212,13 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
response = client.get("/admin/council", follow_redirects=False)
assert response.status_code == 200
assert "Иванов Иван Иванович" in response.text and "Доктор технических наук" in response.text
assert "Минимальный порог количества работ не применяется" in response.text
assert "Подтверждённый итог требует документального подтверждения высшей степени" in response.text
assert "CSV по критериям" not in response.text and "ВСЕГО" in response.text and "Все публикации" in response.text
assert "Книга" in response.text and "Тип не указан" in response.text
assert "&lt;script&gt;" in response.text and "<script>" not in response.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 0.15.4 · Frontend 0.15.4" in response.text
assert "Backend 0.16.0 · Frontend 0.16.0" 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
@@ -258,13 +261,13 @@ def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db
book = load_workbook(BytesIO(response.content))
assert book.sheetnames == ["Сотрудники", "Публикации", "Параметры"]
people, works = book["Сотрудники"], book["Публикации"]
assert [cell.value for cell in people[1]] == [
assert [cell.value for cell in people[1]][:13] == [
"№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "Страница ВШЭ", "Все публикации",
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности"
]
assert people["J2"].value == "https://www.hse.ru/staff/test"
assert people["K2"].value is None
assert people.max_column == 13
assert people.max_column == 26
assert works["L1"].value == "Страница ВШЭ"
assert people.max_row == 29 and works.max_row == 29
assert people["B2"].value == "Петров 27" and people["B29"].value == "Петров 00"
@@ -474,8 +477,8 @@ def test_specialty_filters_use_eligible_source_journal_not_merged_evidence(db_se
assert [a["title"] for a in candidate["articles"] if a["matches_specialty"]] == ["Matching"]
assert "По специальности 1.2.2" in " ".join(candidate["articles"][0]["criteria"])
assert council.council_candidates(db_session, specialty="2.2.14")["matched"] == 0
invalid = council.council_candidates(db_session, specialty="<script>")
assert invalid["specialty"] == "" and invalid["matched"] == 2
with pytest.raises(council.CouncilInputError):
council.council_candidates(db_session, specialty="<script>")
def test_specialty_page_pagination_search_and_export_agree(db_session, export):
@@ -567,12 +570,12 @@ def test_xlsx_specialties_are_explicit_with_and_without_filter(db_session, expor
if not specialty:
unmatched = next(dict(zip(staff[0], values)) for values in staff[1:] if values[1] == other.full_name)
assert unmatched['Специальности ВАК'] == 'Не подтверждены'
assert book['Сотрудники'].auto_filter.ref.endswith(f'M{len(staff)}')
assert book['Публикации'].auto_filter.ref.endswith(f'Q{len(works)}')
assert book['Сотрудники'].auto_filter.ref.endswith(f'Z{len(staff)}')
assert book['Публикации'].auto_filter.ref.endswith(f'AA{len(works)}')
def test_non_doctor_publications_are_not_parsed(db_session, export, monkeypatch):
person = employee(db_session, degree='Кандидат технических наук')
def test_employee_without_degree_publications_are_not_parsed(db_session, export, monkeypatch):
person = employee(db_session, degree='Профессор')
publication(db_session, person, kind='ARTICLE', description={'short': {'ru': '<b>Journal</b>. 2024.'}})
db_session.commit()
def unexpected(publication):
+294
View File
@@ -0,0 +1,294 @@
import itertools
import json
from datetime import date
from io import BytesIO
import pytest
from fastapi.testclient import TestClient
from openpyxl import load_workbook
from app.db import get_db
from app.main import app
from app.models import Employee, EmployeePublication
from app.services import council, council_assessment as assessment
from app.services.council_catalog import catalog, citescore_quartile, publication_catalog
from app.services.council_export import council_xlsx
SOURCE = {"source": "Проверенная запись", "checked_on": "2026-10-08"}
@pytest.fixture()
def proofs(monkeypatch, tmp_path):
path = tmp_path / "evidence.json"
monkeypatch.setattr(assessment, "EVIDENCE", path)
monkeypatch.setattr(council, "_articles_by_author", lambda: {})
def write(degrees=None, publications=None, policy=None):
path.write_text(json.dumps({"schema_version": 1, "policy": policy or {}, "employees": {
"test": {"degrees": degrees or [], "publications": publications or []}}}), encoding="utf8")
assessment.evidence.cache_clear()
write()
yield write
assessment.evidence.cache_clear()
def person(db, degrees):
value = Employee(profile_key="test", canonical_url="https://www.hse.ru/staff/test", full_name="Тестовый сотрудник",
current_data={"sections": [{"title": "Учёные степени", "items": degrees}]})
db.add(value)
db.flush()
return value
def work(db, employee, number=0, year=2024, kind="ARTICLE", basis="К1", **proof_fields):
doi = f"10.1234/work-{number}"
db.add(EmployeePublication(employee_id=employee.id, title=f"Work {number}", year=year, publication_type=kind,
source_hash=f"hash-{number}", doi_url=f"https://doi.org/{doi}"))
return {"doi": doi, "kind": "article", "kind_evidence": SOURCE,
"bases": [{**SOURCE, "basis": basis, "applicable_year": year}], **proof_fields}
def result(db, branch="технические", **kwargs):
db.commit()
return council.council_candidates(db, petition_date=date(2026, 10, 8), branch=branch, **kwargs)["candidates"][0]
@pytest.mark.parametrize("branch,rule", assessment.BRANCHES.items())
@pytest.mark.parametrize("delta", [-1, 0, 1])
def test_each_doctor_threshold_and_monograph_requirement(db_session, proofs, branch, rule, delta):
minimum, mono = rule
degree = "Доктор технических наук"
employee = person(db_session, [degree])
records = [work(db_session, employee, n) for n in range(minimum + delta)]
proofs([{**SOURCE, "degree": degree}], records)
value = result(db_session, branch)
assert value["minimum"] == minimum and value["monograph_minimum"] == mono
assert value["confirmed_count"] == minimum + delta
assert value["assessment_status"].startswith("Соответствует") == (delta >= 0 and not mono)
@pytest.mark.parametrize("degree", ["Кандидат технических наук", "Ph.D."])
@pytest.mark.parametrize("count", [19, 20, 21])
@pytest.mark.parametrize("basis", ["К1", "К2", "RSCI"])
def test_candidate_phd_20_and_k2_exclusion(db_session, proofs, degree, count, basis):
employee = person(db_session, [degree])
proofs([{**SOURCE, "degree": degree}], [work(db_session, employee, n, basis=basis) for n in range(count)])
value = result(db_session)
assert value["clause"] == 6 and value["minimum"] == 20
assert value["confirmed_count"] == (0 if basis == "К2" else count)
assert value["assessment_status"].startswith("Соответствует") == (basis != "К2" and count >= 20)
@pytest.mark.parametrize("degrees", list(itertools.permutations(["Кандидат технических наук", "PhD", "Доктор технических наук"])))
def test_history_always_chooses_doctor_without_fallback(db_session, proofs, degrees):
person(db_session, list(degrees) + ["Профессор", "Неизвестная квалификация"])
proofs([{**SOURCE, "degree": "Кандидат технических наук"}])
value = result(db_session)
assert value["clause"] == 5 and value["selected_degree"] == "Доктор технических наук"
assert not value["degree_confirmed"] and value["count"] == 0
assert value["assessment_status"] == "Требует проверки"
def test_equal_candidate_and_phd_history(db_session, proofs):
person(db_session, ["PhD", "Кандидат технических наук"])
value = result(db_session)
assert value["clause"] == 6 and "PhD" in value["selected_degree"] and "Кандидат" in value["selected_degree"]
@pytest.mark.parametrize("branch", [b for b, rule in assessment.BRANCHES.items() if rule[1]])
def test_monograph_explicit_evidence_and_period_policy(db_session, proofs, branch):
degree = "Доктор технических наук"
employee = person(db_session, [degree])
records = [work(db_session, employee, n) for n in range(8)]
records.append(work(db_session, employee, 8, kind="BOOK", kind_evidence=SOURCE))
proofs([{**SOURCE, "degree": degree}], records)
assert result(db_session, branch)["monograph_count"] == 0
records[-1]["kind"] = "monograph"
proofs([{**SOURCE, "degree": degree}], records, {"monograph_period": {**SOURCE, "value": "five_years"}})
value = result(db_session, branch)
assert value["confirmed_count"] == 8 and value["monograph_count"] == 1
assert value["assessment_status"].startswith("Соответствует")
@pytest.mark.parametrize("year,exact,expected", [(2021, None, False), (2026, None, False),
(2022, None, True), (2021, "2021-10-09", True), (2021, "2021-10-08", False),
(2026, "2026-10-07", True), (2026, "2026-10-08", False), (2026, "2026-10-09", False),
(2024, "2025-01-01", False), (2024, "invalid", False)])
def test_boundary_dates_and_conflicting_year(db_session, proofs, year, exact, expected):
employee = person(db_session, ["Доктор технических наук"])
record = work(db_session, employee, year=year)
if exact:
record.update(publication_date=exact, date_evidence=SOURCE)
proofs(publications=[record])
value = result(db_session)
assert value["confirmed_count"] == int(expected)
def test_no_proof_from_quartile_csv_and_current_k1(db_session, proofs):
employee = person(db_session, ["Доктор технических наук"])
db_session.add(EmployeePublication(employee_id=employee.id, title="Preliminary", year=2024, source_hash="prelim",
raw_data={"journal": "Бизнес-информатика", "csv_enrichment": {"Квартиль scopus": "Q1"}}))
value = result(db_session)
assert value["total_count"] == 1 and value["confirmed_count"] == 0
assert value["assessment_status"] == "Требует проверки"
def test_catalogs_union_percentiles_and_article_not_approved():
data = catalog()
assert len(data["journals"]) == 229 and len(data["passports"]) == 13 and not data["issues"]
assert [citescore_quartile(n) for n in [49, 50, 74, 75]] == ["Q3", "Q2", "Q2", "Q1"]
records, issues = publication_catalog(["0006-3835"], ["BIT Numerical Mathematics"], "1.2.2")
assert records and records[0]["match_level"] == "direct" and not issues
assert {m["quartile"] for m in records[0]["metrics"]} >= {"Q2", "Q3"}
assert all(m["year"] == 2025 for m in records[0]["metrics"])
assert publication_catalog(["9999-9999"], ["Not evaluated"], "1.2.2") == ([], [])
assert publication_catalog(["0006-3835"], ["Wrong name"])[1]
def test_http_validation_context_and_export_parity(db_session, proofs):
degree = "Доктор технических наук"
employee = person(db_session, [degree])
proofs([{**SOURCE, "degree": degree}], [work(db_session, employee, n) for n in range(9)])
db_session.commit()
def database():
yield db_session
app.dependency_overrides[get_db] = database
try:
client = TestClient(app)
for query in ["petition_date=oops", "petition_date=0001-01-01", "branch=oops", "specialty=oops"]:
assert client.get("/admin/council?" + query).status_code == 422
assert client.get("/admin/council/export.xlsx?" + query).status_code == 422
query = "?petition_date=2026-10-08&branch=технические&sort=confirmed_count"
page = client.get("/admin/council" + query)
assert page.status_code == 200 and "Соответствует публикационным критериям пункта 5" in page.text
book = load_workbook(BytesIO(client.get("/admin/council/export.xlsx" + query).content))
headers = [c.value for c in book["Сотрудники"][1]]
row = dict(zip(headers, [c.value for c in book["Сотрудники"][2]]))
assert row["Подтверждено"] == 9 and row["Минимум"] == 9
assert row["Итог"] in page.text
assert row["Отрасль оценки"] == "технические"
finally:
app.dependency_overrides.clear()
def test_unknown_kind_conflicts_and_safe_source_url(db_session, proofs):
employee = person(db_session, ["Доктор технических наук"])
record = work(db_session, employee)
record["bases"][0]["source_url"] = "javascript:alert(1)"
records = [record, {**record, "kind": "monograph"}]
proofs(publications=records)
value = result(db_session)
assert value["confirmed_count"] == 0
assert value["articles"][0]["confirmed_bases"][0]["source_url"] is None
def test_no_degree_or_only_title_does_not_form_conclusion(db_session, proofs):
person(db_session, ["Профессор", "Доцент"])
assert council.council_candidates(db_session)["total"] == 0
def test_exact_dates_conflict_and_bases_intersection_count_once(db_session, proofs):
employee = person(db_session, ["Доктор технических наук"])
record = work(db_session, employee)
record["bases"].append({**SOURCE, "basis": "RSCI", "applicable_year": 2024})
proofs(publications=[record])
assert result(db_session)["confirmed_count"] == 1
proofs(publications=[{**record, "publication_date": "2024-01-01", "date_evidence": SOURCE},
{**record, "publication_date": "2024-02-01", "date_evidence": SOURCE}])
assert result(db_session)["confirmed_count"] == 0
def test_exact_date_can_confirm_missing_year_without_losing_preliminary_count(db_session, proofs):
employee = person(db_session, ["Доктор технических наук"])
record = work(db_session, employee, year=None, publication_date="2024-03-01", date_evidence=SOURCE)
record["bases"][0]["applicable_year"] = 2024
proofs(publications=[record])
value = result(db_session)
assert value["confirmed_count"] == value["total_count"] == 1
def test_identity_mismatch_cannot_be_resolved_by_either_identifier(db_session, proofs):
employee = person(db_session, ["Доктор технических наук"])
record = work(db_session, employee)
record["source_hash"] = "hash-of-another-work"
proofs(publications=[record])
value = result(db_session)
assert value["confirmed_count"] == 0
assert any("разные работы" in reason for reason in value["articles"][0]["confirmation_reasons"])
@pytest.mark.parametrize("passport_id,branch,accepted", [("1.3.8-technical", "технические", True),
("1.3.8-physical-mathematical", "технические", False),
("1.3.8-physical-mathematical", "физико-математические", True)])
def test_thematic_review_uses_correct_passport_branch(db_session, proofs, passport_id, branch, accepted):
employee = person(db_session, ["Доктор технических наук"])
passport = catalog()["passports"][passport_id]
record = work(db_session, employee)
record["thematic_review"] = {**SOURCE, "specialty_code": "1.3.8", "branch": branch,
"passport_id": passport_id, "passport_sha256": passport["sha256"],
"point_numbers": [passport["directions"][0]["number"]], "checked_conditions": [],
"materials": ["Цель, методы, научный результат и текст статьи"], "result": "confirmed"}
proofs(publications=[record])
value = result(db_session, branch)
article = value["articles"][0]
assessment.assess_article(article, [record], date(2021, 10, 8), date(2026, 10, 8), 2, branch, "1.3.8", {})
assert ("соответствие подтверждено" in article["thematic_status"]) == accepted
assert article["confirmed"] # The thematic outcome adds no numeric rule.
@pytest.mark.parametrize("system", sorted(assessment.SYSTEMS))
def test_external_quartile_outside_catalog_requires_category_and_source(db_session, proofs, system):
employee = person(db_session, ["Доктор технических наук"])
record = work(db_session, employee, basis="Q1")
record["bases"][0].update(system=system, category="Verified category", metric="Verified metric", metric_year=2024)
proofs(publications=[record])
assert result(db_session)["confirmed_count"] == 1
record["bases"][0].pop("category")
proofs(publications=[record])
assert result(db_session)["confirmed_count"] == 0
def test_rinc_and_hse_list_do_not_supply_rsci(db_session, proofs):
employee = person(db_session, ["Доктор технических наук"])
db_session.add(EmployeePublication(employee_id=employee.id, title="RINC", year=2024, publication_type="ARTICLE",
source_hash="rinc", raw_data={"hse_list": True, "rinc": True}))
value = result(db_session)
assert value["rsci_count"] == value["confirmed_count"] == 0
def test_degree_document_can_include_employee_without_profile_degree(db_session, proofs):
person(db_session, [])
proofs(degrees=[{**SOURCE, "degree": "PhD"}])
value = result(db_session)
assert value["clause"] == 6 and value["degree_confirmed"] and value["count"] == 0
def test_confirmed_degree_needs_petition_and_branch_even_if_minimum_met(db_session, proofs):
degree = "Доктор технических наук"
employee = person(db_session, [degree])
proofs([{**SOURCE, "degree": degree}], [work(db_session, employee, n) for n in range(9)])
db_session.commit()
value = council.council_candidates(db_session, today=date(2026, 10, 8))["candidates"][0]
assert value["confirmed_count"] == 9 and value["assessment_status"] == "Требует проверки"
assert any("Дата ходатайства" in r for r in value["assessment_reasons"])
def test_boundary_dates_require_sourced_policy(db_session, proofs):
employee = person(db_session, ["Доктор технических наук"])
record = work(db_session, employee, year=2021, publication_date="2021-10-08", date_evidence=SOURCE)
proofs(publications=[record], policy={"boundary_dates": {"value": "inclusive"}})
assert result(db_session)["confirmed_count"] == 0
proofs(publications=[record], policy={"boundary_dates": {**SOURCE, "value": "inclusive"}})
assert result(db_session)["confirmed_count"] == 1
def test_invalid_evidence_schema_is_rejected(monkeypatch, tmp_path):
path = tmp_path / "invalid.json"
monkeypatch.setattr(assessment, "EVIDENCE", path)
path.write_text(json.dumps({"schema_version": 1, "employees": {"x": {"publications": [{"doi": "10.1234/x", "bases": "К1"}]}}}), encoding="utf8")
assessment.evidence.cache_clear()
try:
with pytest.raises(ValueError):
assessment.evidence()
finally:
assessment.evidence.cache_clear()
+83
View File
@@ -0,0 +1,83 @@
import hashlib
import json
import pytest
from app.services import council_catalog as catalogs
@pytest.fixture()
def snapshot(monkeypatch, tmp_path):
data = json.loads(catalogs.CATALOGS[0].read_text(encoding="utf8"))
for specialty in data["specialties"]:
for passport in specialty["passports"]:
path = tmp_path / "passports" / (passport["id"] + ".pdf")
path.parent.mkdir(exist_ok=True)
path.write_bytes(b"test passport")
passport["local_pdf"] = str(path.relative_to(tmp_path))
passport["sha256"] = hashlib.sha256(path.read_bytes()).hexdigest()
path = tmp_path / "catalog.json"
monkeypatch.setattr(catalogs, "DATA", tmp_path)
monkeypatch.setattr(catalogs, "CATALOGS", (path,))
def write():
path.write_text(json.dumps(data), encoding="utf8")
catalogs.catalog.cache_clear()
return catalogs.catalog()
yield data, write
catalogs.catalog.cache_clear()
@pytest.mark.parametrize("fault", ["schema", "duplicate_journal", "duplicate_link", "source", "passport", "point", "path", "hash", "quartile", "id"])
def test_damaged_snapshot_is_quarantined(snapshot, fault):
data, read = snapshot
if fault == "schema":
data["metadata"]["schema_version"] = "2.0.0"
elif fault == "duplicate_journal":
data["journals"].append(data["journals"][0])
elif fault == "duplicate_link":
data["specialty_links"].append(data["specialty_links"][0])
elif fault == "source":
data["journals"][0]["scopus"]["source_id"] = "missing"
elif fault == "passport":
data["specialty_links"][0]["passport_references"][0]["passport_id"] = "missing"
elif fault == "point":
data["specialty_links"][0]["passport_references"][0]["point_numbers"] = [99999]
elif fault == "path":
data["specialties"][0]["passports"][0]["local_pdf"] = "../../outside.pdf"
elif fault == "hash":
data["specialties"][0]["passports"][0]["sha256"] = "0" * 64
elif fault == "quartile":
data["journals"][0]["scopus"]["categories"][0]["quartile"] = "Q4"
elif fault == "id":
data["journals"][0]["scopus_source_id"] = 0
loaded = read()
assert loaded["issues"] and loaded["journals"] == {}
def test_q1_optional_q2_fields_are_not_required(snapshot):
data, read = snapshot
assert "selection_rule" not in data["metadata"]
loaded = read()
assert len(loaded["journals"]) == 107 and not loaded["issues"]
def test_unknown_journal_is_not_not_established_and_issn_conflicts():
records, issues = catalogs.publication_catalog(["0006-3835", "0020-0255"], [])
assert not records and issues
assert catalogs.publication_catalog([], ["A journal not in catalogs"], "1.3.8") == ([], [])
def test_overlap_preserves_both_records_and_best_q1_outside_q1_selection():
loaded = catalogs.catalog()
assert sum(len(records) == 2 for records in loaded["journals"].values()) == 2
q2_with_q1 = [r for records in loaded["journals"].values() for r in records
if r["catalog"] == "q2-journals" and r.get("also_meets_q1_rule") and not r.get("in_local_q1_catalog")]
assert len(q2_with_q1) == 3
assert all(any(m["quartile"] == "Q1" for m in r["metrics"]) for r in q2_with_q1)
def test_two_passports_keep_branch_and_point_numbering():
data = catalogs.catalog()
assert data["passports"]["1.3.8-technical"]["branch"] == "технические науки"
assert data["passports"]["1.3.8-physical-mathematical"]["branch"] == "физико-математические науки"
assert data["passports"]["1.3.8-technical"]["directions"] != data["passports"]["1.3.8-physical-mathematical"]["directions"]
+42
View File
@@ -0,0 +1,42 @@
import hashlib
import importlib.util
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
def extractor(monkeypatch, rows):
class Document:
pages = [SimpleNamespace(extract_tables=lambda: [rows])]
def __enter__(self):
return self
def __exit__(self, *_):
pass
monkeypatch.setitem(sys.modules, "pdfplumber", SimpleNamespace(open=lambda _: Document()))
spec = importlib.util.spec_from_file_location("category_extractor_test", Path("scripts/extract_k1_journals.py"))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_k2_extraction_preserves_provenance_history_and_alias(monkeypatch, tmp_path):
source = tmp_path / "categories.pdf"
source.write_bytes(b"synthetic source")
module = extractor(monkeypatch, [["1", "First", "К1", "01.01.2024"],
["2", "Second (до 01.01.2024 г. наименование в Перечне «Previous»)", "К3\nК2", "01.01.2023\n01.01.2024"]])
result = module.extract(source, "К2")
assert result["source_rows"] == 2 and result["category_counts"] == {"К1": 1, "К2": 1}
assert result["journals"][0]["name"] == "Second"
assert result["journals"][0]["aliases"] == ["Previous"]
assert result["journals"][0]["categorized_on"] == "01.01.2024"
assert result["source_sha256"] == hashlib.sha256(source.read_bytes()).hexdigest()
assert module.extract(source)["journals"][0]["name"] == "First"
@pytest.mark.parametrize("rows", [[["2", "Second", "К2", "01.01.2024"]], [["1", "Second", "К2", "invalid"]]])
def test_k2_rejects_incomplete_numbering_or_date(monkeypatch, tmp_path, rows):
module = extractor(monkeypatch, rows)
with pytest.raises(ValueError):
module.extract(tmp_path / "categories.pdf", "К2")