Merge pull request 'Публикационные критерии совета: подтверждения, пороги и каталоги' (#65) from feature/council-publication-criteria into main
Reviewed-on: #65 Reviewed-by: agent <agent@vakanaut.ru>
This commit was merged in pull request #65.
This commit is contained in:
commit
b777bb9e16
42 files changed
+77154
-25862
No files matched your search
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## 0.16.5
|
||||
|
||||
- Закреплён обязательный перенос недостающих данных перед будущим production-выпуском: резервная копия, предварительная сверка, сохранение заполненных значений, отдельная обработка конфликтов и проверка полноты до обновления приложения.
|
||||
|
||||
- Источники публикаций названы «Данные ВШЭ» и «ЛК сотрудника ВШЭ» на странице совета и в XLSX. Счётчик личного кабинета не включает записи, полученные только из выгрузки ВШЭ.
|
||||
- Из выгрузки ВШЭ удалены 797 строк, однозначно найденных у всех указанных авторов в локальной БД workers; метаданные сохранены в 1226 существующих записях, резервные копии созданы. Неоднозначные совпадения и неполные связи авторов оставлены.
|
||||
|
||||
## 0.16.4
|
||||
|
||||
- Вступление страницы совета и пояснения занимают всю доступную ширину своих блоков; убрана flex-разметка заголовка и ограничения ширины текста.
|
||||
|
||||
## 0.16.3
|
||||
|
||||
- Уточнены описание страницы совета и пояснения к источникам данных, предварительным счётчикам, подтверждённым работам и подбору по специальности.
|
||||
|
||||
## 0.16.2
|
||||
|
||||
- Страница совета и XLSX настроены на технические науки и пятилетний период относительно текущей даты. Убраны дата ходатайства, сведения о монографиях и связанные причины проверки.
|
||||
|
||||
## 0.16.1
|
||||
|
||||
- Ручные основания больше не возвращают книги и другие недопущенные виды в предварительные счётчики совета. Дополнительные виды учитываются только при подтверждённой разрешающей политике и согласованном виде материала.
|
||||
- Повреждение одного квартильного каталога не блокирует метрики исправного второго. Общее предупреждение о снимке отделено от проблем конкретной публикации; страница и XLSX сохраняют доступные проверенные сведения.
|
||||
|
||||
## 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; журналы и связи со специальностями не изменены.
|
||||
|
||||
@@ -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: работа в нескольких категориях учитывается один раз. При расхождении годов источников каждый критерий проверяется по году своей записи; неизвестный год наследуется только при единственном согласованном годе. Счётчики «В БД» и «Все публикации» включают все годы независимо от категорий.
|
||||
Страница и XLSX настроены на технические науки: пункт 5 — 9 публикаций для доктора; пункт 6 — 20 для кандидата/PhD, самостоятельного К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 не объявляется доказательством выполнения критериев. Сопоставление данных ВШЭ по полному ФИО разрешено только при единственном совпадении. Дедупликация использует 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.5`. Админка всегда показывает версии backend и frontend в footer.
|
||||
|
||||
## Разовый импорт публикаций
|
||||
|
||||
@@ -210,11 +210,28 @@ JSON-справочника записываются с LF для воспрои
|
||||
|
||||
Исходный `app/data/council_articles.csv` сохранён для проверки и отдельного импорта
|
||||
в БД. Данные файла и индекс кэшируются на процесс, CSV не преобразуется при
|
||||
HTTP-запросах. Название источника «Файл CSV» и номера строк в интерфейсе и XLSX
|
||||
сохранены как происхождение данных. Перед разбором публикаций сотрудников из БД
|
||||
проверяется докторская степень; публикации остальных сотрудников не разбираются.
|
||||
HTTP-запросах. В интерфейсе и XLSX используются названия «Данные ВШЭ» (выгрузка из базы публикаций ВШЭ) и «ЛК сотрудника ВШЭ» (публикации из личного кабинета). Номера строк выгрузки сохраняются для проверки источника. Показываются сотрудники с обнаруженной или подтверждённой степенью доктора, кандидата наук или PhD.
|
||||
|
||||
После обновления CSV пересоздайте JSON до сборки/развёртывания сервиса:
|
||||
При сверке 08.10.2026 с локальной БД workers из исходной выгрузки удалены 797 строк, однозначно найденных у всех указанных авторов. Их метаданные перенесены в `raw_data.csv_enrichment` 1226 существующих публикаций; парсер сохраняет это обогащение при обновлении записи. Выгрузка теперь содержит 1000 строк и 776 ФИО. Резервные копии исходной выгрузки, JSON и записей БД сохранены в `.impeccable/review/hse-source-backup/` и `.impeccable/review/workers-enrichment-backup.json` (не публикуются в Git). Строки с конфликтами или неполными связями авторов сохранены. Для другого экземпляра БД перенос метаданных из исходной выгрузки необходимо выполнить до использования сокращённого файла: сам Git-коммит данные БД не переносит.
|
||||
|
||||
### Обязательный перенос данных перед выпуском в production
|
||||
|
||||
Production пока остаётся на текущей стабильной версии; локальная БД может продолжать обогащаться. Перед будущим обновлением production до версии из этого PR **обязательно перенести недостающие данные из локальной БД, актуальной на момент выпуска**. Наличие самой публикации в production не заменяет перенос её метаданных, включая квартильные сведения в `raw_data.csv_enrichment`. Обновление Git эти данные не переносит. Полная замена production-БД локальной копией недопустима.
|
||||
|
||||
Порядок выпуска:
|
||||
|
||||
1. Зафиксировать актуальный снимок локальных данных и создать проверенную резервную копию production с возможностью восстановления. Сверку и перенос проводить с учётом параллельных изменений: перед записью повторно проверить значения назначения в транзакции.
|
||||
2. Выполнить предварительное сравнение без записи. Сотрудников сопоставлять по устойчивому идентификатору профиля; публикации — внутри соответствующего сотрудника по идентификатору источника или нормализованному DOI. Сопоставление по названию и году разрешено только при однозначном совпадении без конфликтующих идентификаторов. Одного совпадения ФИО недостаточно. Сформировать перечни новых записей, недостающих значений и конфликтов.
|
||||
3. Добавлять только отсутствующие записи и значения полей. Заполненные значения production не перезаписывать и не удалять. В JSON-объектах добавлять недостающие ключи, включая вложенные ключи `raw_data.csv_enrichment`, сохраняя остальные данные. Не заменять целиком заполненные JSON-объекты или массивы локальными значениями.
|
||||
4. Считать отсутствующим значением SQL `NULL`, отсутствующий JSON-ключ или JSON `null`; для текстовых полей также пустую строку либо строку только из пробельных символов. Числа `0`, логическое `false`, пустые массивы `[]` и объекты `{}` не считать отсутствующими значениями. В существующем объекте `{}` можно добавлять недостающие ключи без замены самого объекта. Некорректное заполненное значение — конфликт для отдельной проверки, а не разрешение на автоматическую перезапись.
|
||||
5. Неоднозначные совпадения и различия заполненных значений фиксировать в отдельном отчёте и рассматривать вручную. Повторный перенос того же снимка должен быть безопасным: не создавать дубли и не изменять уже перенесённые значения. Проверять уникальность связей публикации с сотрудником и повторять сопоставление перед добавлением записи.
|
||||
6. После переноса сверить запланированные и фактически перенесённые записи и значения, проверить метаданные и показатели страницы/XLSX. Убедиться, что удалённые из справочника публикации доступны в production со всеми необходимыми данными и что счётчики не снизились из-за пропущенного переноса. Конфликты, мешающие подтвердить полноту переноса, разрешить до выпуска. **Обновлять приложение и использовать сокращённый справочник только после успешной проверки миграции.**
|
||||
|
||||
Миграция относится к будущему production-выпуску; в рамках исправления этого PR она не выполняется.
|
||||
|
||||
### Пересоздание справочника публикаций
|
||||
|
||||
После обновления выгрузки ВШЭ пересоздайте JSON до сборки/развёртывания сервиса:
|
||||
|
||||
```sh
|
||||
python scripts/prepare_council_articles.py app/data/council_articles.csv
|
||||
|
||||
+9
-3
@@ -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
|
||||
|
||||
@@ -138,7 +138,10 @@ def directory(
|
||||
def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", specialty: 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,
|
||||
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)
|
||||
@@ -149,8 +152,11 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_c
|
||||
def export_council(request: Request, q: str = "", sort: str = "total_count", direction: str = "desc", specialty: 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,
|
||||
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)
|
||||
|
||||
@@ -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`. Последующие изменения документа и остальные условия членства в диссертационном совете не проверены. Соответствие публикационным критериям не означает автоматического включения в совет.
|
||||
@@ -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). Перед использованием такого сопоставления проверяйте его связь с актуальными исходными файлами.
|
||||
|
||||
При обновлении каталогов заново сверяйте квартильные категории за выбранный год, идентификаторы, источники тематики и связи с паспортами. Изменение квартиля, описания журнала или паспорта может потребовать пересмотра условий и уровня соответствия. Сохраняйте год, дату проверки, источник и версию схемы; исключение журнала из обновлённой подборки само по себе не является отрицательной оценкой его тематики.
|
||||
File diff suppressed because it is too large.
Load diff
+4447
-24959
File diff suppressed because it is too large.
Load diff
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
-15
@@ -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,27 @@ 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, publication_proofs,
|
||||
publication_type_allowed, sourced,
|
||||
)
|
||||
|
||||
|
||||
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 +74,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]:
|
||||
@@ -86,10 +116,10 @@ def _articles_by_author() -> dict[str, list[dict]]:
|
||||
def _database_article(publication) -> dict:
|
||||
raw = publication.raw_data if isinstance(publication.raw_data, dict) else {}
|
||||
imported = raw.get("csv_enrichment")
|
||||
from_site = bool(publication.publication_id or publication.url or any(key != "csv_enrichment" for key in raw))
|
||||
origins = ["БД miem-workers · сайт ВШЭ"] if from_site else []
|
||||
from_site = not imported or bool(publication.publication_id or publication.url or any(key != "csv_enrichment" for key in raw))
|
||||
origins = ["ЛК сотрудника ВШЭ"] if from_site else []
|
||||
if imported:
|
||||
origins.append("БД miem-workers · импорт CSV")
|
||||
origins.append("Данные ВШЭ")
|
||||
kind = publication.publication_type
|
||||
description = publication.description if isinstance(publication.description, dict) else raw.get("description")
|
||||
journals = _journal_names(raw)
|
||||
@@ -100,8 +130,9 @@ 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,
|
||||
"origins": origins or ["БД miem-workers"], "rows": [], "in_csv": False, "in_db": True,
|
||||
"journals": journals, "issns": [raw[key] for key in ("issn", "eissn") if isinstance(raw.get(key), str)],
|
||||
"source_hashes": [publication.source_hash],
|
||||
"origins": origins, "rows": [], "in_csv": bool(imported), "in_db": from_site,
|
||||
"url": _safe_url(publication.url)}
|
||||
|
||||
|
||||
@@ -132,9 +163,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 +178,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 +198,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 +225,20 @@ 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 = "",
|
||||
branch: str = "технические") -> dict:
|
||||
if branch and branch not in BRANCHES:
|
||||
raise CouncilInputError("Неизвестная отрасль науки")
|
||||
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
|
||||
start, end = period(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 +249,51 @@ 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)
|
||||
kinds = {proof["kind"] for proof in proofs if proof.get("kind") and sourced(proof.get("kind_evidence"))}
|
||||
kind = next(iter(kinds)) if len(kinds) == 1 else None
|
||||
preliminary_type_allowed = len(kinds) <= 1 and publication_type_allowed(
|
||||
article["types"], kind, verified.get("policy", {}), preliminary=True)
|
||||
if not preliminary_type_allowed:
|
||||
for flag in ("q1", "q2", "k1", "k2", "rsci"):
|
||||
article[flag] = False
|
||||
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 preliminary_type_allowed:
|
||||
article[metric["quartile"].lower()] = True
|
||||
for proof in proofs:
|
||||
if not preliminary_type_allowed:
|
||||
continue
|
||||
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, 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 +308,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,
|
||||
"branch": branch,
|
||||
"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}
|
||||
@@ -0,0 +1,267 @@
|
||||
"""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"}
|
||||
TYPE_LABELS = {"article": "Статья", "monograph": "Книга", "book": "Книга", "chapter": "Глава книги",
|
||||
"report": "Доклад", "patent": "Патент", "preprint": "Препринт", "conference_paper": "Материал конференции"}
|
||||
|
||||
|
||||
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 publication_type_allowed(types, kind, policy, *, preliminary=False):
|
||||
if kind is None:
|
||||
return preliminary and (not types or types == ["Статья"])
|
||||
if types and types != [TYPE_LABELS.get(kind)]:
|
||||
return False
|
||||
if kind == "article":
|
||||
return True
|
||||
admitted = policy.get("publication_types", {})
|
||||
return sourced(admitted) and kind in admitted.get("values", [])
|
||||
|
||||
|
||||
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_conflict = len(types) > 1 or bool(article["types"] and kind and article["types"] != [TYPE_LABELS.get(kind)])
|
||||
if type_conflict:
|
||||
reasons.append("Виды материала в источниках расходятся")
|
||||
eligible_kind = publication_type_allowed(article["types"], kind, policy)
|
||||
if kind and kind != "article" and 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, 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 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}
|
||||
@@ -0,0 +1,175 @@
|
||||
"""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))
|
||||
# Snapshot failures are reported once in the assessment context. Only
|
||||
# problems with this journal may prevent use of its validated metrics.
|
||||
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
|
||||
@@ -26,34 +26,53 @@ def council_xlsx(result: dict) -> bytes:
|
||||
def specialties_text(codes):
|
||||
return "\n".join(label for code, label in specialty_labels.items() if code in codes) or "Не подтверждены"
|
||||
|
||||
_append(employees, ["№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "Страница ВШЭ", "Все публикации",
|
||||
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности"])
|
||||
_append(employees, ["№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "ЛК сотрудника ВШЭ", "Все публикации",
|
||||
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности",
|
||||
"Текущая степень", "Степень подтверждена", "Пункт", "Отрасль оценки", "Минимум",
|
||||
"Подтверждено", "К2 предварительно", "RSCI предварительно", "Итог", "Причины проверки"])
|
||||
_append(publications, ["№ сотрудника", "ФИО", "Название публикации", "Год", "Тип материала", "Источники",
|
||||
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "Страница ВШЭ", "DOI", "Ссылка на сайте", "Строки CSV", "Специальности ВАК", "По выбранной специальности"])
|
||||
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "ЛК сотрудника ВШЭ", "DOI", "Ссылка на сайте", "Строки выгрузки ВШЭ", "Специальности ВАК", "По выбранной специальности",
|
||||
"К2", "RSCI", "Подтверждённый зачёт", "Дата публикации", "Причины проверки", "Подтверждённые основания",
|
||||
"Журнал и идентификаторы", "Квартильные категории и источники", "Аналитическая связь журнала", "Рассмотрение статьи"])
|
||||
for number, candidate in enumerate(result["candidates"], 1):
|
||||
note = "Совпадение ФИО: публикации CSV не присвоены автоматически" if candidate["ambiguous"] else ""
|
||||
note = "Совпадение ФИО: данные ВШЭ не присвоены автоматически" if candidate["ambiguous"] else ""
|
||||
specialty_codes = {code for article in candidate["articles"] for code in article["specialty_codes"]}
|
||||
_append(employees, [number, candidate["name"], candidate["degree"], candidate["q1_count"], candidate["q2_count"],
|
||||
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"], candidate["minimum"], candidate["confirmed_count"],
|
||||
candidate["k2_count"], candidate["rsci_count"],
|
||||
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["branch"]],
|
||||
["Период оценки", "Пять лет относительно текущей даты Москвы"],
|
||||
["Статус счётчиков", "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",
|
||||
"total_count": "ВСЕГО", "db_count": "Страница ВШЭ", "count": "Все публикации"}[result["sort"]]],
|
||||
["Сортировать по", {"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 +80,22 @@ def council_xlsx(result: dict) -> bytes:
|
||||
"Без названия журнала К1 не подтверждён."],
|
||||
["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — специальности релевантного журнала К1, учитываемые за пять лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
|
||||
["Работ / По выбранной специальности", "При выбранном фильтре показаны число соответствующих публикаций сотрудника и отметка Да/Нет для каждой работы. Без фильтра ячейки пустые; список специальностей остаётся заполненным."],
|
||||
["ВСЕГО", "Уникальные публикации, входящие в Q1 ИЛИ Q2 ИЛИ К1; пересечения учитываются один раз."],
|
||||
["Страница ВШЭ / Все публикации", "Все годы и категории; общий перечень объединяет CSV и БД без дублей. "
|
||||
"Страница ВШЭ учитывает сохранённые публикации, включая импортированные из CSV; источник указан на листе Публикации."],
|
||||
["ВСЕГО", "Предварительные уникальные работы Q1/Q2/К1/RSCI либо К2 для доктора; пересечения учитываются один раз."],
|
||||
["ЛК сотрудника ВШЭ / Все публикации", "Все годы и категории. ЛК сотрудника ВШЭ — публикации из личного кабинета. "
|
||||
"Все публикации объединяет записи личного кабинета и выгрузку из базы ВШЭ без дублей; источники указаны на листе Публикации."],
|
||||
]:
|
||||
_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]),
|
||||
_append(parameters, ["Пункт 5 · технические", "9 публикаций; К1/К2/RSCI/Q1/Q2"])
|
||||
_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, 12, 16, 16, 16, 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
|
||||
|
||||
@@ -98,7 +98,7 @@ def load_reference(path: Path) -> dict[str, list[dict]]:
|
||||
expected[author].append(index)
|
||||
articles.append({"title": record["title"], "year": record["year"], "doi": record["doi"],
|
||||
"types": [], "quartiles": record["quartiles"], "journals": record["journals"],
|
||||
"origins": ["Файл CSV"], "rows": [record["source_row"]],
|
||||
"origins": ["Данные ВШЭ"], "rows": [record["source_row"]],
|
||||
"in_csv": True, "in_db": False, "url": None})
|
||||
if by_author != dict(expected) or any(type(i) is not int for indexes in by_author.values() for i in indexes):
|
||||
raise ValueError("Council author index disagrees with publication authors")
|
||||
|
||||
@@ -825,19 +825,14 @@
|
||||
}
|
||||
|
||||
.council__hero {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
padding: clamp(24px, 4vw, 48px);
|
||||
color: #f9f6f0;
|
||||
background: #263640;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.council__hero-copy { max-width: 680px; }
|
||||
.council__title { margin: 0; font-family: Georgia, serif; font-size: clamp(29px, 4vw, 48px); line-height: 1.12; letter-spacing: -0.025em; }
|
||||
.council__intro { max-width: 60ch; margin: 16px 0 0; color: #e3e8e5; line-height: 1.55; }
|
||||
.council__intro { margin: 16px 0 0; color: #e3e8e5; line-height: 1.55; }
|
||||
.council__total { margin: 0; min-width: 160px; color: #e3e8e5; font-size: 14px; line-height: 1.4; }
|
||||
.council__total strong { display: block; color: #f6d49b; font-size: 42px; font-variant-numeric: tabular-nums; line-height: 1; }
|
||||
|
||||
@@ -885,7 +880,7 @@
|
||||
.council__message p { margin: 0; color: #4c5b64; line-height: 1.5; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.council__hero, .council__list-heading { align-items: start; flex-direction: column; }
|
||||
.council__list-heading { align-items: start; flex-direction: column; }
|
||||
.council__total strong { display: inline; margin-inline-end: 8px; font-size: 28px; }
|
||||
.council__criteria { grid-template-columns: 1fr; gap: 14px; }
|
||||
.council__candidate-summary { grid-template-columns: minmax(0, 1fr) auto 14px; gap: 10px; padding: 14px; }
|
||||
@@ -900,7 +895,7 @@
|
||||
.council__search { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; }
|
||||
.council__search .directory__field { flex: 1 1 240px; max-width: 520px; }
|
||||
.council__notice summary { cursor: pointer; padding-block: 6px; }
|
||||
.council__notice p { max-width: 75ch; margin-bottom: 0; }
|
||||
.council__notice p { margin-bottom: 0; }
|
||||
.council__counts { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: clamp(8px, 1.5vw, 20px); }
|
||||
@media (max-width: 700px) {
|
||||
.council__counts { grid-template-columns: repeat(3, minmax(0, 1fr)); row-gap: 14px; }
|
||||
|
||||
+34
-15
@@ -5,23 +5,27 @@
|
||||
<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">Подбор сотрудников в диссертационный совет по техническим наукам. Публикации за последние пять лет сопоставляются с данными о квартилях и категориях журналов. Минимум — 9 работ для доктора наук и 20 для кандидата наук или 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>«В БД» и «Все публикации» показывают количество работ за все годы, независимо от категорий. Общий перечень объединяет CSV и БД без дублей. Наличие публикаций не подтверждает соответствие всем требованиям совета.</p>
|
||||
<p>При выборе специальности показаны сотрудники с хотя бы одной учитываемой за пять лет публикацией в журнале К1, которому эта специальность присвоена в «ВАК Перечень.pdf» на 15.09.2026. Это соответствие по журналу; содержание статьи и исторический перечень специальностей на дату публикации не проверяются.</p>
|
||||
<summary><strong>Как рассчитываются показатели</strong></summary>
|
||||
<p>Период оценки: {{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }}, последние пять лет относительно текущей даты Москвы. Для доктора наук применяется минимум 9 публикаций по пункту 5, для кандидата наук или PhD — 20 по пункту 6. Используется высшая обнаруженная степень; её документальное подтверждение проверяется отдельно.</p>
|
||||
<p>Q1, Q2, К1, К2 и RSCI — предварительные показатели. Квартили определяются по данным ВШЭ, применимым данным каталогов и проверенным основаниям; категории К1/К2 — по сопоставлению журнала со справочниками ВАК. RSCI учитывается при наличии отдельной записи об этом основании; отметка РИНЦ сама по себе его не подтверждает. Сведения из профиля сотрудника дополняются внешними источниками.</p>
|
||||
<p>ВСЕГО — число уникальных работ с основаниями Q1/Q2/К1/RSCI; для доктора наук учитывается также К2. Пересечения между основаниями не увеличивают итог. Для кандидата наук или PhD одной категории К2 недостаточно. Известные другие виды материалов не входят в этот подсчёт без отдельного подтверждения их допустимости.</p>
|
||||
<p>«Подтверждено» — работы с проверенными видом материала, периодом и допустимым основанием. Для пограничных лет нужно уточнить дату публикации; текущая категория журнала не подтверждает его категорию в год выхода работы. «Требует проверки» означает, что для вывода не хватает подтверждений: например, степени, даты или основания зачёта работы. Это не означает, что у сотрудника нет нужных публикаций. Соответствие публикационному минимуму не означает автоматического включения в совет.</p>
|
||||
<p>Фильтр «Специальность ВАК» подбирает сотрудников с хотя бы одной публикацией за пять лет в подходящем журнале К1. Связь определяется по журналу из Перечня ВАК на 15.09.2026, а не по содержанию статьи. Квартильные каталоги содержат ограниченные подборки и метрики 2025 года; они не подтверждают другие годы или текущую индексацию. Аналитическая связь журнала со специальностью и отдельное рассмотрение статьи показаны в публикациях.</p>
|
||||
<p>«ЛК сотрудника ВШЭ» — количество публикаций из личного кабинета сотрудника за все годы. «Данные ВШЭ» — выгрузка из базы публикаций ВШЭ, которая дополняет сведения из личного кабинета. «Все публикации» объединяет оба источника без дублей за все годы и независимо от категории. При совпадении ФИО нескольких сотрудников данные ВШЭ не присваиваются автоматически. XLSX содержит всех найденных сотрудников и их публикации, включая другие страницы списка.</p>
|
||||
</details>
|
||||
|
||||
<form class="council__search" method="get" action="/admin/council" role="search">
|
||||
@@ -38,7 +42,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>
|
||||
@@ -64,21 +68,25 @@
|
||||
{% 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.db_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 }}</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 %}
|
||||
{% if candidate.ambiguous %}<p class="council__hint">Есть сотрудники с одинаковым ФИО. Публикации из данных ВШЭ не присвоены автоматически; показаны записи из личного кабинета этого сотрудника.</p>{% endif %}
|
||||
<ol class="council__articles">
|
||||
{% for article in candidate.articles %}
|
||||
<li class="council__article">
|
||||
@@ -91,10 +99,21 @@
|
||||
<span>{{ article.type_label }}</span>
|
||||
{% if article.doi_url %}<a class="admin__link" href="{{ article.doi_url }}" target="_blank" rel="noopener noreferrer">DOI</a>{% endif %}
|
||||
{% if article.url %}<a class="admin__link" href="{{ article.url }}" target="_blank" rel="noopener noreferrer">Публикация на сайте</a>{% endif %}
|
||||
{% if article.rows %}<span>Строки CSV: {{ article.rows|join(', ') }}</span>{% endif %}
|
||||
{% if article.rows %}<span>Строки выгрузки ВШЭ: {{ 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 %}
|
||||
@@ -112,8 +131,8 @@
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="council__message">
|
||||
<h4>{% if q or specialty %}Сотрудники не найдены{% else %}Сотрудники с докторской степенью и публикациями пока не найдены{% endif %}</h4>
|
||||
<p>{% if q or specialty %}Попробуйте другую фамилию или специальность. Без названия журнала, года или подтверждённого соответствия журналу К1 публикация не подтверждает специальность.{% else %}Для включения в список нужны докторская степень в профиле и хотя бы одна публикация, однозначно связанная с сотрудником.{% endif %}</p>
|
||||
<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="?specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.15.4"
|
||||
FRONTEND_VERSION = "0.15.4"
|
||||
BACKEND_VERSION = "0.15.4"
|
||||
APP_VERSION = "0.16.5"
|
||||
FRONTEND_VERSION = "0.16.5"
|
||||
BACKEND_VERSION = "0.16.5"
|
||||
@@ -0,0 +1,78 @@
|
||||
# Подтверждения публикационных критериев совета
|
||||
|
||||
Страница и XLSX читают один результат сервиса. Правила и ограничения исходного материала описаны в `app/data/COUNCIL_CRITERIA.md`, каталогов — в `app/data/Q1_Q2_CATALOG_GUIDE.md`. Публичная страница и XLSX настроены на **технические науки**: для доктора — 9 публикаций, для кандидата/PhD — 20. Пятилетний период рассчитывается относительно текущей даты Москвы. Дата ходатайства, монографии и связанные причины проверки не отображаются; подтверждение степени, вида, периода и основания работ по-прежнему требуется. Внутренний сервис сохраняет правила других отраслей для проверки исходной инструкции.
|
||||
|
||||
По умолчанию `app/data/council_evidence.json` содержит пустой набор подтверждений. Это намеренно: профиль, данные ВШЭ и текущие списки журналов не заменяют документы. Без достаточных подтверждений вывод — «Требует проверки». Источники считаются неполными; отрицательное заключение из малого количества найденных работ не формируется.
|
||||
|
||||
## Хранение и привязка
|
||||
|
||||
Файл предназначен для проверенных администратором записей и не обслуживается как публичный файл. Он отделён от `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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.15.4"
|
||||
version = "0.16.5"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -57,8 +58,8 @@ def main():
|
||||
|
||||
original = council._articles_by_author
|
||||
council._articles_by_author = lambda: {"сотрудник 26": [
|
||||
{"title": f"Публикация из CSV {number}", "year": 2024, "doi": f"10.1234/test-{number}",
|
||||
"types": [], "journals": [], "quartiles": ["Scopus Q1"], "origins": ["Файл CSV"], "rows": [number + 2],
|
||||
{"title": f"Публикация из данных ВШЭ {number}", "year": 2024, "doi": f"10.1234/test-{number}",
|
||||
"types": [], "journals": [], "quartiles": ["Scopus Q1"], "origins": ["Данные ВШЭ"], "rows": [number + 2],
|
||||
"in_csv": True, "in_db": False, "url": None} for number in range(2)]}
|
||||
app.dependency_overrides[get_db] = database
|
||||
client = TestClient(app)
|
||||
@@ -81,7 +82,10 @@ 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()
|
||||
expect(page.locator("#council-date")).to_have_count(0)
|
||||
assert "Монографии" not in page.content()
|
||||
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_role("status")).to_have_text("Показаны 26–28 из 28")
|
||||
@@ -89,7 +93,7 @@ def main():
|
||||
summary = page.locator(".council__candidate-summary").first
|
||||
summary.focus()
|
||||
page.keyboard.press("Enter")
|
||||
expect(page.get_by_text("БД miem-workers · сайт ВШЭ").first).to_be_visible()
|
||||
expect(page.locator(".council__source").filter(has_text="ЛК сотрудника ВШЭ").first).to_be_visible()
|
||||
expect(page.get_by_text("Q1/Q2 и К1 не подтверждены доступными данными").first).to_be_visible()
|
||||
assert page.locator(".council__candidate").first.evaluate("e => e.open")
|
||||
page.screenshot(path=str(output / "desktop.png"), full_page=True)
|
||||
@@ -161,6 +165,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
@@ -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.5"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
|
||||
+21
-18
@@ -72,20 +72,20 @@ def test_council_union_counts_provenance_and_types(db_session, export):
|
||||
result = council.council_candidates(db_session, date(2026, 9, 30))
|
||||
assert result["total"] == 1
|
||||
candidate = result["candidates"][0]
|
||||
assert (candidate["total_count"], candidate["db_count"], candidate["count"]) == (3, 3, 5)
|
||||
assert (candidate["total_count"], candidate["db_count"], candidate["count"]) == (3, 2, 5)
|
||||
shared = next(a for a in candidate["articles"] if a["doi"] == "10.1234/shared")
|
||||
assert shared["type_label"] == "Статья"
|
||||
assert shared["quartiles"] == ["Scopus Q1", "Web of Science Q2"]
|
||||
assert shared["rows"] == [2, 3]
|
||||
assert "Файл CSV" in shared["origins"] and "БД miem-workers · сайт ВШЭ" in shared["origins"]
|
||||
assert "Данные ВШЭ" in shared["origins"] and "ЛК сотрудника ВШЭ" in shared["origins"]
|
||||
imported = next(a for a in candidate["articles"] if a["title"] == "Imported")
|
||||
assert imported["origins"] == ["БД miem-workers · импорт CSV"]
|
||||
assert imported["origins"] == ["Данные ВШЭ"]
|
||||
assert imported["qualifies"] and imported["q2"]
|
||||
assert "Тип не указан" in imported["type_label"]
|
||||
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 "<script>" 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.5 · Frontend 0.16.5" 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,19 +261,19 @@ 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]] == [
|
||||
"№", "ФИО", "Учёная степень", "Q1", "Q2", "К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 works["L1"].value == "Страница ВШЭ"
|
||||
assert people.max_column == 23
|
||||
assert works["L1"].value == "ЛК сотрудника ВШЭ"
|
||||
assert people.max_row == 29 and works.max_row == 29
|
||||
assert people["B2"].value == "Петров 27" and people["B29"].value == "Петров 00"
|
||||
assert [people.cell(2, n).value for n in [4, 5, 6, 7, 8, 9]] == [1, 1, 0, 1, 1, 1]
|
||||
assert works["C2"].value == "Shared work" and works["E2"].value == "Статья"
|
||||
assert "Файл CSV" in works["F2"].value and "сайт ВШЭ" in works["F2"].value
|
||||
assert "Данные ВШЭ" in works["F2"].value and "ЛК сотрудника ВШЭ" in works["F2"].value
|
||||
assert "Scopus Q1" in works["G2"].value and "Web of Science Q2" in works["G2"].value
|
||||
assert works["M2"].value == "https://doi.org/10.1234/shared" and works["O2"].value == "2, 3"
|
||||
assert people["D2"].data_type == "n" and people.freeze_panes == "A2"
|
||||
@@ -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'W{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):
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import itertools
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
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, today=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, monkeypatch):
|
||||
class Clock:
|
||||
@staticmethod
|
||||
def now(zone):
|
||||
return datetime(2026, 10, 8, tzinfo=zone)
|
||||
monkeypatch.setattr(council, "datetime", Clock)
|
||||
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 ["specialty=oops"]:
|
||||
assert client.get("/admin/council?" + query).status_code == 422
|
||||
assert client.get("/admin/council/export.xlsx?" + query).status_code == 422
|
||||
query = "?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["Отрасль оценки"] == "технические"
|
||||
assert not any("ходатайств" in str(c.value).lower() or "монограф" in str(c.value).lower() for sheet in book for cells in sheet for c in cells)
|
||||
assert "council-date" not in page.text and "Монографии" not in page.text
|
||||
assert "Дата ходатайства" not in page.text
|
||||
legacy = client.get("/admin/council/export.xlsx?petition_date=2000-01-01&branch=экономические")
|
||||
legacy_book = load_workbook(BytesIO(legacy.content))
|
||||
assert legacy_book["Сотрудники"]["Q2"].value == "технические"
|
||||
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_technical_assessment_uses_current_period_without_petition(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"] == value["minimum"] == 9
|
||||
assert value["assessment_status"].startswith("Соответствует")
|
||||
assert 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()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("basis,flag", [("К1", "k1"), ("К2", "k2"), ("RSCI", "rsci"), ("Q1", "q1"), ("Q2", "q2")])
|
||||
@pytest.mark.parametrize("kind", ["BOOK", None])
|
||||
@pytest.mark.parametrize("admitted", [False, True])
|
||||
def test_manual_monograph_bases_respect_preliminary_type_policy(db_session, proofs, basis, flag, kind, admitted):
|
||||
employee = person(db_session, ["Доктор технических наук"])
|
||||
record = work(db_session, employee, kind=kind, basis=basis)
|
||||
record["kind"] = "monograph"
|
||||
policy = {"publication_types": {**SOURCE, "values": ["monograph"]}} if admitted else {}
|
||||
proofs(publications=[record], policy=policy)
|
||||
full_result = council.council_candidates(db_session, today=date(2026, 10, 8), branch="технические")
|
||||
candidate = full_result["candidates"][0]
|
||||
assert candidate[f"{flag}_count"] == candidate["total_count"] == int(admitted)
|
||||
# The policy permits a type; it does not itself confirm a quartile.
|
||||
assert candidate["confirmed_count"] == (int(admitted) if basis not in {"Q1", "Q2"} else 0)
|
||||
book = load_workbook(BytesIO(council_xlsx(full_result)))
|
||||
headers = [cell.value for cell in book["Сотрудники"][1]]
|
||||
exported = dict(zip(headers, [cell.value for cell in book["Сотрудники"][2]]))
|
||||
assert exported["ВСЕГО"] == int(admitted)
|
||||
assert exported[{"К1": "К1", "К2": "К2 предварительно", "RSCI": "RSCI предварительно", "Q1": "Q1", "Q2": "Q2"}[basis]] == int(admitted)
|
||||
|
||||
|
||||
def test_admitted_type_needs_sourced_policy_and_consistent_material(db_session, proofs):
|
||||
employee = person(db_session, ["Доктор технических наук"])
|
||||
record = work(db_session, employee, kind="BOOK")
|
||||
record["kind"] = "monograph"
|
||||
proofs(publications=[record], policy={"publication_types": {"values": ["monograph"]}})
|
||||
assert result(db_session)["total_count"] == 0
|
||||
record["kind"] = "article"
|
||||
proofs(publications=[record])
|
||||
assert result(db_session)["total_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("broken_index", [0, 1])
|
||||
def test_valid_snapshot_keeps_preliminary_metrics_when_other_is_broken(db_session, proofs, monkeypatch, tmp_path, broken_index):
|
||||
from app.services import council_catalog as catalogs
|
||||
paths = list(catalogs.CATALOGS)
|
||||
valid_path = paths[1 - broken_index]
|
||||
valid = json.loads(valid_path.read_text(encoding="utf8"))
|
||||
journal = valid["journals"][0]
|
||||
broken = tmp_path / "broken" / "catalog.json"
|
||||
broken.parent.mkdir()
|
||||
broken.write_text("{}", encoding="utf8")
|
||||
paths[broken_index] = broken
|
||||
monkeypatch.setattr(catalogs, "CATALOGS", tuple(paths))
|
||||
catalogs.catalog.cache_clear()
|
||||
try:
|
||||
employee = person(db_session, ["Доктор технических наук"])
|
||||
db_session.add(EmployeePublication(employee_id=employee.id, title="Snapshot work", year=2025,
|
||||
publication_type="ARTICLE", source_hash="snapshot", raw_data={"journal": journal["name"], "issn": journal["issn"]}))
|
||||
db_session.commit()
|
||||
full_result = council.council_candidates(db_session, today=date(2026, 10, 8), branch="технические")
|
||||
candidate = full_result["candidates"][0]
|
||||
article = candidate["articles"][0]
|
||||
flag = "q2" if broken_index == 0 else "q1"
|
||||
assert candidate[f"{flag}_count"] == candidate["total_count"] == 1
|
||||
assert candidate["confirmed_count"] == 0
|
||||
assert full_result["catalog_issues"] and not article["catalog_issues"]
|
||||
assert {r["catalog"] for r in article["catalog_records"]} == {valid_path.parent.name}
|
||||
book = load_workbook(BytesIO(council_xlsx(full_result)))
|
||||
assert book["Сотрудники"].cell(2, 5 if flag == "q2" else 4).value == 1
|
||||
assert "целостность каталога" in " ".join(str(cell.value) for row in book["Параметры"] for cell in row)
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
response = TestClient(app).get("/admin/council")
|
||||
assert response.status_code == 200 and "целостность каталога" in response.text
|
||||
assert journal["name"] in response.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
finally:
|
||||
catalogs.catalog.cache_clear()
|
||||
@@ -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"]
|
||||
@@ -44,7 +44,7 @@ def test_prepared_reference_preserves_quoted_fields_metadata_and_matching(tmp_pa
|
||||
assert articles['елкин петр'][0]['rows'] == [2]
|
||||
assert articles['другой автор'][0]['title'] == record['title']
|
||||
assert len(articles['елкин петр']) == 1
|
||||
assert articles['елкин петр'][0]['origins'] == ['Файл CSV']
|
||||
assert articles['елкин петр'][0]['origins'] == ['Данные ВШЭ']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change', ['author', 'index', 'bool_index', 'year', 'schema', 'source_row', 'list'])
|
||||
@@ -69,7 +69,7 @@ def test_committed_reference_is_reproducible_and_loaded_once(monkeypatch):
|
||||
source = council.EXPORT.with_suffix('.csv')
|
||||
data = json.loads(council.EXPORT.read_text(encoding='utf-8'))
|
||||
assert data == prepare_reference(source)
|
||||
assert data['source_rows'] == 1797 and len(data['by_author']) == 825
|
||||
assert data['source_rows'] == 1000 and len(data['by_author']) == 776
|
||||
calls = []
|
||||
original = council.load_reference
|
||||
def counted(path):
|
||||
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user