Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
194ed30dd4 | ||
|
|
7a51521abf | ||
|
|
5cc7a2dc52 | ||
|
|
b777bb9e16 | ||
|
|
2484bad0fa | ||
|
|
33f5a61865 | ||
|
|
c548998522 | ||
|
|
2e48292a32 | ||
|
|
ec9a03fcf2 | ||
|
|
a3f4a5b321 | ||
|
|
fba963e110 | ||
|
|
24d07706e1 | ||
|
|
44fadd7289 | ||
|
|
f851c62b8a | ||
|
|
86f0ee1137 | ||
|
|
00c818e681 | ||
|
|
76bcb0db35 | ||
|
|
74f32a3e67 | ||
|
|
dffe1afb14 | ||
|
|
9f5e508b47 | ||
|
|
881aba6b35 | ||
|
|
675a3b5c6d | ||
|
|
7eaed9aa8a | ||
|
|
d92338d252 | ||
|
|
64618354be | ||
|
|
ff11da2a56 | ||
|
|
6bcef05d27 | ||
|
|
2bfed87f98 | ||
|
|
8ab36a7f52 | ||
|
|
784dd68a3b | ||
|
|
a56fb723dd | ||
|
|
5bda837ee2 | ||
|
|
9324639c58 | ||
|
|
2a99ff4df2 | ||
|
|
30ea1c9257 | ||
|
|
0af278247a | ||
|
|
2dc63098e4 | ||
|
|
339dd71f63 | ||
|
|
4aeb26abe9 | ||
|
|
6bd1f31f3a |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
app/data/council_articles.csv text eol=lf
|
||||
app/data/council_articles.json text eol=lf
|
||||
app/data/k1_journals.json text eol=lf
|
||||
app/data/k1_specialties.json text eol=lf
|
||||
@@ -1,5 +1,89 @@
|
||||
# Changelog
|
||||
|
||||
## 0.18.1
|
||||
|
||||
- Проверка К1/К2 учитывает ISSN/eISSN исходной записи вместе с идентификаторами DOI, в том числе без сетевого поиска. Противоречие названия и ISSN не превращается в предварительное совпадение.
|
||||
- Квартильные основания привязаны к собственному DOI и журналу. После изменения публикации парсером старые основания сохраняются, но исключаются из счётчиков при конфликте; обновление общего результата поиска не перепривязывает старые записи.
|
||||
|
||||
## 0.18.0
|
||||
|
||||
- Добавлено поле `vak_categories` с результатом проверки журнала по предоставленным спискам К1/К2, источником и способом сопоставления. Совпадение по названию отмечается отдельно от подтверждённого ISSN; текущий снимок не объявляется исторической категорией.
|
||||
- Инструмент обогащения сохраняет все доступные квартильные категории всех систем, включая дополнительные сведения для публикаций с уже известным квартилем.
|
||||
|
||||
## 0.17.0
|
||||
|
||||
- Добавлено отдельное поле публикации `quartiles` с квартилями Q1–Q4, системой, годом метрики и источником. Данные ВШЭ переносятся без удаления исходных значений; новые сведения сопоставляются по DOI и ISSN.
|
||||
- Добавлена возобновляемая проверка DOI и обогащение по квартильным каталогам: при отсутствии исторической метрики можно явно разрешить последний доступный год. Недоступные сведения и конфликты фиксируются отдельно; текущий квартиль не подменяет исторический зачёт в совете.
|
||||
- Состав полей публикации, миграция и порядок обогащения описаны в документации.
|
||||
|
||||
## 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; журналы и связи со специальностями не изменены.
|
||||
|
||||
## 0.15.3
|
||||
|
||||
- Исправлена воспроизводимость справочника публикаций на Windows и Linux: при подготовке CSV окончания CRLF нормализуются в LF, JSON записывается с LF, а SHA-256 совпадает с CSV из Git. Исходный CSV не перезаписывается; проверка воспроизводимости сохранена и дополнена тестом обоих форматов строк.
|
||||
|
||||
## 0.15.2
|
||||
|
||||
- Портал использует подготовленный читаемый JSON из 1797 публикаций и индекс 825 ФИО. CSV сохранён для проверки и импорта; преобразование выполняется отдельным атомарным скриптом, а готовые данные кэшируются на процесс. Номера строк, источники и правила сопоставления сохранены.
|
||||
- При подготовке кандидатов в диссертационный совет докторская степень проверяется до разбора публикаций сотрудника, исключая повторную обработку данных неподходящих сотрудников.
|
||||
|
||||
## 0.15.1
|
||||
|
||||
- В XLSX диссертационного совета добавлены отдельные колонки с кодами и названиями подтверждённых специальностей ВАК для сотрудников и публикаций, включая выгрузку без фильтра. При выборе специальности показаны число подходящих работ сотрудника и отметка соответствия каждой публикации.
|
||||
|
||||
## 0.15.0
|
||||
|
||||
- На странице «Диссертационный совет» добавлен подбор докторов наук по 12 специальностям ВАК: учитывается хотя бы одна публикация за последние пять лет в релевантном журнале К1. Показаны основания соответствия и количество подходящих работ; поиск, пагинация и XLSX сохраняют выбранную специальность.
|
||||
- Добавлен справочник связей всех 773 журналов К1 со специальностями из Перечня ВАК на 15.09.2026, с ISSN, страницами, строками источника и датами назначений. Исключены истёкшие назначения; одинаковое название «Нефтегазовое дело» для двух ISSN помечено как неоднозначное и не используется в автоматическом подборе.
|
||||
- Добавлен воспроизводимый скрипт извлечения и проверенные варианты названий. Сервис читает готовый JSON без обработки PDF при запросах.
|
||||
|
||||
## 0.14.3
|
||||
|
||||
- Из XLSX диссертационного совета удалён столбец «Статус». Колонка «В БД» переименована в «Страница ВШЭ» на листах «Сотрудники» и «Публикации»; расчёт значений сохранён.
|
||||
|
||||
## 0.14.2
|
||||
|
||||
- Исправлено сопоставление К1 для переименованного журнала: действующее и явно указанное в исходном PDF прежнее название распознаются как один журнал. Административное примечание хранится отдельно от названия; конфликтующие алиасы не присваиваются автоматически.
|
||||
|
||||
## 0.14.1
|
||||
|
||||
- В списке кандидатов в диссертационный совет и XLSX заменён «CSV по критериям» на Q1, Q2, К1 и ВСЕГО за последние пять лет; ВСЕГО учитывает объединение категорий без дублей.
|
||||
- Добавлен извлечённый справочник 773 журналов К1 из предоставленного PDF; PDF не читается при запросах. Названия журналов берутся из данных ВШЭ или необязательного поля CSV.
|
||||
- Сортировка поддерживает новые счётчики. При расхождении годов источников критерии проверяются по году каждой записи, независимо от их порядка; расхождение отмечено в перечне публикаций.
|
||||
|
||||
## 0.8.15
|
||||
|
||||
- Исправлен production deploy: cron installer запускается через `sh` и не требует executable-бита.
|
||||
|
||||
@@ -88,12 +88,22 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
|
||||
|
||||
## Кандидаты в диссертационный совет
|
||||
|
||||
Страница `/admin/council` доступна без авторизации. Она показывает сотрудников с докторской степенью в профиле и хотя бы одной публикацией в CSV или таблице `employee_publications`. Порог количества работ не применяется; сотрудники без степени, с кандидатской степенью или только PhD в список не включаются; уволенные сотрудники отмечены отдельно. Есть поиск по ФИО, сортировка по ФИО и каждому из трёх счётчиков публикаций в обоих направлениях и страницы по 25 сотрудников. Сортировка применяется ко всему списку и сохраняется при поиске и переходе между страницами. У каждого сотрудника видны степень, число уникальных работ из CSV с Q1/Q2 за скользящие пять лет, число уникальных работ в БД и общий перечень без дублей с источниками, типами и подтверждёнными критериями.
|
||||
Страница `/admin/council` доступна без авторизации. Показывает докторов, кандидатов наук и PhD, включая сотрудников без доступных публикаций; уволенные отмечены отдельно. Высшая степень выбирается по всей истории: доктор выше кандидата/PhD, кандидат и PhD имеют одинаковый уровень. Неподтверждённого доктора нельзя заменить подтверждённым кандидатом. Звание профессора или доцента не заменяет степень.
|
||||
|
||||
CSV `app/data/council_articles.csv` сопоставляется по полному ФИО только при единственном совпадении. Публикации объединяются по DOI, затем по нормализованным названию и году, если нет конфликтующих DOI. Совпадение в двух источниках сохраняет оба источника, но учитывается один раз в общем количестве. Импорт CSV в БД не выдаётся за данные сайта. CSV не содержит типа материала, точной даты и подтверждения К1/RSCI: неизвестные сведения обозначены явно; счётчик CSV предварительный. Публичная страница не открывает контакты, служебные данные или операции админки.
|
||||
Страница и XLSX настроены на технические науки: пункт 5 — 9 публикаций для доктора; пункт 6 — 20 для кандидата/PhD, самостоятельного К2 недостаточно. Период — пять лет относительно текущей даты Москвы. Дата ходатайства и сведения о монографиях не отображаются и не препятствуют итогу по техническим наукам. Некорректную специальность отклоняет HTTP 422.
|
||||
|
||||
Отдельно показаны предварительные Q1/Q2/К1/К2/RSCI, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет.
|
||||
|
||||
Сервис читает подготовленный `app/data/council_articles.json` и таблицу `employee_publications`. Справочник преобразуется из CSV скриптом `scripts/prepare_council_articles.py`, проверяет схему и индекс авторов; импорт/парсинг CSV не объявляется доказательством выполнения критериев. Сопоставление данных ВШЭ по полному ФИО разрешено только при единственном совпадении. Дедупликация использует DOI либо нормализованные название и год при отсутствии неоднозначных DOI. Источники с разными годами не переносят категории между периодами.
|
||||
|
||||
К1/К2 сопоставляются по точным нормализованным названиям журнала и документированным алиасам; название статьи, переводы и нечёткое совпадение не используются. Справочник К1 содержит 773 журнала, К2 — 1539; экстрактор проверяет все 3134 строки, даты и SHA-256 источника. Это текущие категории, а не историческое доказательство. Q1/Q2 читаются также из двух тематических JSON-каталогов (схемы 1.0.0/1.1.0, 229 уникальных журналов): ISSN/eISSN, категории CiteScore/JCR, источники и версии паспортов. Снимок метрик 2025 не распространяется автоматически на другие годы; текущая индексация не проверена. Каталоги не являются белым списком. Аналитические прямые/условные связи журнала показываются отдельно от рассмотрения статьи по паспорту.
|
||||
|
||||
Подтверждения хранятся отдельно от перезаписываемых парсером данных, в `app/data/council_evidence.json`; по умолчанию файл пустой. Формат, привязка к сотруднику/работе, правила конфликта, монографии и нормативные уточнения: [docs/council-evidence.md](docs/council-evidence.md). Непубличные документы и контакты на страницу не выводятся. РИНЦ или поле `hse_list` не подтверждают RSCI. Неизвестные виды, пограничные даты и неподтверждённые основания не создают положительного заключения.
|
||||
|
||||
Доступны поиск, сортировка, страницы по 25 сотрудников и XLSX всех найденных сотрудников. Дата, отрасль и остальные параметры сохраняются при переходах и экспорте. Листы «Сотрудники», «Публикации» и «Параметры» используют тот же расчёт, включают причины проверки и источники; внешние ссылки проверяются, строки не выполняются как формулы Excel. Фильтр специальности по журналам К1 остаётся отдельным вспомогательным подбором с ограниченным охватом, а не условием соответствия пунктам 5–6.
|
||||
|
||||
После изменения JSON/PDF пересоберите образ и пересоздайте API: данные включены в образ и кешируются в процессе.
|
||||
|
||||
Список **предварительный**: в CSV указаны год и квартиль Scopus/WoS, но нет точной даты, типа материала, журнала, источника квартиля, подтверждения К1 и RSCI. На границах периода год не позволяет проверить точную дату. Статьи с Q1/Q2 учитываются с пометкой источника; отсутствие кандидата не доказывает недостаточную публикационную активность. Для подтверждённого отбора нужны проверка журналов и дат и дополнительные источники публикаций. Выгрузка поставляется вместе с релизом и обновляется отдельным изменением репозитория.
|
||||
|
||||
## Парсинг
|
||||
|
||||
Worker запускает обход по `CRAWL_CRON`. Ручной запуск также доступен в админке на `Dashboard` и странице `Runs` или через REST:
|
||||
@@ -145,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.10.6`. Админка всегда показывает версии backend и frontend в footer.
|
||||
Версия сервиса: `0.18.1`. Админка всегда показывает версии backend и frontend в footer.
|
||||
|
||||
## Разовый импорт публикаций
|
||||
|
||||
@@ -154,3 +164,89 @@ node --check app/static/admin.js
|
||||
Интеграционная проверка импорта на отдельном PostgreSQL: [docs/postgresql-import-verification.md](docs/postgresql-import-verification.md).
|
||||
|
||||
Отдельный тестовый стенд на VPS и read-only проверка ресурсов: [docs/postgresql-import-vps.md](docs/postgresql-import-vps.md). Стенд запускает только владелец после оценки ресурсов.
|
||||
|
||||
### Подбор по специальностям ВАК
|
||||
|
||||
На странице `/admin/council` доступны 12 специальностей. При выборе специальности
|
||||
показаны доктора наук с хотя бы одной учитываемой за последние пять лет публикацией
|
||||
в подходящем журнале К1. Публикации без года или названия журнала и материалы с
|
||||
известным типом, отличным от статьи, не подтверждают специальность. Счётчики Q1/Q2/К1
|
||||
сохраняют прежний смысл; число работ по выбранной специальности указано внутри
|
||||
карточки сотрудника. Фильтр сохраняется при поиске, смене страницы и экспорте XLSX.
|
||||
На листах «Сотрудники» и «Публикации» XLSX отдельная колонка содержит коды и названия
|
||||
подтверждённых специальностей, включая выгрузку без фильтра. При выбранном фильтре
|
||||
добавлены число подходящих работ сотрудника и отметка «Да/Нет» для каждой публикации.
|
||||
|
||||
`app/data/k1_specialties.json` содержит все 773 журнала из `k1_journals.json`, коды
|
||||
действующих специальностей, ISSN, страницы и строки исходного перечня, историю
|
||||
назначений и SHA-256 источников. Специальности взяты из «ВАК Перечень.pdf» на
|
||||
15.09.2026; категория К1 — из отдельного предоставленного справочника. Это связь по
|
||||
журналу, без оценки содержания статьи или исторической специальности на дату её
|
||||
публикации. Одинаковое название «Нефтегазовое дело» относится к двум ISSN: связь
|
||||
помечена как неоднозначная и не используется для автоматического подбора.
|
||||
Неполные даты начала из исходного PDF сохранены без восстановления даты; текущее
|
||||
назначение определяется по отсутствию истёкшего срока окончания в снимке перечня.
|
||||
|
||||
Для воспроизводимого обновления нужен `pdfplumber` только в среде разработки:
|
||||
|
||||
```sh
|
||||
python scripts/extract_k1_specialties.py "app/data/ВАК Перечень.pdf"
|
||||
```
|
||||
|
||||
Скрипт проверяет последовательность всех 3231 строк. Проверенные различия названий
|
||||
сохранены в `scripts/k1_specialty_matches.json`; каждое сопоставление проверяется по
|
||||
номеру строки и точному исходному названию. Нечёткий поиск не применяется.
|
||||
Сервис читает готовый JSON и не требует PDF или `pdfplumber` при запуске. Хеш
|
||||
`k1_source_sha256` рассчитывается для исходного JSON с окончаниями строк LF; оба
|
||||
JSON-справочника записываются с LF для воспроизводимости на Windows и Linux.
|
||||
|
||||
### Квартили публикаций и схема БД
|
||||
|
||||
Полный состав полей `employee_publications`, структура отдельного поля `quartiles`, ограничения сопоставления и команды обогащения описаны в [docs/publication-database.md](docs/publication-database.md). Для существующей БД добавлены nullable JSONB-поля миграциями `010_publication_quartiles.sql` и `011_publication_vak_categories.sql`; данные переносятся отдельным инструментом, а не при старте приложения.
|
||||
|
||||
Квартили Q1–Q4 из данных ВШЭ сохраняются с исходной системой; неизвестный год метрики остаётся неизвестным. Для остальных публикаций журнал устанавливается по DOI, рейтинг — по ISSN. Исторические SJR доступны через открытые данные университета Эстремадуры за 2009–2025; CiteScore/JCR читаются из проверенных каталогов. При отсутствии метрики за год публикации допускается явно разрешённый последний доступный год, с сохранением его года и источника. Отсутствие DOI, неоднозначность и недоступная метрика фиксируются без присвоения вымышленного квартиля. Парсер сохраняет новое поле при обновлении записи.
|
||||
|
||||
### Подготовка публикаций для портала
|
||||
|
||||
Портал читает `app/data/council_articles.json`: публикации представлены отдельными
|
||||
объектами с названием, годом, DOI, массивами авторов, квартилей и журналов.
|
||||
Дополнительные поля исходного экспорта сохранены в `metadata`; `source_row` связывает
|
||||
запись со строкой исходного CSV. `by_author` — готовый индекс нормализованных ФИО
|
||||
со ссылками на публикации, без копирования одной работы для каждого соавтора.
|
||||
|
||||
Исходный `app/data/council_articles.csv` сохранён для проверки и отдельного импорта
|
||||
в БД. Данные файла и индекс кэшируются на процесс, CSV не преобразуется при
|
||||
HTTP-запросах. В интерфейсе и XLSX используются названия «Данные ВШЭ» (выгрузка из базы публикаций ВШЭ) и «ЛК сотрудника ВШЭ» (публикации из личного кабинета). Номера строк выгрузки сохраняются для проверки источника. Показываются сотрудники с обнаруженной или подтверждённой степенью доктора, кандидата наук или PhD.
|
||||
|
||||
При сверке 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
|
||||
```
|
||||
|
||||
Нужна только стандартная библиотека Python. Скрипт проверяет структуру CSV и индекс,
|
||||
сохраняет число записей и SHA-256 источника с окончаниями строк LF и заменяет JSON
|
||||
атомарно после проверки. CRLF нормализуются в LF только при чтении; исходный CSV
|
||||
не перезаписывается. JSON и хеш одинаковы на Windows и Linux; Git фиксирует эти
|
||||
файлы с LF.
|
||||
После замены файла перезапустите процессы API, чтобы сбросить их кэш. Обновления
|
||||
публикаций из БД продолжают отображаться без перезапуска.
|
||||
+24
-4
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
@@ -19,7 +19,8 @@ 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
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
@@ -134,16 +135,35 @@ def directory(
|
||||
|
||||
|
||||
@router.get("/council", response_class=HTMLResponse)
|
||||
def council(request: Request, q: str = "", offset: int = 0, sort: str = "csv_count", direction: str = "desc",
|
||||
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)
|
||||
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)
|
||||
return _render(request, "council.html", {"error": False, "public_page": True, **result})
|
||||
|
||||
|
||||
@router.get("/council/export.xlsx")
|
||||
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,
|
||||
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)
|
||||
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="council.xlsx"'})
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
|
||||
def employee_detail(
|
||||
employee_id: int,
|
||||
|
||||
@@ -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
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
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.
@@ -37,6 +37,14 @@ def _ensure_runtime_schema() -> None:
|
||||
models.EmployeePublication.__table__.create(bind=engine, checkfirst=True)
|
||||
inspector = inspect(engine)
|
||||
table_names = set(inspector.get_table_names())
|
||||
if "employee_publications" in table_names:
|
||||
publication_columns = {column["name"] for column in inspector.get_columns("employee_publications")}
|
||||
for column in ("quartiles", "vak_categories"):
|
||||
if column not in publication_columns:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("ALTER TABLE employee_publications ADD COLUMN " +
|
||||
("IF NOT EXISTS " if engine.dialect.name == "postgresql" else "") +
|
||||
column + " " + ("JSONB" if engine.dialect.name == "postgresql" else "JSON")))
|
||||
if "employees" in table_names and "employee_news_links" not in table_names:
|
||||
models.EmployeeNewsLink.__table__.create(bind=engine, checkfirst=True)
|
||||
inspector = inspect(engine)
|
||||
|
||||
@@ -108,6 +108,8 @@ class EmployeePublication(Base):
|
||||
other_url: Mapped[str | None] = mapped_column(Text)
|
||||
document_url: Mapped[str | None] = mapped_column(Text)
|
||||
citation_text: Mapped[str | None] = mapped_column(Text)
|
||||
quartiles: Mapped[dict | None] = mapped_column(json_type)
|
||||
vak_categories: Mapped[dict | None] = mapped_column(json_type)
|
||||
annotation: Mapped[dict | None] = mapped_column(json_type)
|
||||
description: Mapped[dict | None] = mapped_column(json_type)
|
||||
authors: Mapped[list | None] = mapped_column(json_type)
|
||||
|
||||
+187
-64
@@ -1,9 +1,9 @@
|
||||
"""Public publication evidence from the council export and employee database."""
|
||||
|
||||
import calendar
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import date, datetime
|
||||
from functools import lru_cache
|
||||
@@ -16,23 +16,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, load_reference, _description_journals
|
||||
from app.services.council_catalog import catalog, publication_catalog
|
||||
from app.services.publication_quartiles import labels as quartile_labels, identity_issues, hse_entries, merge as merge_quartiles
|
||||
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.csv"
|
||||
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)
|
||||
DOI = re.compile(r"10\.\d{4,9}/\S+", re.IGNORECASE)
|
||||
TYPES = {"ARTICLE": "Статья", "BOOK": "Книга", "CHAPTER": "Глава книги",
|
||||
"BOOK_CHAPTER": "Глава книги", "REPORT": "Доклад", "PATENT": "Патент",
|
||||
"PREPRINT": "Препринт", "CONFERENCE_PAPER": "Материал конференции"}
|
||||
|
||||
|
||||
def _name(value: str) -> str:
|
||||
return " ".join(unicodedata.normalize("NFKC", value).casefold().replace("ё", "е").split())
|
||||
|
||||
|
||||
def _doi(value: str) -> str:
|
||||
value = re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", value.strip(), flags=re.I).lower()
|
||||
return value if DOI.fullmatch(value) else ""
|
||||
class CouncilInputError(ValueError):
|
||||
"""Invalid public assessment context, as opposed to damaged source data."""
|
||||
|
||||
|
||||
def _safe_url(value: str | None) -> str | None:
|
||||
@@ -50,51 +54,88 @@ def period(today: date) -> tuple[date, date]:
|
||||
return date(year, today.month, min(today.day, calendar.monthrange(year, today.month)[1])), today
|
||||
|
||||
|
||||
def _quartiles(row: dict) -> list[str]:
|
||||
return [f"{label} {str(row.get(field) or '').strip().upper()}"
|
||||
for label, field in (("Scopus", "Квартиль scopus"), ("Web of Science", "Квартиль wos"))
|
||||
if str(row.get(field) or '').strip().upper() in {"Q1", "Q2"}]
|
||||
def _journal_key(value: str) -> str:
|
||||
return " ".join(re.findall(r"\w+", _name(value)))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _k1_journals() -> dict[str, str]:
|
||||
data = json.loads(K1_JOURNALS.read_text(encoding="utf-8"))
|
||||
journals = {}
|
||||
for journal in data["journals"]:
|
||||
for name in [journal["name"], *journal.get("aliases", [])]:
|
||||
key = _journal_key(name)
|
||||
if key in journals and journals[key] != journal["name"]:
|
||||
raise ValueError("Ambiguous K1 journal alias")
|
||||
journals[key] = journal["name"]
|
||||
return journals
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _k1_specialties() -> dict:
|
||||
data = json.loads(K1_SPECIALTIES.read_text(encoding="utf-8"))
|
||||
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
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _articles_by_author() -> dict[str, list[dict]]:
|
||||
articles = defaultdict(list)
|
||||
with EXPORT.open(encoding="utf-8-sig", newline="") as source:
|
||||
reader = csv.DictReader(source, delimiter=";")
|
||||
required = {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"}
|
||||
if not required <= set(reader.fieldnames or []):
|
||||
raise ValueError("Missing council CSV headers")
|
||||
for row_number, row in enumerate(reader, 2):
|
||||
if None in row or any(value is None for value in row.values()):
|
||||
raise ValueError("Malformed council CSV record")
|
||||
title = row["Название публикации"].strip()
|
||||
if not title:
|
||||
continue
|
||||
year = int(row["Год"]) if row["Год"].strip().isdigit() else None
|
||||
article = {"title": title, "year": year, "doi": _doi(row["DOI"]),
|
||||
"types": [], "quartiles": _quartiles(row), "origins": ["Файл CSV"],
|
||||
"rows": [row_number], "in_csv": True, "in_db": False, "url": None}
|
||||
for author in set(_name(value) for value in row["Авторы НИУ ВШЭ"].split(";")) - {""}:
|
||||
articles[author].append(article)
|
||||
return articles
|
||||
|
||||
return load_reference(EXPORT)
|
||||
|
||||
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)
|
||||
if isinstance(description, dict):
|
||||
journals.extend(_description_journals(description))
|
||||
doi = _doi(publication.doi_url or "")
|
||||
identifiers = [raw[key] for key in ("issn", "eissn", "ISSN", "EISSN") if isinstance(raw.get(key), str)]
|
||||
current_journals = list(journals)
|
||||
imported_quartiles = merge_quartiles(None, hse_entries(raw, publication.year))
|
||||
issues = identity_issues(publication.quartiles, doi, current_journals, identifiers)
|
||||
imported_issues = identity_issues(imported_quartiles, doi, current_journals, identifiers)
|
||||
imported_doi = _doi(str(imported.get("DOI") or "")) if isinstance(imported, dict) else ""
|
||||
if isinstance(imported, dict) and not imported_issues and (not imported_doi or imported_doi == doi):
|
||||
journals.extend(_journal_names(imported))
|
||||
issues = list(dict.fromkeys([*issues, *imported_issues]))
|
||||
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 [],
|
||||
"origins": origins or ["БД miem-workers"], "rows": [], "in_csv": False, "in_db": True,
|
||||
"quartiles": list(dict.fromkeys([*quartile_labels(publication.quartiles, publication.year, doi, current_journals, identifiers),
|
||||
*quartile_labels(imported_quartiles, publication.year, doi, current_journals, identifiers)])),
|
||||
"quartile_issues": issues,
|
||||
"journals": journals, "issns": [raw[key] for key in ("issn", "eissn", "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)}
|
||||
|
||||
|
||||
def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]:
|
||||
k1_journals = _k1_journals()
|
||||
specialties = _k1_specialties()["by_journal"]
|
||||
title_years = defaultdict(set)
|
||||
for article in articles:
|
||||
if article["year"] is not None:
|
||||
@@ -118,74 +159,156 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
|
||||
key = identifier or fallback
|
||||
if key not in unique:
|
||||
unique[key] = {**article, "doi": identifier,
|
||||
**{field: list(article[field]) for field in ("types", "quartiles", "origins", "rows")},
|
||||
"csv_qualifies": False}
|
||||
**{field: list(article[field]) for field in ("types", "quartiles", "journals", "origins", "rows")},
|
||||
"evidence": [], "issns": [], "source_hashes": [], "quartile_issues": []}
|
||||
merged = unique[key]
|
||||
for field in ("types", "quartiles", "origins", "rows"):
|
||||
merged["evidence"].append(article)
|
||||
merged["quartile_issues"] = list(dict.fromkeys([*merged["quartile_issues"], *article.get("quartile_issues", [])]))
|
||||
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"]
|
||||
merged["in_db"] |= article["in_db"]
|
||||
merged["url"] = merged["url"] or article["url"]
|
||||
merged["year"] = merged["year"] or article["year"]
|
||||
in_period = article["year"] is not None and start.year <= article["year"] <= end.year
|
||||
merged["csv_qualifies"] |= bool(article["in_csv"] and article["quartiles"] and in_period)
|
||||
for article in unique.values():
|
||||
year = article["year"]
|
||||
article["criteria"] = list(article["quartiles"])
|
||||
if year is None:
|
||||
evidence = article.pop("evidence")
|
||||
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"] = [*article["quartiles"], *article["quartile_issues"]]
|
||||
matches = list(dict.fromkeys(k1_journals[key] for name in article["journals"]
|
||||
if (key := _journal_key(name)) in k1_journals))
|
||||
article["criteria"].extend(f"К1 · {name} (справочник ВАК)" for name in matches)
|
||||
eligible_type = not article["types"] or article["types"] == ["Статья"]
|
||||
article["q1"] = article["q2"] = article["k1"] = False
|
||||
article["specialty_codes"] = []
|
||||
for item in evidence:
|
||||
source_year = item["year"] if item["year"] is not None else year
|
||||
if not eligible_type or source_year is None or not start.year <= source_year <= end.year:
|
||||
continue
|
||||
article["q1"] |= any(q.endswith(" Q1") for q in item["quartiles"])
|
||||
article["q2"] |= any(q.endswith(" Q2") for q in item["quartiles"])
|
||||
item_journals = {k1_journals[key] for name in item["journals"]
|
||||
if (key := _journal_key(name)) in k1_journals}
|
||||
article["k1"] |= bool(item_journals)
|
||||
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))
|
||||
+ "; критерии учитываются по году каждой исходной записи")
|
||||
elif year is None:
|
||||
article["criteria"].append("Год не указан — период не подтверждён")
|
||||
elif start.year <= year <= end.year:
|
||||
article["criteria"].append("В пределах пяти лет по году; точную дату нужно проверить" if year in {start.year, end.year}
|
||||
else "В пределах пяти лет")
|
||||
else:
|
||||
article["criteria"].append("Вне пятилетнего периода")
|
||||
if not article["quartiles"]:
|
||||
article["criteria"].append("Q1/Q2, К1 и RSCI не подтверждены доступными данными")
|
||||
if not article["quartiles"] and not matches:
|
||||
article["criteria"].append("Q1/Q2 и К1 не подтверждены доступными данными")
|
||||
article["doi_url"] = f"https://doi.org/{quote(article['doi'], safe='/')}" if article["doi"] else None
|
||||
article["type_label"] = " / ".join(article["types"]) or "Тип не указан — статья не подтверждена"
|
||||
if article["types"] and article["types"] != ["Статья"]:
|
||||
article["csv_qualifies"] = False
|
||||
article["criteria"].append("Тип материала не подтверждён как статья — не включён в счётчик CSV")
|
||||
return sorted(unique.values(), key=lambda item: (not item["csv_qualifies"], -(item["year"] or 0), item["title"].casefold()))
|
||||
article["criteria"].append("Тип материала не подтверждён как статья — не включён в счётчики Q1/Q2/К1")
|
||||
return sorted(unique.values(), key=lambda item: (not item["qualifies"], -(item["year"] or 0), item["title"].casefold()))
|
||||
|
||||
|
||||
def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0,
|
||||
sort: str = "csv_count", direction: str = "desc") -> 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"]
|
||||
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 "")
|
||||
ambiguous = bool(author and name_counts[author] > 1 and by_author.get(author))
|
||||
csv_articles = by_author.get(author, []) if author and name_counts[author] == 1 else []
|
||||
articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end)
|
||||
if not articles:
|
||||
continue
|
||||
data = employee.current_data if isinstance(employee.current_data, dict) else {}
|
||||
try:
|
||||
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
|
||||
candidates.append({"name": employee.full_name or "ФИО не указано", "degree": "; ".join(degrees) or "Учёная степень не указана",
|
||||
"count": len(articles), "csv_count": sum(a["csv_qualifies"] for a in articles),
|
||||
articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end)
|
||||
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({**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", "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", "csv_count", "db_count", "count"} else "csv_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":
|
||||
candidates.sort(key=lambda item: (item[sort], item["count"]), reverse=direction == "desc")
|
||||
total = len(candidates)
|
||||
query = _name(q)
|
||||
candidates = [item for item in candidates if query in _name(item["name"])]
|
||||
candidates = [item for item in candidates if query in _name(item["name"])
|
||||
and (not specialty or item["specialty_count"] > 0)]
|
||||
matched = len(candidates)
|
||||
limit = 25
|
||||
offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0
|
||||
return {"candidates": candidates[offset:offset + limit], "start": start, "end": end, "total": total,
|
||||
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 = [*article.get("catalog_issues", []), *article.get("quartile_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
|
||||
@@ -0,0 +1,115 @@
|
||||
"""XLSX report containing the same public evidence as the council page."""
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
|
||||
def _append(sheet, values):
|
||||
values = [ILLEGAL_CHARACTERS_RE.sub("", value) if isinstance(value, str) else value for value in values]
|
||||
if any(isinstance(value, str) and len(value) > 32767 for value in values):
|
||||
raise ValueError("Text exceeds Excel cell limit")
|
||||
sheet.append(values)
|
||||
|
||||
|
||||
def council_xlsx(result: dict) -> bytes:
|
||||
workbook = Workbook()
|
||||
workbook.properties.creator = "MIEM Employees"
|
||||
employees = workbook.active
|
||||
employees.title = "Сотрудники"
|
||||
publications = workbook.create_sheet("Публикации")
|
||||
parameters = workbook.create_sheet("Параметры")
|
||||
specialty_labels = {item["code"]: f"{item['code']}. — {item['name']}" for item in result["specialties"]}
|
||||
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", "ВСЕГО", "ЛК сотрудника ВШЭ", "Все публикации",
|
||||
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности",
|
||||
"Текущая степень", "Степень подтверждена", "Пункт", "Отрасль оценки", "Минимум",
|
||||
"Подтверждено", "К2 предварительно", "RSCI предварительно", "Итог", "Причины проверки"])
|
||||
_append(publications, ["№ сотрудника", "ФИО", "Название публикации", "Год", "Тип материала", "Источники",
|
||||
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "ЛК сотрудника ВШЭ", "DOI", "Ссылка на сайте", "Строки выгрузки ВШЭ", "Специальности ВАК", "По выбранной специальности",
|
||||
"К2", "RSCI", "Подтверждённый зачёт", "Дата публикации", "Причины проверки", "Подтверждённые основания",
|
||||
"Журнал и идентификаторы", "Квартильные категории и источники", "Аналитическая связь журнала", "Рассмотрение статьи"])
|
||||
for number, candidate in enumerate(result["candidates"], 1):
|
||||
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["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[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", "k2_count": "К2", "rsci_count": "RSCI", "confirmed_count": "Подтверждено",
|
||||
"total_count": "ВСЕГО", "db_count": "ЛК сотрудника ВШЭ", "count": "Все публикации"}[result["sort"]]],
|
||||
["Направление", "По возрастанию" if result["direction"] == "asc" else "По убыванию"],
|
||||
["Сотрудников в выгрузке", result["matched"]], ["Всего сотрудников в списке", result["total"]],
|
||||
["Отбор по степени", "Высшая степень из истории: доктор выше кандидата/PhD; отсутствие подтверждения доктора не переводит оценку на пункт 6. Отсутствие публикаций не исключает сотрудника."],
|
||||
["Счётчики Q1/Q2/К1", "Уникальные публикации в пятилетнем периоде по году. Q1/Q2 — отметки Scopus или Web of Science. "
|
||||
"Для пограничных лет нужно проверить дату, для неизвестного типа — подтвердить, что это статья. "
|
||||
"Материалы с известным другим типом исключены. К1 — совпадение названия журнала с итоговой категорией "
|
||||
"в справочнике из ВАК категории.pdf; историческая категория не подтверждается. "
|
||||
"Без названия журнала К1 не подтверждён."],
|
||||
["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — специальности релевантного журнала К1, учитываемые за пять лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
|
||||
["Работ / По выбранной специальности", "При выбранном фильтре показаны число соответствующих публикаций сотрудника и отметка Да/Нет для каждой работы. Без фильтра ячейки пустые; список специальностей остаётся заполненным."],
|
||||
["ВСЕГО", "Предварительные уникальные работы Q1/Q2/К1/RSCI либо К2 для доктора; пересечения учитываются один раз."],
|
||||
["ЛК сотрудника ВШЭ / Все публикации", "Все годы и категории. ЛК сотрудника ВШЭ — публикации из личного кабинета. "
|
||||
"Все публикации объединяет записи личного кабинета и выгрузку из базы ВШЭ без дублей; источники указаны на листе Публикации."],
|
||||
]:
|
||||
_append(parameters, values)
|
||||
_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
|
||||
for column, width in enumerate(widths, 1):
|
||||
sheet.column_dimensions[get_column_letter(column)].width = width
|
||||
for row in sheet:
|
||||
for cell in row:
|
||||
if isinstance(cell.value, str):
|
||||
# CSV/profile content is text, never an Excel formula or error code.
|
||||
cell.data_type = "s"
|
||||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||
for cell in sheet[1]:
|
||||
cell.font = Font(bold=True, color="FFFFFF")
|
||||
cell.fill = PatternFill("solid", fgColor="263640")
|
||||
output = BytesIO()
|
||||
workbook.save(output)
|
||||
return output.getvalue()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Prepared council publication data and shared source normalization."""
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
from io import StringIO
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
DOI = re.compile(r"10\.\d{4,9}/\S+", re.IGNORECASE)
|
||||
|
||||
|
||||
def _name(value: str) -> str:
|
||||
return " ".join(unicodedata.normalize("NFKC", value).casefold().replace("ё", "е").split())
|
||||
|
||||
|
||||
def _doi(value: str) -> str:
|
||||
value = re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", value.strip(), flags=re.I).lower()
|
||||
return value if DOI.fullmatch(value) else ""
|
||||
|
||||
|
||||
def _quartiles(row: dict) -> list[str]:
|
||||
return [f"{label} {str(row.get(field) or '').strip().upper()}"
|
||||
for label, field in (("Scopus", "Квартиль scopus"), ("Web of Science", "Квартиль wos"))
|
||||
if str(row.get(field) or '').strip().upper() in {"Q1", "Q2"}]
|
||||
|
||||
|
||||
def _journal_names(data: dict) -> list[str]:
|
||||
names = []
|
||||
for field in ("Название журнала", "Журнал", "journal"):
|
||||
value = data.get(field)
|
||||
if isinstance(value, dict):
|
||||
value = value.get("name")
|
||||
if isinstance(value, str) and value.strip():
|
||||
names.append(value.strip())
|
||||
return names
|
||||
|
||||
|
||||
def _description_journals(description: dict) -> list[str]:
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
names = []
|
||||
for field in ("short", "shortLeft"):
|
||||
value = description.get(field)
|
||||
values = value.values() if isinstance(value, dict) else [value]
|
||||
for text in values:
|
||||
if isinstance(text, str):
|
||||
text = BeautifulSoup(text, "html.parser").get_text(" ", strip=True)
|
||||
# HSE short descriptions start with the journal, followed by the year.
|
||||
names.append(re.split(r"\b(?:19|20)\d{2}\b", text, maxsplit=1)[0].strip(" .,:;"))
|
||||
return names
|
||||
|
||||
|
||||
def prepare_reference(source: Path) -> dict:
|
||||
publications = []
|
||||
by_author = defaultdict(list)
|
||||
# Git stores this text export with LF; canonicalize Windows CRLF before
|
||||
# parsing and hashing so the prepared artifact is identical on both hosts.
|
||||
source_bytes = source.read_bytes().replace(b"\r\n", b"\n")
|
||||
with StringIO(source_bytes.decode("utf-8-sig"), newline="") as stream:
|
||||
reader = csv.DictReader(stream, delimiter=";")
|
||||
required = {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"}
|
||||
if not required <= set(reader.fieldnames or []):
|
||||
raise ValueError("Missing council CSV headers")
|
||||
if len(reader.fieldnames) != len(set(reader.fieldnames)):
|
||||
raise ValueError("Duplicate council CSV headers")
|
||||
for row_number, row in enumerate(reader, 2):
|
||||
if None in row or any(value is None for value in row.values()):
|
||||
raise ValueError("Malformed council CSV record")
|
||||
authors = list(dict.fromkeys(value.strip() for value in row["Авторы НИУ ВШЭ"].split(";") if value.strip()))
|
||||
record = {"source_row": row_number, "title": row["Название публикации"].strip(),
|
||||
"year": int(row["Год"]) if row["Год"].strip().isdigit() else None,
|
||||
"doi": _doi(row["DOI"]), "authors": authors, "quartiles": _quartiles(row),
|
||||
"journals": _journal_names(row),
|
||||
"metadata": {key: value for key, value in row.items()
|
||||
if key not in {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ"}}}
|
||||
index = len(publications)
|
||||
publications.append(record)
|
||||
if record["title"]:
|
||||
for author in sorted({_name(value) for value in authors} - {""}):
|
||||
by_author[author].append(index)
|
||||
return {"schema_version": 1, "source_file": source.name,
|
||||
"source_sha256": hashlib.sha256(source_bytes).hexdigest(), "source_hash_eol": "LF",
|
||||
"source_rows": len(publications), "publications": publications, "by_author": dict(by_author)}
|
||||
|
||||
|
||||
def load_reference(path: Path) -> dict[str, list[dict]]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict) or type(data.get("schema_version")) is not int or data.get("schema_version") != 1:
|
||||
raise ValueError("Unsupported council reference schema")
|
||||
publications = data.get("publications")
|
||||
by_author = data.get("by_author")
|
||||
if not isinstance(publications, list) or not isinstance(by_author, dict) or data.get("source_rows") != len(publications):
|
||||
raise ValueError("Invalid council reference records")
|
||||
articles = []
|
||||
expected = defaultdict(list)
|
||||
for index, record in enumerate(publications):
|
||||
if not isinstance(record, dict) or not isinstance(record.get("title"), str) or not isinstance(record.get("doi"), str):
|
||||
raise ValueError("Invalid council publication text")
|
||||
if record["doi"] != _doi(record["doi"]) or (record.get("year") is not None and type(record["year"]) is not int):
|
||||
raise ValueError("Invalid council publication DOI/year")
|
||||
if record.get("source_row") != index + 2:
|
||||
raise ValueError("Invalid council source row")
|
||||
for field in ("authors", "quartiles", "journals"):
|
||||
if not isinstance(record.get(field), list) or not all(isinstance(value, str) for value in record[field]):
|
||||
raise ValueError("Invalid council publication list")
|
||||
if not set(record["quartiles"]) <= {"Scopus Q1", "Scopus Q2", "Web of Science Q1", "Web of Science Q2"}:
|
||||
raise ValueError("Invalid council quartiles")
|
||||
if record["title"]:
|
||||
for author in sorted({_name(value) for value in record["authors"]} - {""}):
|
||||
expected[author].append(index)
|
||||
articles.append({"title": record["title"], "year": record["year"], "doi": record["doi"],
|
||||
"types": [], "quartiles": record["quartiles"], "journals": record["journals"],
|
||||
"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")
|
||||
return {author: [articles[index] for index in indexes] for author, indexes in by_author.items()}
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Publication quartiles with source, system and year kept separately."""
|
||||
|
||||
from datetime import date
|
||||
from collections import defaultdict
|
||||
import re
|
||||
|
||||
from app.services.council_catalog import publication_catalog, safe_url, issn
|
||||
from app.services.council_source import _name, _doi
|
||||
|
||||
|
||||
SYSTEM_FIELDS = {"Scopus": "Квартиль scopus", "Web of Science": "Квартиль wos"}
|
||||
QUARTILES = {"Q1", "Q2", "Q3", "Q4"}
|
||||
|
||||
|
||||
def entries(payload):
|
||||
if payload is None:
|
||||
return []
|
||||
if (not isinstance(payload, dict) or type(payload.get("schema_version")) is not int or payload["schema_version"] != 1
|
||||
or not isinstance(payload.get("entries"), list)
|
||||
or (payload.get("lookup") is not None and not isinstance(payload["lookup"], dict))
|
||||
or (isinstance(payload.get("lookup"), dict) and payload["lookup"].get("doi") is not None
|
||||
and not isinstance(payload["lookup"]["doi"], str))
|
||||
or not all(isinstance(item, dict) and item.get("quartile") in QUARTILES
|
||||
and item.get("system") in {*SYSTEM_FIELDS, "SCImago"}
|
||||
and (item.get("doi") is None or isinstance(item["doi"], str))
|
||||
and (item.get("journal") is None or isinstance(item["journal"], str))
|
||||
and isinstance(item.get("issns", []), list)
|
||||
and all(isinstance(value, str) for value in item.get("issns", []))
|
||||
for item in payload["entries"])):
|
||||
raise ValueError("Invalid publication quartiles")
|
||||
return payload["entries"]
|
||||
|
||||
|
||||
def hse_entries(raw, publication_year):
|
||||
metadata = raw.get("csv_enrichment", {}) if isinstance(raw, dict) else {}
|
||||
if not isinstance(metadata, dict):
|
||||
return []
|
||||
source_year = str(metadata.get("Год") or "").strip()
|
||||
applicable_year = int(source_year) if source_year.isdigit() else publication_year
|
||||
output = []
|
||||
for system, field in SYSTEM_FIELDS.items():
|
||||
quartile = str(metadata.get(field) or "").strip().upper()
|
||||
if quartile in QUARTILES:
|
||||
output.append({"system": system, "quartile": quartile, "metric": None,
|
||||
"doi": _doi(str(metadata.get("DOI") or "")) or None,
|
||||
"metric_year": None, "publication_year": applicable_year,
|
||||
"category": None, "journal": metadata.get("Название журнала") or None,
|
||||
"issns": [], "source": "Данные ВШЭ", "source_url": None,
|
||||
"checked_on": date.today().isoformat()})
|
||||
return output
|
||||
|
||||
|
||||
def catalog_entries(doi_metadata, publication_year, allow_latest=False):
|
||||
"""Only a journal identified by DOI/ISSN and the exact requested metric year."""
|
||||
if doi_metadata.get("type") != "journal-article":
|
||||
return [], "not_journal_article"
|
||||
if publication_year is None:
|
||||
return [], "missing_publication_year"
|
||||
if publication_year not in doi_metadata.get("years", []):
|
||||
return [], "publication_year_conflict"
|
||||
if not doi_metadata.get("issns"):
|
||||
return [], "missing_journal_issn"
|
||||
records, issues = publication_catalog(doi_metadata["issns"], [])
|
||||
if issues:
|
||||
return [], "journal_identity_conflict"
|
||||
metrics = [(record, metric) for record in records for metric in record["metrics"]]
|
||||
selected_years = {}
|
||||
for system in SYSTEM_FIELDS:
|
||||
years = {metric["year"] for _, metric in metrics if metric["system"] == system}
|
||||
selected_years[system] = publication_year if publication_year in years else max(years, default=None) if allow_latest else None
|
||||
output = []
|
||||
for record in records:
|
||||
for metric in record["metrics"]:
|
||||
if metric["year"] == selected_years.get(metric["system"]) and metric["system"] in SYSTEM_FIELDS:
|
||||
output.append({"system": metric["system"], "quartile": metric["quartile"],
|
||||
"doi": doi_metadata.get("doi"),
|
||||
"metric": metric["metric"], "metric_year": metric["year"],
|
||||
"publication_year": publication_year, "category": metric["category"],
|
||||
"journal": record["name"], "issns": doi_metadata["issns"],
|
||||
"source": metric["source"], "source_url": safe_url(metric["source_url"]),
|
||||
"checked_on": date.today().isoformat()})
|
||||
return output, ("found_latest" if any(item["metric_year"] != publication_year for item in output) else "found") if output else "historical_quartile_unavailable"
|
||||
|
||||
|
||||
def merge(existing, additions, lookup=None):
|
||||
current = entries(existing)
|
||||
previous_doi = _doi(((existing or {}).get("lookup") or {}).get("doi") or "") or None
|
||||
# Freeze the previous lookup's identity on legacy evidence before replacing it.
|
||||
output = [{**item, "doi": previous_doi} if "doi" not in item else dict(item) for item in current]
|
||||
# Repeating a lookup must not duplicate evidence merely because its date changed.
|
||||
def key(item):
|
||||
return tuple(item.get(field) for field in ("system", "quartile", "metric", "metric_year",
|
||||
"publication_year", "category", "journal", "source", "source_url", "doi"))
|
||||
seen = {key(item) for item in output}
|
||||
for item in additions:
|
||||
if key(item) not in seen:
|
||||
output.append(item)
|
||||
seen.add(key(item))
|
||||
result = {**(existing or {}), "schema_version": 1, "entries": output}
|
||||
if lookup is not None:
|
||||
result["lookup"] = lookup
|
||||
return result
|
||||
|
||||
|
||||
def binding_issue(item, payload, doi="", journals=(), identifiers=()):
|
||||
value = item.get("doi") if "doi" in item else ((payload or {}).get("lookup") or {}).get("doi")
|
||||
source_doi = _doi(value or "")
|
||||
if value and not source_doi:
|
||||
return "DOI сохранённого квартиля некорректен — требуется перепроверка"
|
||||
current_doi = _doi(doi or "")
|
||||
if source_doi and source_doi != current_doi:
|
||||
return "Сохранённый квартиль относится к другому DOI — требуется перепроверка"
|
||||
source_ids = {issn(value) for value in item.get("issns", [])} - {""}
|
||||
current_ids = {issn(value) for value in identifiers} - {""}
|
||||
ids_match = bool(source_ids & current_ids)
|
||||
if source_ids and current_ids and not ids_match:
|
||||
return "ISSN сохранённого квартиля и публикации расходятся — требуется перепроверка"
|
||||
source_name = _name(item.get("journal") or "")
|
||||
names_match = bool(source_name and source_name in {_name(name) for name in journals if name})
|
||||
if source_name and journals and not names_match and not ids_match:
|
||||
return "Журнал сохранённого квартиля и публикации расходятся — требуется перепроверка"
|
||||
if not (source_doi and source_doi == current_doi) and not ids_match and not names_match:
|
||||
return "Привязка сохранённого квартиля к публикации не подтверждена — требуется перепроверка"
|
||||
return None
|
||||
|
||||
|
||||
def identity_issues(payload, doi="", journals=(), identifiers=()):
|
||||
return list(dict.fromkeys(issue for item in entries(payload)
|
||||
if (issue := binding_issue(item, payload, doi, journals, identifiers))))
|
||||
|
||||
|
||||
def labels(payload, publication_year, doi="", journals=(), identifiers=()):
|
||||
return list(dict.fromkeys(f"{item['system']} {item['quartile']}" for item in entries(payload)
|
||||
if item["quartile"] in {"Q1", "Q2"}
|
||||
and item["system"] in SYSTEM_FIELDS
|
||||
and item.get("publication_year") == publication_year
|
||||
and item.get("metric_year") in {None, publication_year}
|
||||
and not binding_issue(item, payload, doi, journals, identifiers)))
|
||||
|
||||
|
||||
def index_sjr(rankings):
|
||||
from app.services.council_catalog import issn
|
||||
indexed = {}
|
||||
for year, records in rankings.items():
|
||||
by_issn = defaultdict(list)
|
||||
for record in records:
|
||||
for identifier in set(map(issn, record["issns"])) - {""}:
|
||||
by_issn[identifier].append(record)
|
||||
indexed[int(year)] = dict(by_issn)
|
||||
return indexed
|
||||
|
||||
|
||||
def with_title_history(metadata, by_issn, by_title, source_url):
|
||||
"""Use explicit Scopus title succession, never a fuzzy journal-name match."""
|
||||
from app.services.council_catalog import issn
|
||||
if not safe_url(source_url):
|
||||
return metadata
|
||||
extra = set()
|
||||
names = {_name(name) for name in metadata.get("journals", [])}
|
||||
for identifier in metadata.get("issns", []):
|
||||
for old in by_issn.get(issn(identifier), []):
|
||||
if old["history"] not in {"Continued as", "Continues"}:
|
||||
continue
|
||||
for related in old["related"]:
|
||||
candidates = by_title.get(_name(related), [])
|
||||
if _name(related) in names and len(candidates) == 1:
|
||||
extra.update(candidates[0]["issns"])
|
||||
if not extra:
|
||||
return metadata
|
||||
return {**metadata, "ranking_issns": sorted(set(metadata["issns"]) | extra),
|
||||
"journal_identity_source_url": source_url}
|
||||
|
||||
|
||||
def sjr_entries(metadata, publication_year, rankings, allow_latest=False):
|
||||
from app.services.council_catalog import issn
|
||||
if metadata.get("type") != "journal-article":
|
||||
return [], "not_journal_article"
|
||||
if publication_year is None or publication_year not in metadata.get("years", []):
|
||||
return [], "publication_year_conflict"
|
||||
matches = {}
|
||||
identifiers = set(map(issn, metadata.get("ranking_issns", metadata.get("issns", [])))) - {""}
|
||||
for year, by_issn in rankings.items():
|
||||
journals = {record["source_url"]: record for identifier in identifiers for record in by_issn.get(identifier, [])}
|
||||
if journals:
|
||||
matches[int(year)] = journals
|
||||
year = publication_year if publication_year in matches else max(matches, default=None) if allow_latest else None
|
||||
if year is None:
|
||||
return [], "historical_quartile_unavailable"
|
||||
if len(matches[year]) != 1:
|
||||
return [], "journal_identity_conflict"
|
||||
record = next(iter(matches[year].values()))
|
||||
if record.get("conflict"):
|
||||
return [], "ranking_category_conflict"
|
||||
categories = []
|
||||
for category in record.get("categories", "").split(";"):
|
||||
if not category.strip():
|
||||
continue
|
||||
match = re.fullmatch(r"\s*(.+?)\s*\((Q[1-4])\)\s*", category)
|
||||
if match:
|
||||
categories.append((match[1].strip(), match[2]))
|
||||
else:
|
||||
return [], "ranking_category_conflict"
|
||||
if categories and min(quartile for _, quartile in categories) != record["quartile"]:
|
||||
return [], "ranking_category_conflict"
|
||||
if not categories and record.get("quartile") in QUARTILES:
|
||||
categories = [(None, record["quartile"])]
|
||||
result = [{"system": "SCImago", "quartile": quartile, "metric": "SJR" if category else "SJR best quartile",
|
||||
"doi": metadata.get("doi"),
|
||||
"metric_year": year, "publication_year": publication_year, "category": category,
|
||||
"journal": record["journal"], "issns": record["issns"],
|
||||
"source": "SCImago Journal & Country Rank · Universidad de Extremadura",
|
||||
"source_url": record["source_url"], "checked_on": date.today().isoformat()}
|
||||
for category, quartile in categories]
|
||||
if metadata.get("journal_identity_source_url"):
|
||||
for item in result:
|
||||
item["journal_identity_source_url"] = metadata["journal_identity_source_url"]
|
||||
return result, "found" if year == publication_year else "found_latest"
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Match a publication's journal to the supplied K1/K2 category snapshots."""
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from functools import lru_cache
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from app.services.council_catalog import issn
|
||||
from app.services.council_source import _name
|
||||
|
||||
DATA = Path(__file__).resolve().parents[1] / "data"
|
||||
|
||||
|
||||
def journal_key(value):
|
||||
return " ".join(re.findall(r"\w+", _name(value)))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def category_index():
|
||||
files = {category: DATA / filename for category, filename in
|
||||
(("К1", "k1_journals.json"), ("К2", "k2_journals.json"))}
|
||||
documents = {category: json.loads(path.read_text(encoding="utf8")) for category, path in files.items()}
|
||||
if documents["К1"]["source_sha256"] != documents["К2"]["source_sha256"]:
|
||||
raise ValueError("VAK category source mismatch")
|
||||
identities = json.loads((DATA / "k1_specialties.json").read_text(encoding="utf8"))
|
||||
if identities["k1_source_sha256"] != hashlib.sha256(files["К1"].read_bytes().replace(b"\r\n", b"\n")).hexdigest():
|
||||
raise ValueError("VAK journal identity source mismatch")
|
||||
known = {item["name"]: sorted({issn(entry["issn"]) for entry in item["vak_entries"]})
|
||||
for item in identities["journals"] if item.get("status") == "matched"}
|
||||
by_name, by_issn = defaultdict(list), defaultdict(list)
|
||||
for category, document in documents.items():
|
||||
for journal in document["journals"]:
|
||||
record = {"category": category, "journal": journal["name"], "source": document["source"],
|
||||
"source_sha256": document["source_sha256"], "source_row": journal["source_row"],
|
||||
"categorized_on": journal["categorized_on"], "issns": known.get(journal["name"], []) if category == "К1" else []}
|
||||
if record["issns"]:
|
||||
record.update(identity_source=identities["source"], identity_source_sha256=identities["source_sha256"],
|
||||
identity_as_of=identities["as_of"])
|
||||
for name in [journal["name"], *journal.get("aliases", [])]:
|
||||
by_name[journal_key(name)].append(record)
|
||||
for identifier in record["issns"]:
|
||||
by_issn[identifier].append(record)
|
||||
return dict(by_name), dict(by_issn)
|
||||
|
||||
|
||||
def assess_vak(names, identifiers):
|
||||
by_name, by_issn = category_index()
|
||||
matches = {}
|
||||
normalized = {issn(value) for value in identifiers} - {""}
|
||||
for identifier in normalized:
|
||||
for record in by_issn.get(identifier, []):
|
||||
matches[(record["category"], record["source_row"])] = {**record, "match_method": "issn", "identity_confirmed": True}
|
||||
for name in names:
|
||||
for record in by_name.get(journal_key(name), []):
|
||||
if record["issns"] and normalized and not normalized.intersection(record["issns"]):
|
||||
return {"schema_version": 1, "entries": [], "status": "journal_identity_conflict",
|
||||
"scope": "supplied_category_snapshot", "historical_category_confirmed": False, "checked_on": date.today().isoformat()}
|
||||
key = record["category"], record["source_row"]
|
||||
matches.setdefault(key, {**record, "match_method": "normalized_name", "identity_confirmed": False})
|
||||
if len(matches) > 1:
|
||||
status, output = "ambiguous_journal", []
|
||||
elif matches:
|
||||
status, output = "found", list(matches.values())
|
||||
else:
|
||||
status, output = ("not_found_in_k1_k2" if names or identifiers else "unknown_journal"), []
|
||||
return {"schema_version": 1, "entries": output, "status": status,
|
||||
"scope": "supplied_category_snapshot", "historical_category_confirmed": False,
|
||||
"checked_on": date.today().isoformat()}
|
||||
@@ -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,8 +895,11 @@
|
||||
.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__counts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(12px, 3vw, 36px); }
|
||||
.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; }
|
||||
}
|
||||
.council__candidate-count small { max-width: 105px; line-height: 1.4; }
|
||||
.council__hint { margin: 0; color: #4c5b64; font-size: 14px; line-height: 1.5; }
|
||||
.council__candidate-body .council__hint { margin-top: 12px; }
|
||||
|
||||
+56
-21
@@ -5,30 +5,44 @@
|
||||
<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>Не удалось загрузить публикации</h3>
|
||||
<p>Данные временно недоступны. Попробуйте обновить страницу позже.</p>
|
||||
<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>«CSV по критериям» — уникальные публикации с отметкой Scopus или Web of Science Q1/Q2 за {{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }}. «В БД» — все уникальные публикации сотрудника из miem-workers, включая импортированные из CSV. Общий перечень объединяет оба источника; совпадения не увеличивают итоговое количество.</p>
|
||||
<p>В CSV нет типа материала, точной даты и подтверждения К1 или RSCI. Счётчик CSV предварительный: для пограничных лет нужно проверить дату, для материалов без типа — подтвердить, что это статьи. Материалы, для которых в БД указан другой тип, в этот счётчик не входят. Отсутствие отметки означает недостаток данных. Наличие публикаций не подтверждает соответствие всем требованиям совета.</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">
|
||||
<label class="directory__field" for="council-specialty">Специальность ВАК
|
||||
<select class="directory__input" id="council-specialty" name="specialty">
|
||||
<option value="">Все специальности</option>
|
||||
{% for item in specialties %}
|
||||
<option value="{{ item.code }}"{% if specialty == item.code %} selected{% endif %}>{{ item.code }}. — {{ item.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="directory__field" for="council-search">Поиск по ФИО
|
||||
<input class="directory__input" id="council-search" name="q" type="search" value="{{ q }}" placeholder="Фамилия или полное имя">
|
||||
</label>
|
||||
<label class="directory__field">Сортировать по
|
||||
<select class="directory__input" name="sort">
|
||||
{% for value, label in [('full_name', 'ФИО'), ('csv_count', 'Публикациям CSV по критериям'), ('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>
|
||||
@@ -40,29 +54,39 @@
|
||||
</select>
|
||||
</label>
|
||||
<button class="button" type="submit">Применить</button>
|
||||
{% if q %}<a class="admin__link" href="?sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
<button class="button button--secondary" type="submit" formaction="/admin/council/export.xlsx">Скачать XLSX</button>
|
||||
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
</form>
|
||||
<div class="council__list-heading">
|
||||
<h3>Сотрудники</h3>
|
||||
<p role="status">{% if matched %}Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }} из {{ matched }}{% else %}Найдено: 0{% endif %}{% if q %} · Всего в списке: {{ total }}{% endif %}</p>
|
||||
<p role="status">{% if matched %}Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }} из {{ matched }}{% else %}Найдено: 0{% endif %}{% if q or specialty %} · Всего в списке: {{ total }}{% endif %}</p>
|
||||
</div>
|
||||
{% if specialty %}<p class="council__hint">{{ specialty }}. — {{ specialty_name }}. Основания отмечены в публикациях каждого сотрудника.</p>{% endif %}
|
||||
{% if candidates %}
|
||||
<p class="council__hint">Откройте сотрудника, чтобы увидеть публикации, источники и основания.</p>
|
||||
<p class="council__hint">Откройте сотрудника, чтобы увидеть публикации, источники и основания. XLSX содержит всех найденных сотрудников и их публикации, включая другие страницы списка.</p>
|
||||
<div class="council__list">
|
||||
{% 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.csv_count }}</strong><small>CSV по критериям</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 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.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">
|
||||
@@ -75,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 %}
|
||||
@@ -89,16 +124,16 @@
|
||||
</div>
|
||||
{% if matched > limit %}
|
||||
<nav class="directory__pagination" aria-label="Страницы сотрудников">
|
||||
{% if offset > 0 %}<a class="admin__link" href="?q={{ q|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset - limit }}">Назад</a>{% endif %}
|
||||
{% if offset > 0 %}<a class="admin__link" href="?q={{ q|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset - limit }}">Назад</a>{% endif %}
|
||||
<span class="directory__page">{{ offset // limit + 1 }} / {{ (matched + limit - 1) // limit }}</span>
|
||||
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset + limit }}">Далее</a>{% endif %}
|
||||
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset + limit }}">Далее</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="council__message">
|
||||
<h4>{% if q %}Сотрудники не найдены{% else %}Сотрудники с докторской степенью и публикациями пока не найдены{% endif %}</h4>
|
||||
<p>{% if q %}Попробуйте другую фамилию или сбросьте поиск.{% else %}Для включения в список нужны докторская степень в профиле и хотя бы одна публикация, однозначно связанная с сотрудником.{% endif %}</p>
|
||||
{% if q %}<a class="admin__link" href="?sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
<h4>{% if q or specialty %}Сотрудники не найдены{% else %}Сотрудники с учёной степенью пока не найдены{% endif %}</h4>
|
||||
<p>{% if q or specialty %}Попробуйте другую фамилию или специальность. Вспомогательный подбор по журналам К1 может скрывать сотрудников с другими основаниями и не является условием соответствия пунктам 5–6.{% else %}Для списка требуется обнаруженная либо документально подтверждённая степень доктора, кандидата наук или PhD.{% endif %}</p>
|
||||
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.12.0"
|
||||
FRONTEND_VERSION = "0.12.0"
|
||||
BACKEND_VERSION = "0.12.0"
|
||||
APP_VERSION = "0.18.1"
|
||||
FRONTEND_VERSION = "0.18.1"
|
||||
BACKEND_VERSION = "0.18.1"
|
||||
@@ -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: справочники и подтверждения кешируются в процессе.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Запись публикации в БД
|
||||
|
||||
Объект `EmployeePublication`, таблица `employee_publications`. Одна запись относится к одному сотруднику; одна работа у нескольких соавторов может иметь несколько записей с одним DOI. Типы ниже указаны для PostgreSQL; в SQLite JSONB представлен JSON.
|
||||
|
||||
| Поле | Тип | Обязательное | Назначение |
|
||||
|---|---|:---:|---|
|
||||
| `id` | integer | Да | Первичный ключ |
|
||||
| `employee_id` | integer | Да | Внешний ключ `employees.id`; удаление сотрудника удаляет связанные записи |
|
||||
| `publication_id` | varchar(64) | Нет | Идентификатор публикации в источнике ВШЭ |
|
||||
| `title` | text | Да | Название |
|
||||
| `year` | integer | Нет | Год публикации |
|
||||
| `publication_type` | varchar(64) | Нет | Тип материала: ARTICLE, CHAPTER, BOOK, PREPRINT и другие |
|
||||
| `language` | varchar(16) | Нет | Язык |
|
||||
| `status` | integer | Нет | Код статуса из источника |
|
||||
| `url` | text | Нет | Основная ссылка |
|
||||
| `doi_url` | text | Нет | DOI или ссылка DOI |
|
||||
| `other_url` | text | Нет | Дополнительная ссылка |
|
||||
| `document_url` | text | Нет | Ссылка на документ |
|
||||
| `citation_text` | text | Нет | Библиографическое описание |
|
||||
| `quartiles` | jsonb | Нет | Квартили Q1–Q4 с системой, годом метрики, источником и результатом поиска |
|
||||
| `vak_categories` | jsonb | Нет | Результат проверки журнала по предоставленным спискам К1/К2, способ идентификации и источник |
|
||||
| `annotation` | jsonb | Нет | Аннотация |
|
||||
| `description` | jsonb | Нет | Структурированное описание |
|
||||
| `authors` | jsonb | Нет | Список авторов |
|
||||
| `raw_data` | jsonb | Нет | Исходные данные, включая обогащение из данных ВШЭ |
|
||||
| `source_hash` | varchar(64) | Да | Хеш исходной записи |
|
||||
| `created_at` | timestamptz | Да | Создание записи |
|
||||
| `updated_at` | timestamptz | Да | Обновление записи |
|
||||
|
||||
Уникальны пары `(employee_id, publication_id)` и `(employee_id, source_hash)`. Индексы: `employee_id`, `publication_id`, `doi_url`, `year`, `publication_type`. Атрибут ORM `employee` ссылается на объект сотрудника и отдельным столбцом не является.
|
||||
|
||||
## Поле квартилей
|
||||
|
||||
`NULL` означает, что обогащение ещё не выполнялось. После проверки поле содержит объект со схемой версии 1. Пустой `entries` означает отсутствие найденного квартиля; причина сохраняется в `lookup`. Пустой результат не равнозначен Q4 и не доказывает отсутствие индексации.
|
||||
|
||||
Пример структуры (иллюстративные данные):
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"entries": [
|
||||
{
|
||||
"system": "Scopus",
|
||||
"quartile": "Q2",
|
||||
"doi": "10.1234/example",
|
||||
"metric": "CiteScore",
|
||||
"metric_year": 2024,
|
||||
"publication_year": 2024,
|
||||
"category": "Applied Mathematics",
|
||||
"journal": "Название журнала",
|
||||
"issns": ["0006-3835"],
|
||||
"source": "Название проверенного источника",
|
||||
"source_url": "https://example.org/metrics",
|
||||
"checked_on": "2026-10-08"
|
||||
}
|
||||
],
|
||||
"lookup": {
|
||||
"doi": "10.1234/example",
|
||||
"status": "found",
|
||||
"requested_year": 2024,
|
||||
"checked_on": "2026-10-08"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `entries` сохраняет все найденные категории и системы отдельно, без выбора одного «лучшего» квартиля вместо остальных. Возможные системы: Scopus, Web of Science, SCImago. Метрики CiteScore, JCR и SJR не подменяют друг друга. При наличии только лучшего квартиля SJR используется метрика `SJR best quartile`, категория остаётся `null`.
|
||||
- Каждое основание хранит собственный `doi`. При дополнении старого payload основания без этого ключа получают DOI из прежнего `lookup` до его обновления; при отсутствии прежнего DOI сохраняется `null`. Новый общий `lookup` не перепривязывает прежние записи. DOI данных ВШЭ берётся из их собственных исходных метаданных.
|
||||
- Перед использованием в счётчиках основание сверяется с текущими DOI, журналом и ISSN публикации. Конфликт исключает это основание и выводится как причина перепроверки; доказательства сохраняются. Совпадающий ISSN позволяет подтвердить переименование журнала, но не снимает конфликт DOI. Основание без проверяемой привязки не засчитывается по одному совпадению года. Это также относится к обратному чтению `raw_data.csv_enrichment`.
|
||||
- Значения `Квартиль scopus` и `Квартиль wos` из `raw_data.csv_enrichment` переносятся в отдельные записи Q1–Q4. Их точная метрика и год метрики неизвестны: `metric` и `metric_year` остаются `null`. Год публикации не объявляется годом метрики. Исходное обогащение не удаляется.
|
||||
- При поиске новых значений журнал определяется по DOI через [Crossref](https://www.crossref.org/documentation/retrieve-metadata/rest-api/), а при отсутствии записи — через публичный [DataCite API](https://support.datacite.org/docs/rest-api). ISSN связывает журнал с рейтингом. Проверяются идентичность DOI, тип материала и соответствие года публикации данным DOI. Книге или главе не присваивается журнальный квартиль по одному похожему названию. Препринт DataCite не получает квартиль журнала связанной статьи автоматически.
|
||||
- Для старого ISSN допускается подтверждённая преемственность названия по официальному [списку Scopus за август 2026](https://downloads.ctfassets.net/o78em1y1w4i4/7xtaTxNiNcWRTeZkV86eNy/69cf2d506c905dc299531fdc93049dbb/ext_list_Aug_2026.xlsx). Нужны явная связь Continued as/Continues и единственное связанное название, совпадающее с журналом в ответе DOI. Похожесть названий не используется. Исходные ISSN Crossref сохраняются; дополнительные ISSN для рейтинга и ссылка на историю записываются отдельно в `lookup`, ссылка `journal_identity_source_url` также сохраняется в найденных квартилях.
|
||||
- Для каждой системы сначала используется метрика за год публикации. При явно разрешённом `--allow-latest` и отсутствии исторической метрики допускается последний доступный год: он остаётся в `metric_year`, а результат имеет статус `found_latest`. Все категории CiteScore/JCR и SJR сохраняются вместе; разные системы могут иметь разные годы метрики. Это последний год в доступных проверенных источниках, а не обещание глобально самого нового рейтинга.
|
||||
- Источники: проверяемые каталоги проекта (CiteScore/JCR) и исторические SJR-данные [Universidad de Extremadura](https://opendata.unex.es/dataset/revistas-cientificas-2025) за 2009–2025. Портал указывает SCImago как автора и поддерживающую организацию и публикует данные по CC BY 3.0; атрибуция и ссылка сохраняются в каждой записи. [SJR](https://www.scimagolab.com/products/sjr-scimago-journal-country-rank/) хранится с системой SCImago, даже несмотря на происхождение исходных данных из Scopus.
|
||||
|
||||
`lookup` также может содержать полученные по DOI тип материала, названия журнала, ISSN, годы и ссылку на регистратора DOI. Состояния поиска:
|
||||
|
||||
| Статус | Значение |
|
||||
|---|---|
|
||||
| `found` | Найдена метрика за год публикации |
|
||||
| `found_latest` | Использован другой, последний доступный год |
|
||||
| `missing_doi` / `invalid_doi` | DOI отсутствует или некорректен |
|
||||
| `doi_not_found` / `doi_lookup_failed` | DOI не найден в Crossref и DataCite / источник не ответил успешно; DOI может относиться к другому регистратору |
|
||||
| `doi_identity_conflict` | Ответ источника относится к другому DOI |
|
||||
| `not_journal_article` | DOI относится к материалу, не определённому как журнальная статья |
|
||||
| `missing_publication_year` / `publication_year_conflict` | Год неизвестен / отличается от годов источника DOI |
|
||||
| `missing_journal_issn` | Журнал по ISSN не установлен |
|
||||
| `journal_identity_conflict` | Идентификаторы связываются с несколькими журналами |
|
||||
| `ranking_category_conflict` | Сведения о категориях SJR противоречат друг другу |
|
||||
| `historical_quartile_unavailable` | Проверенные источники не дали допустимой метрики |
|
||||
|
||||
## Миграция и обогащение
|
||||
|
||||
Миграции `migrations/010_publication_quartiles.sql` и `migrations/011_publication_vak_categories.sql` добавляют nullable JSONB-поля без удаления или перезаписи данных. Инициализация приложения также добавляет поля на существующей схеме PostgreSQL/SQLite. Обогащение не запускается при старте API или обработке HTTP-запроса.
|
||||
|
||||
Перед изменением заполненной БД создать и проверить резервную копию. Подключение инструмента — только через `DATABASE_URL` из окружения; `.env` и файлы с credentials инструмент не читает. Не передавать секреты в аргументах и не коммитить отчёты, кэши и резервные копии.
|
||||
|
||||
Сначала выполнить предварительное сравнение без записи, затем перенос известных значений:
|
||||
|
||||
```sh
|
||||
python scripts/enrich_publication_quartiles.py --cache-dir .impeccable/quartiles/cache --report .impeccable/quartiles/hse-plan.json
|
||||
python scripts/enrich_publication_quartiles.py --apply --cache-dir .impeccable/quartiles/cache --report .impeccable/quartiles/hse-applied.json
|
||||
```
|
||||
|
||||
Затем проверить DOI у записей без квартиля. Для предварительного сравнения убрать `--apply`:
|
||||
|
||||
```sh
|
||||
python scripts/enrich_publication_quartiles.py --apply --lookup-doi --all-systems --check-vak --sjr-history --allow-latest --workers 4 --cache-dir .impeccable/quartiles/cache --report .impeccable/quartiles/doi-applied.json
|
||||
```
|
||||
|
||||
Для проверки смены ISSN добавить `--scopus-title-history /путь/ext_list_Aug_2026.xlsx`. Используется конкретный официальный снимок: SHA-256 `11e81f686401c89fbef28de31d1880388bb7122f35c89e09db6e1848237e9afb`; несоответствующий файл отклоняется. Это список названий и их истории, не источник квартилей.
|
||||
|
||||
`--all-systems` проверяет также публикации с уже известным квартилем: добавляются остальные доступные системы и предметные категории. Без этого флага проверяются записи без квартиля, ранее найденные последние метрики и заполненные основания с изменившейся или неподтверждённой привязкой. Новые доказательства дополняют список, не удаляя прежние. Несколько категорий с одинаковым Q1 сохраняются отдельно; один квартиль не заменяет остальные.
|
||||
|
||||
## Поле категорий ВАК
|
||||
|
||||
`vak_categories` имеет `schema_version: 1`, массив `entries`, `status`, `scope: "supplied_category_snapshot"`, `historical_category_confirmed: false` и дату `checked_on`. `NULL` — проверка ещё не проводилась. В каждом совпадении сохраняются `category` (К1/К2), каноническое название журнала, название и SHA-256 источника, строка источника, дата категоризации, ISSN при наличии, `match_method` и `identity_confirmed`.
|
||||
|
||||
Проверяются `k1_journals.json` и `k2_journals.json`, извлечённые из предоставленного документа «ВАК категории.pdf». Для К1 доступна дополнительная идентификация по ISSN из `k1_specialties.json`; её источник, SHA-256 и дата списка сохраняются отдельно. В проверку передаются ISSN/eISSN исходного `raw_data`, данных ВШЭ и актуального ответа DOI; исходные идентификаторы используются и без сетевого поиска или DOI. Ответ, сохранённый для другого DOI, не считается актуальным источником идентификаторов. Поиск К2 в текущих данных выполняется по нормализованному названию и явным административным переименованиям. Транслитерация, похожесть названий и предположение о соответствии русской и переводной версии не используются.
|
||||
|
||||
- `match_method: "issn"`, `identity_confirmed: true` — журнал идентифицирован по ISSN.
|
||||
- `match_method: "normalized_name"`, `identity_confirmed: false` — название совпало со списком; идентичность по ISSN не подтверждена. Это предварительное совпадение.
|
||||
- `status: "found"` — одно совпадение; `ambiguous_journal` или `journal_identity_conflict` — неоднозначность/конфликт, категория автоматически не выбирается.
|
||||
- `not_found_in_k1_k2` — совпадений в этих двух снимках нет; это не доказывает отсутствие журнала в ВАК или наличие К3. `unknown_journal` — данных для идентификации журнала нет.
|
||||
|
||||
Дата категоризации в документе не объявляется датой всего снимка, а категория по имеющемуся списку не подтверждает её наличие в год публикации. Книги и главы не становятся журнальными статьями из-за совпадения названия. Проверка К1/К2 не является подтверждением зачёта конкретной работы по критериям совета.
|
||||
|
||||
`--check-vak` сохраняет этот результат в отдельном поле. Уже заполненные отличающиеся значения не перезаписываются; число таких случаев отражается в `vak_existing_value_kept`. Их нужно отдельно сравнить с актуальными источниками. Проверить результаты и причины неоднозначности до использования данных в заключении.
|
||||
|
||||
Запросы DOI кэшируются по нормализованному DOI и переиспользуются для соавторов. Ошибки сети допускают повторную попытку; для полного обновления ранее найденных ответов использовать новый каталог кэша. Исторические рейтинги запрашиваются только для ISSN найденных журналов; ошибки источника отражаются в `ranking_failures` отчёта. Существующие квартильные записи не перезаписываются; повторный перенос не дублирует доказательства только из-за новой даты проверки. Перед записью инструмент проверяет, что публикация не изменилась параллельно; при изменении пропускает её и фиксирует `concurrent_change_skipped`. Коммит выполняется по публикациям: прерванную обработку можно возобновить. Проверить итоговый отчёт и полноту переноса после завершения.
|
||||
|
||||
Парсер сохраняет `quartiles`, обновляя остальные исходные поля публикации. Страница совета читает отдельное поле с обратной совместимостью для исходных данных ВШЭ. В предварительные Q1/Q2 совета попадают Scopus/Web of Science с применимым годом; последние метрики другого года и SJR автоматически не превращаются в исторический зачёт или документальное подтверждение критериев.
|
||||
|
||||
Миграция и обогащение локальной БД не обновляют production. Порядок переноса только недостающих данных в production и сохранения его заполненных значений описан в README.
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Source/year-aware quartiles; NULL means enrichment has not run yet.
|
||||
ALTER TABLE employee_publications ADD COLUMN IF NOT EXISTS quartiles JSONB;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Matches to the supplied category snapshot, with provenance and identity state.
|
||||
ALTER TABLE employee_publications ADD COLUMN IF NOT EXISTS vak_categories JSONB;
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"lxml>=5.2.0",
|
||||
"psycopg[binary]>=3.2.0",
|
||||
"pydantic-settings>=2.4.0",
|
||||
"openpyxl>=3.1.5,<4",
|
||||
"python-multipart>=0.0.9",
|
||||
"requests>=2.32.0",
|
||||
"sqlalchemy>=2.0.32",
|
||||
|
||||
@@ -6,6 +6,7 @@ jinja2>=3.1.4
|
||||
lxml>=5.2.0
|
||||
psycopg[binary]>=3.2.0
|
||||
pydantic-settings>=2.4.0
|
||||
openpyxl>=3.1.5,<4
|
||||
python-multipart>=0.0.9
|
||||
requests>=2.32.0
|
||||
sqlalchemy>=2.0.32
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Backfill HSE quartiles and resolve remaining DOI journals. No .env or DDL."""
|
||||
|
||||
import argparse
|
||||
from collections import Counter, defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import date, datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from sqlalchemy import MetaData, Table, create_engine, select, update
|
||||
from openpyxl import load_workbook
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from app.services.council_source import _doi, _name, _journal_names, _description_journals
|
||||
from app.services.council_catalog import issn
|
||||
from app.services.publication_quartiles import catalog_entries, entries, hse_entries, merge, sjr_entries, index_sjr, with_title_history, identity_issues
|
||||
from app.services.publication_vak import assess_vak
|
||||
|
||||
SOURCE_TITLES_URL = "https://downloads.ctfassets.net/o78em1y1w4i4/7xtaTxNiNcWRTeZkV86eNy/69cf2d506c905dc299531fdc93049dbb/ext_list_Aug_2026.xlsx"
|
||||
SOURCE_TITLES_SHA256 = "11e81f686401c89fbef28de31d1880388bb7122f35c89e09db6e1848237e9afb"
|
||||
|
||||
|
||||
def load_title_history(path):
|
||||
if hashlib.sha256(path.read_bytes()).hexdigest() != SOURCE_TITLES_SHA256:
|
||||
raise ValueError("Scopus title-list snapshot checksum mismatch")
|
||||
by_issn, by_title = defaultdict(list), defaultdict(list)
|
||||
workbook = load_workbook(path, read_only=True, data_only=True)
|
||||
try:
|
||||
rows = workbook.worksheets[0].iter_rows(values_only=True)
|
||||
columns = next(rows)
|
||||
required = {"Source Title", "ISSN", "EISSN", "Source Type", "Title History Indication", "Related Title 1"}
|
||||
if not required <= set(columns):
|
||||
raise ValueError("Invalid Scopus title list")
|
||||
for row in rows:
|
||||
data = dict(zip(columns, row))
|
||||
if data["Source Type"] != "Journal":
|
||||
continue
|
||||
identifiers = {issn(data.get(key)) for key in ("ISSN", "EISSN")} - {""}
|
||||
identifiers = {identifier[:4] + "-" + identifier[4:] for identifier in identifiers if len(identifier) == 8}
|
||||
record = {"title": str(data["Source Title"] or ""), "issns": identifiers,
|
||||
"history": data["Title History Indication"],
|
||||
"related": [str(value) for key, value in data.items() if key and "Related Title" in key and value]}
|
||||
by_title[_name(record["title"])].append(record)
|
||||
for identifier in identifiers:
|
||||
by_issn[issn(identifier)].append(record)
|
||||
finally:
|
||||
workbook.close()
|
||||
return dict(by_issn), dict(by_title)
|
||||
|
||||
|
||||
def load_sjr(identifiers, years, cache):
|
||||
"""Read the university's licensed SCImago dataset for the requested ISSNs."""
|
||||
rankings = {}
|
||||
failures = []
|
||||
for year in sorted(set(years) & set(range(2009, 2026))):
|
||||
records = {}
|
||||
for offset in range(0, len(identifiers), 200):
|
||||
batch = identifiers[offset:offset + 200]
|
||||
digest = hashlib.sha256(json.dumps([year, batch]).encode()).hexdigest()
|
||||
path = cache / f"sjr-{digest}.json"
|
||||
if path.exists():
|
||||
bindings = json.loads(path.read_text(encoding="utf8"))
|
||||
else:
|
||||
values = " ".join(json.dumps(value) for value in batch)
|
||||
query = f'''PREFIX bibo: <http://purl.org/ontology/bibo/>
|
||||
PREFIX dct: <http://purl.org/dc/terms/>
|
||||
PREFIX ou: <https://opendata.unex.es/def/ontouniversidad#>
|
||||
SELECT DISTINCT ?uri ?title ?issn ?eissn ?quartile ?categories WHERE {{
|
||||
VALUES ?wanted {{ {values} }}
|
||||
{{ ?uri bibo:issn ?wanted }} UNION {{ ?uri bibo:eissn ?wanted }}
|
||||
?uri a bibo:Journal; dct:title ?title; dct:identifier ?id; ou:sjrMejorCuartil ?quartile .
|
||||
FILTER(STRSTARTS(STR(?id), "{year}-"))
|
||||
OPTIONAL {{ ?uri bibo:issn ?issn }} OPTIONAL {{ ?uri bibo:eissn ?eissn }}
|
||||
OPTIONAL {{ ?uri ou:categoriaSJR ?categories }}
|
||||
}} LIMIT 10001'''
|
||||
try:
|
||||
response = requests.get("https://opendata.unex.es/sparql/", timeout=45,
|
||||
params={"query": query, "format": "application/sparql-results+json"},
|
||||
headers={"Accept": "application/sparql-results+json"})
|
||||
response.raise_for_status()
|
||||
bindings = response.json()["results"]["bindings"]
|
||||
if len(bindings) >= 10000:
|
||||
raise ValueError("Truncated ranking query")
|
||||
path.write_text(json.dumps(bindings, ensure_ascii=False), encoding="utf8")
|
||||
except (requests.RequestException, ValueError, KeyError, TypeError):
|
||||
failures.append({"year": year, "batch": offset, "status": "ranking_source_unavailable"})
|
||||
continue
|
||||
for item in bindings:
|
||||
value = lambda field: item.get(field, {}).get("value", "")
|
||||
url = value("uri")
|
||||
if not url.startswith(f"https://opendata.unex.es/recurso/ciencia-tecnologia/investigacion/revistas/Revista/{year}-"):
|
||||
raise ValueError("Ranking year/identity mismatch")
|
||||
record = {"journal": value("title"), "issns": sorted({value("issn"), value("eissn")} - {""}),
|
||||
"quartile": value("quartile"), "categories": value("categories"), "source_url": url}
|
||||
if record["quartile"] not in {"Q1", "Q2", "Q3", "Q4"}:
|
||||
continue
|
||||
previous = records.get(url)
|
||||
if previous:
|
||||
record["journal"] = min(previous["journal"], record["journal"])
|
||||
record["issns"] = sorted(set(previous["issns"]) | set(record["issns"]))
|
||||
record["categories"] = "; ".join(sorted({category.strip() for value in
|
||||
(previous["categories"], record["categories"]) for category in value.split(";") if category.strip()}))
|
||||
record["conflict"] = previous.get("conflict", False) or previous["quartile"] != record["quartile"]
|
||||
records[url] = record
|
||||
rankings[year] = list(records.values())
|
||||
print(f"SJR {year}: {len(records)} matching journals", flush=True)
|
||||
return index_sjr(rankings), failures
|
||||
|
||||
|
||||
def resolve_datacite(doi, timeout):
|
||||
url = "https://api.datacite.org/dois/" + quote(doi, safe="")
|
||||
result = {"doi": doi, "status": "doi_lookup_failed", "source_url": url, "datacite_checked": True}
|
||||
for attempt in range(3):
|
||||
try:
|
||||
response = requests.get(url, timeout=timeout, allow_redirects=False,
|
||||
headers={"User-Agent": "MIEM-workers/0.18 publication-quartiles", "Accept": "application/json"})
|
||||
if response.status_code == 404:
|
||||
return {**result, "status": "doi_not_found"}
|
||||
if response.status_code == 429 or response.status_code >= 500:
|
||||
time.sleep(min(10, 2 ** attempt))
|
||||
continue
|
||||
response.raise_for_status()
|
||||
metadata = response.json()["data"]["attributes"]
|
||||
if _doi(metadata.get("doi", "")) != doi:
|
||||
return {**result, "status": "doi_identity_conflict"}
|
||||
types = metadata.get("types", {})
|
||||
general = types.get("resourceTypeGeneral")
|
||||
journal_article = general == "JournalArticle" or (general in {None, "Text"} and types.get("citeproc") == "article-journal")
|
||||
container = metadata.get("container") or {}
|
||||
identifiers = []
|
||||
if container.get("identifierType") == "ISSN":
|
||||
identifiers.append(container.get("identifier", ""))
|
||||
for related in metadata.get("relatedIdentifiers", []):
|
||||
if related.get("relatedIdentifierType") == "ISSN" and related.get("relationType") in {"IsPartOf", "IsPublishedIn"}:
|
||||
identifiers.append(related.get("relatedIdentifier", ""))
|
||||
identifiers = sorted({value[:4] + "-" + value[-4:] for value in identifiers if isinstance(value, str)
|
||||
and re.fullmatch(r"\d{4}-?\d{3}[\dX]", value, re.I)})
|
||||
year = metadata.get("publicationYear")
|
||||
years = [int(year)] if str(year).isdigit() else []
|
||||
return {**result, "status": "resolved", "type": "journal-article" if journal_article else "non-journal-material",
|
||||
"issns": identifiers, "journals": [container["title"]] if isinstance(container.get("title"), str) else [], "years": years}
|
||||
except (requests.RequestException, ValueError, KeyError, TypeError, AttributeError):
|
||||
if attempt < 2:
|
||||
time.sleep(2 ** attempt)
|
||||
return result
|
||||
|
||||
|
||||
def resolve_doi(doi, cache, timeout=20):
|
||||
path = cache / (hashlib.sha256(doi.encode()).hexdigest() + ".json")
|
||||
if path.exists():
|
||||
result = json.loads(path.read_text(encoding="utf8"))
|
||||
if result.get("doi") == doi and (result.get("status") == "resolved"
|
||||
or (result.get("status") == "doi_not_found" and result.get("datacite_checked"))):
|
||||
return result
|
||||
url = "https://api.crossref.org/works/" + quote(doi, safe="")
|
||||
result = {"doi": doi, "status": "doi_lookup_failed", "source_url": url}
|
||||
for attempt in range(3):
|
||||
try:
|
||||
response = requests.get(url, timeout=timeout, allow_redirects=False,
|
||||
headers={"User-Agent": "MIEM-workers/0.18 publication-quartiles",
|
||||
"Accept": "application/json"})
|
||||
if response.status_code == 404:
|
||||
result = resolve_datacite(doi, timeout)
|
||||
break
|
||||
if response.status_code == 429 or response.status_code >= 500:
|
||||
time.sleep(min(10, 2 ** attempt))
|
||||
continue
|
||||
response.raise_for_status()
|
||||
message = response.json()["message"]
|
||||
if _doi(message.get("DOI", "")) != doi:
|
||||
result["status"] = "doi_identity_conflict"
|
||||
break
|
||||
years = set()
|
||||
for field in ("published", "published-print", "published-online", "issued"):
|
||||
for parts in message.get(field, {}).get("date-parts", []):
|
||||
if parts and type(parts[0]) is int:
|
||||
years.add(parts[0])
|
||||
result.update(status="resolved", type=message.get("type"),
|
||||
journals=[v for v in message.get("container-title", []) if isinstance(v, str)],
|
||||
issns=[v for v in message.get("ISSN", []) if isinstance(v, str)
|
||||
and re.fullmatch(r"\d{4}-\d{3}[\dX]", v, re.I)], years=sorted(years))
|
||||
break
|
||||
except (requests.RequestException, ValueError, KeyError, TypeError, AttributeError):
|
||||
if attempt < 2:
|
||||
time.sleep(2 ** attempt)
|
||||
if result["status"] in {"resolved", "doi_not_found"}:
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(result, ensure_ascii=False), encoding="utf8")
|
||||
temporary.replace(path)
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--apply", action="store_true", help="Commit changes; default is read-only")
|
||||
parser.add_argument("--lookup-doi", action="store_true")
|
||||
parser.add_argument("--all-systems", action="store_true", help="Collect additional systems/categories even for publications with known quartiles")
|
||||
parser.add_argument("--check-vak", action="store_true", help="Check the supplied K1/K2 snapshots")
|
||||
parser.add_argument("--allow-latest", action="store_true", help="Use latest available metric if the publication year is unavailable")
|
||||
parser.add_argument("--sjr-history", action="store_true", help="Check UNEx/SCImago historical quartiles by DOI ISSNs")
|
||||
parser.add_argument("--scopus-title-history", type=Path, help="Official Scopus August 2026 source-title XLSX for explicit ISSN succession")
|
||||
parser.add_argument("--cache-dir", type=Path, required=True)
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
parser.add_argument("--workers", type=int, choices=range(1, 9), default=4)
|
||||
args = parser.parse_args()
|
||||
pending = args.report.with_suffix(args.report.suffix + ".pending")
|
||||
if args.scopus_title_history and args.scopus_title_history.resolve() in {args.report.resolve(), pending.resolve()}:
|
||||
raise ValueError("Report must not overwrite the source title list")
|
||||
args.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Verify report destination before changing any data.
|
||||
pending.write_text("", encoding="utf8")
|
||||
engine = create_engine(os.environ["DATABASE_URL"])
|
||||
table = Table("employee_publications", MetaData(), autoload_with=engine)
|
||||
if "quartiles" not in table.c:
|
||||
raise ValueError("Apply migration 010 before enrichment")
|
||||
fields = ["id", "year", "doi_url", "raw_data", "quartiles", "description"]
|
||||
if args.check_vak:
|
||||
if "vak_categories" not in table.c:
|
||||
raise ValueError("Apply migration 011 before VAK enrichment")
|
||||
fields.extend(["publication_type", "vak_categories"])
|
||||
columns = [table.c[field] for field in fields]
|
||||
with engine.connect() as connection:
|
||||
rows = [dict(row) for row in connection.execute(select(*columns)).mappings()]
|
||||
def needs_lookup(row):
|
||||
raw = row["raw_data"] if isinstance(row["raw_data"], dict) else {}
|
||||
names = _journal_names(raw)
|
||||
description = row["description"] if isinstance(row["description"], dict) else raw.get("description", {})
|
||||
if isinstance(description, dict):
|
||||
names.extend(_description_journals(description))
|
||||
ids = [raw[key] for key in ("issn", "eissn", "ISSN", "EISSN") if isinstance(raw.get(key), str)]
|
||||
payload = merge(row["quartiles"], hse_entries(row["raw_data"], row["year"]))
|
||||
return (args.all_systems or not entries(payload) or (payload.get("lookup") or {}).get("status") == "found_latest"
|
||||
or bool(identity_issues(payload, row["doi_url"], names, ids)))
|
||||
identifiers = sorted({_doi(row["doi_url"] or "") for row in rows
|
||||
if needs_lookup(row)} - {""})
|
||||
resolved = {}
|
||||
if args.lookup_doi:
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as pool:
|
||||
tasks = {pool.submit(resolve_doi, doi, args.cache_dir): doi for doi in identifiers}
|
||||
for task in as_completed(tasks):
|
||||
resolved[tasks[task]] = task.result()
|
||||
if len(resolved) % 100 == 0 or len(resolved) == len(identifiers):
|
||||
print(f"DOI checked: {len(resolved)}/{len(identifiers)}", flush=True)
|
||||
rankings, ranking_failures = {}, []
|
||||
if args.scopus_title_history:
|
||||
by_issn, by_title = load_title_history(args.scopus_title_history)
|
||||
resolved = {doi: with_title_history(metadata, by_issn, by_title, SOURCE_TITLES_URL)
|
||||
for doi, metadata in resolved.items()}
|
||||
if args.lookup_doi and args.sjr_history:
|
||||
issns = sorted({value for metadata in resolved.values() if metadata.get("type") == "journal-article"
|
||||
for value in metadata.get("ranking_issns", metadata.get("issns", []))})
|
||||
years = {row["year"] for row in rows if needs_lookup(row) and row["year"] is not None}
|
||||
if args.allow_latest:
|
||||
years.add(2025)
|
||||
rankings, ranking_failures = load_sjr(issns, years, args.cache_dir)
|
||||
counts = Counter()
|
||||
report = []
|
||||
for row in rows:
|
||||
doi = _doi(row["doi_url"] or "")
|
||||
previous_lookup = (row["quartiles"].get("lookup") or {}) if row["quartiles"] else {}
|
||||
metadata = resolved.get(doi, previous_lookup if doi and _doi(previous_lookup.get("doi") or "") == doi else {})
|
||||
additions = hse_entries(row["raw_data"], row["year"])
|
||||
payload = merge(row["quartiles"], additions)
|
||||
if additions:
|
||||
counts["hse_quartiles"] += 1
|
||||
if args.lookup_doi and needs_lookup(row):
|
||||
doi = _doi(row["doi_url"] or "")
|
||||
metadata = resolved.get(doi, {"status": "invalid_doi" if row["doi_url"] else "missing_doi"})
|
||||
status = metadata["status"]
|
||||
if status == "resolved":
|
||||
found, status = catalog_entries(metadata, row["year"], args.allow_latest)
|
||||
if rankings:
|
||||
sjr, sjr_status = sjr_entries(metadata, row["year"], rankings, args.allow_latest)
|
||||
found.extend(sjr)
|
||||
if not found and status == "historical_quartile_unavailable":
|
||||
status = sjr_status
|
||||
if found:
|
||||
status = "found_latest" if any(item["metric_year"] != row["year"] for item in found) else "found"
|
||||
payload = merge(payload, found)
|
||||
payload["lookup"] = {**metadata, "status": status, "requested_year": row["year"],
|
||||
"checked_on": date.today().isoformat()}
|
||||
counts[status] += 1
|
||||
counts["with_quartile" if entries(payload) else "without_quartile"] += 1
|
||||
vak_payload = row.get("vak_categories")
|
||||
if args.check_vak:
|
||||
raw = row["raw_data"] if isinstance(row["raw_data"], dict) else {}
|
||||
imported = raw.get("csv_enrichment", {})
|
||||
names = [*_journal_names(raw), *(_journal_names(imported) if isinstance(imported, dict) else []),
|
||||
*(metadata.get("journals", []) if metadata.get("type") in {"journal-article", "journal"} else [])]
|
||||
if row["publication_type"] == "ARTICLE" and metadata.get("type") in {None, "journal-article", "journal"}:
|
||||
description = row["description"] if isinstance(row["description"], dict) else raw.get("description", {})
|
||||
if isinstance(description, dict):
|
||||
names.extend(_description_journals(description))
|
||||
identifiers = list(metadata.get("issns", []))
|
||||
for source in (raw, imported):
|
||||
if isinstance(source, dict):
|
||||
identifiers.extend(source[key] for key in ("issn", "eissn", "ISSN", "EISSN") if isinstance(source.get(key), str))
|
||||
vak_payload = assess_vak(list(dict.fromkeys(names)), list(dict.fromkeys(identifiers)))
|
||||
if row.get("vak_categories") is not None and row["vak_categories"] != vak_payload:
|
||||
counts["vak_existing_value_kept"] += 1
|
||||
vak_payload = row["vak_categories"]
|
||||
counts["vak_" + vak_payload["status"]] += 1
|
||||
if payload == row["quartiles"] and vak_payload == row.get("vak_categories"):
|
||||
continue
|
||||
if args.apply:
|
||||
# Only this field is changed; parser-owned data and existing evidence survive.
|
||||
with engine.begin() as connection:
|
||||
current = dict(connection.execute(select(*columns).where(table.c.id == row["id"])
|
||||
.with_for_update()).mappings().one())
|
||||
if any(current[field] != row[field] for field in fields if field != "id"):
|
||||
counts["concurrent_change_skipped"] += 1
|
||||
continue
|
||||
values = {"quartiles": payload, "updated_at": datetime.now(timezone.utc)}
|
||||
if args.check_vak:
|
||||
values["vak_categories"] = vak_payload
|
||||
connection.execute(update(table).where(table.c.id == row["id"]).values(**values))
|
||||
counts["changed"] += 1
|
||||
report.append({"id": row["id"], "quartiles": payload, **({"vak_categories": vak_payload} if args.check_vak else {})})
|
||||
summary = {"mode": "apply" if args.apply else "dry-run", "publications": len(rows),
|
||||
"unique_doi_checked": len(resolved), "counts": dict(counts), "ranking_failures": ranking_failures}
|
||||
pending.write_text(json.dumps({"summary": summary, "records": report}, ensure_ascii=False, indent=2), encoding="utf8")
|
||||
pending.replace(args.report)
|
||||
print(json.dumps(summary, ensure_ascii=False), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
# Connection errors can contain credentials; do not print their text.
|
||||
print(f"Enrichment failed ({type(error).__name__}); completed records are retained; retry is safe.", flush=True)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""One-time extraction: python scripts/extract_k1_journals.py <categories.pdf>.
|
||||
|
||||
Requires pdfplumber for this development operation only; the service reads JSON.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import pdfplumber
|
||||
|
||||
|
||||
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:
|
||||
for table in page.extract_tables():
|
||||
for row in table:
|
||||
if row[0] and row[0].isdigit():
|
||||
if len(row) != 4 or not all(row) or not set(row[2].split()) <= {"К1", "К2", "К3"}:
|
||||
raise ValueError(f"Invalid category row: {row}")
|
||||
# Some entries include previous categories; use the final category.
|
||||
row[2] = row[2].split()[-1]
|
||||
row[3] = row[3].splitlines()[-1]
|
||||
datetime.strptime(row[3], "%d.%m.%Y")
|
||||
rows.append(row)
|
||||
if not rows or [int(row[0]) for row in rows] != list(range(1, len(rows) + 1)):
|
||||
raise ValueError("Journal numbering is incomplete or duplicated")
|
||||
journals = []
|
||||
for row in rows:
|
||||
if row[2] != category:
|
||||
continue
|
||||
name = " ".join(row[1].split())
|
||||
journal = {"name": name, "categorized_on": row[3], "source_row": int(row[0])}
|
||||
# Only an explicit administrative rename note supplies an alias.
|
||||
# Parentheses in journal names (e.g. bilingual titles) stay unchanged.
|
||||
rename = re.fullmatch(r"(.+?) \((до \d{2}\.\d{2}\.\d{4} г\. наименование в Перечне «(.+)»)\)", name)
|
||||
if rename:
|
||||
journal.update(name=rename[1], note=rename[2], aliases=[rename[3]])
|
||||
journals.append(journal)
|
||||
if not journals:
|
||||
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}
|
||||
|
||||
|
||||
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, 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'])} {args.category} journals; verified {result['source_rows']} source rows")
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Extract K1 journal specialties from the supplied VAK list (development only).
|
||||
|
||||
python scripts/extract_k1_specialties.py <list.pdf> [--rows-cache <json>]
|
||||
Requires pdfplumber only for regeneration; requests read the generated JSON.
|
||||
"""
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
SPECIALTIES = [
|
||||
("1.2.2", "Математическое моделирование, численные методы и комплексы программ"),
|
||||
("2.2.2", "Электронная компонентная база микро- и наноэлектроники, квантовых устройств"),
|
||||
("2.3.6", "Методы и системы защиты информации, информационная безопасность"),
|
||||
("2.3.1", "Системный анализ, управление и обработка информации, статистика"),
|
||||
("2.3.7", "Компьютерное моделирование и автоматизация проектирования"),
|
||||
("1.3.8", "Физика конденсированного состояния"),
|
||||
("2.2.15", "Системы, сети и устройства телекоммуникаций"),
|
||||
("1.1.2", "Дифференциальные уравнения и математическая физика"),
|
||||
("2.2.13", "Радиотехника, в том числе системы и устройства телевидения"),
|
||||
("2.3.2", "Вычислительные системы и их элементы"),
|
||||
("2.2.14", "Антенны, СВЧ-устройства и их технологии"),
|
||||
("2.3.5", "Математическое и программное обеспечение вычислительных систем, комплексов и компьютерных сетей"),
|
||||
]
|
||||
CODE = re.compile(r"(?<![\d.])([1-6]\.\d{1,2}\.\d{1,2})\.")
|
||||
|
||||
|
||||
def journal_key(value):
|
||||
# Administrative notes are separate from the title; do not remove ordinary parentheses.
|
||||
value = re.split(r"\s*\(\s*(?:до\s+\d|перевод)", value, flags=re.I)[0]
|
||||
return " ".join(re.findall(r"\w+", unicodedata.normalize("NFKC", value).casefold().replace("ё", "е")))
|
||||
|
||||
|
||||
def extract_rows(source):
|
||||
import pdfplumber
|
||||
rows = []
|
||||
with pdfplumber.open(source) as document:
|
||||
for number, page in enumerate(document.pages, 1):
|
||||
for table in page.find_tables():
|
||||
# Ignore tiny tables detected inside the journal-name cell.
|
||||
if table.bbox[2] - table.bbox[0] < 500:
|
||||
continue
|
||||
for row, values in zip(table.rows, table.extract()):
|
||||
columns = [""] * 5
|
||||
for cell, value in zip(row.cells, values):
|
||||
if cell and value:
|
||||
# This source uses five fixed columns; some pages contain
|
||||
# additional cell borders within those columns.
|
||||
column = sum(cell[0] >= edge - 1 for edge in (71.424, 234.387, 295.39, 498.48))
|
||||
columns[column] += ("\n" if columns[column] else "") + value
|
||||
rows.append({"page": number, "cells": columns})
|
||||
page.close()
|
||||
return rows
|
||||
|
||||
|
||||
def parse_rows(rows):
|
||||
journals = []
|
||||
for row in rows:
|
||||
cells = row["cells"]
|
||||
if len(cells) != 5:
|
||||
raise ValueError("Expected five VAK columns")
|
||||
if re.fullmatch(r"\d+\.", cells[0].strip()):
|
||||
journals.append({"row": int(cells[0].strip(". ")), "name": cells[1], "issn": cells[2],
|
||||
"pages": [], "assignments": []})
|
||||
elif not journals:
|
||||
continue # The first-page header.
|
||||
elif cells[0].strip():
|
||||
raise ValueError(f"Unexpected journal number on page {row['page']}")
|
||||
elif cells[1]:
|
||||
journals[-1]["name"] += " " + cells[1]
|
||||
journal = journals[-1]
|
||||
if row["page"] not in journal["pages"]:
|
||||
journal["pages"].append(row["page"])
|
||||
if cells[2] and not re.fullmatch(r"\d+\.", cells[0].strip()):
|
||||
journal["issn"] += " " + cells[2]
|
||||
if cells[3]:
|
||||
if cells[4]:
|
||||
dates = re.sub(r"\s+", "", cells[4]).lower().replace("c", "с")
|
||||
dates = dates.removeprefix("с")
|
||||
bounds = dates.split("по")
|
||||
if len(bounds) > 2:
|
||||
raise ValueError(f"Unrecognized VAK dates: {cells[4]}")
|
||||
def parse_date(value):
|
||||
try:
|
||||
return datetime.strptime(value, "%d.%m.%Y").date().isoformat()
|
||||
except ValueError:
|
||||
return None
|
||||
start = parse_date(bounds[0])
|
||||
end = parse_date(bounds[1]) if len(bounds) == 2 else None
|
||||
if len(bounds) == 2 and end is None:
|
||||
raise ValueError(f"Unrecognized end date: {cells[4]}")
|
||||
assignment = {"codes": [], "valid_from": start, "valid_until": end,
|
||||
"source_dates": cells[4], "pages": []}
|
||||
if start is None:
|
||||
# Preserve source typos, without inventing a historical start date.
|
||||
assignment["date_note"] = "Дата начала неполная в источнике; текущее назначение определяется по отсутствию истёкшего срока окончания в Перечне."
|
||||
journal["assignments"].append(assignment)
|
||||
elif not journal["assignments"]:
|
||||
raise ValueError(f"Missing initial dates in VAK row {journal['row']}")
|
||||
assignment = journal["assignments"][-1]
|
||||
assignment["codes"] = sorted(set(assignment["codes"]) | set(CODE.findall(cells[3])))
|
||||
if row["page"] not in assignment["pages"]:
|
||||
assignment["pages"].append(row["page"])
|
||||
elif cells[4]:
|
||||
raise ValueError("Dates without specialty text")
|
||||
if not journals or [j["row"] for j in journals] != list(range(1, len(journals) + 1)):
|
||||
raise ValueError("VAK numbering is incomplete or duplicated")
|
||||
for journal in journals:
|
||||
journal["name"] = " ".join(journal["name"].split())
|
||||
journal["issn"] = " ".join(journal["issn"].split())
|
||||
return journals
|
||||
|
||||
|
||||
def active_codes(journal, as_of):
|
||||
return sorted({code for a in journal["assignments"]
|
||||
if (a["valid_from"] is None or a["valid_from"] <= as_of)
|
||||
and (a["valid_until"] is None or as_of <= a["valid_until"])
|
||||
for code in a["codes"]})
|
||||
|
||||
|
||||
def build_reference(journals, k1, overrides, as_of):
|
||||
by_name = defaultdict(list)
|
||||
by_row = {j["row"]: j for j in journals}
|
||||
for journal in journals:
|
||||
by_name[journal_key(journal["name"])].append(journal)
|
||||
reviewed = {o["k1_name"]: o for o in overrides}
|
||||
if len(reviewed) != len(overrides) or not set(reviewed) <= {j["name"] for j in k1["journals"]}:
|
||||
raise ValueError("Invalid or stale reviewed K1 matches")
|
||||
result = []
|
||||
for journal in k1["journals"]:
|
||||
entry = {"name": journal["name"], "specialty_codes": [], "status": "not_found", "vak_entries": []}
|
||||
matches = by_name[journal_key(journal["name"])]
|
||||
if journal["name"] in reviewed:
|
||||
override = reviewed[journal["name"]]
|
||||
candidate = by_row[override["vak_row"]]
|
||||
if candidate["name"] != override["vak_name"]:
|
||||
raise ValueError("Reviewed source journal title changed")
|
||||
matches = [candidate]
|
||||
entry["match_note"] = override["reason"]
|
||||
active = [j for j in matches if active_codes(j, as_of)]
|
||||
if len(active) == 1:
|
||||
entry.update(specialty_codes=active_codes(active[0], as_of), status="matched")
|
||||
elif len(active) > 1:
|
||||
entry["status"] = "ambiguous" # Do not union different journals with identical titles.
|
||||
elif matches:
|
||||
entry["status"] = "no_current_specialties"
|
||||
entry["vak_entries"] = matches
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("--k1", type=Path, default=Path("app/data/k1_journals.json"))
|
||||
parser.add_argument("--matches", type=Path, default=Path("scripts/k1_specialty_matches.json"))
|
||||
parser.add_argument("--output", type=Path, default=Path("app/data/k1_specialties.json"))
|
||||
parser.add_argument("--rows-cache", type=Path)
|
||||
parser.add_argument("--as-of", default="2026-09-15")
|
||||
args = parser.parse_args()
|
||||
date.fromisoformat(args.as_of)
|
||||
rows = json.loads(args.rows_cache.read_text(encoding="utf-8")) if args.rows_cache else extract_rows(args.source)
|
||||
journals = parse_rows(rows)
|
||||
k1 = json.loads(args.k1.read_text(encoding="utf-8"))
|
||||
entries = build_reference(journals, k1, json.loads(args.matches.read_text(encoding="utf-8")), args.as_of)
|
||||
result = {"schema_version": 1, "source": args.source.name,
|
||||
"source_sha256": hashlib.sha256(args.source.read_bytes()).hexdigest(), "as_of": args.as_of,
|
||||
"k1_source_sha256": hashlib.sha256(args.k1.read_bytes().replace(b"\r\n", b"\n")).hexdigest(), "k1_source_hash_eol": "LF", "source_rows": len(journals),
|
||||
"specialties": [{"code": code, "name": name} for code, name in SPECIALTIES], "journals": entries}
|
||||
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
|
||||
from collections import Counter
|
||||
print(len(entries), dict(Counter(j['status'] for j in entries)))
|
||||
@@ -0,0 +1,662 @@
|
||||
[
|
||||
{
|
||||
"k1_name": "Acta Biomedica Scientifica (East Siberian Biomedical Journal)",
|
||||
"vak_row": 3,
|
||||
"vak_name": "Acta biomedica scientifica (перевод наименования на государственный язык Российской Федерации: Научный биомедицинский журнал) (До 02.10.2017 г. наименование в Перечне «Бюллетень Восточно-Сибирского научного центра СО РАМН (Bulletin of the East Siberian Scientific Center SB RAMS\" ISSN 1811-0649)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Acta Linguistica Petropolitana. Труды института лингвистических исследований",
|
||||
"vak_row": 4,
|
||||
"vak_name": "Acta Linguistica Petropolitana. Труды Института лингвистических исследований РАН",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Ars Administrandi. Искусство управления",
|
||||
"vak_row": 11,
|
||||
"vak_name": "Ars Administrandi (перевод наименование на государственный язык Российской Федерации: Искусство управления)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Iberoamerica",
|
||||
"vak_row": 1194,
|
||||
"vak_name": "Журнал \"IВЕRОАМЕRIСА\" на испанском языке (перевод названия на государственный язык Российской Федерации: ИБЕРОАМЕРИКА)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "International Journal for Computational Civil and Structural Engineering",
|
||||
"vak_row": 57,
|
||||
"vak_name": "International Journal for Computation Civil and Structural Engineering (Международный журнал по расчету гражданских и строительных конструкций)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Journal of Economic Regulation",
|
||||
"vak_row": 67,
|
||||
"vak_name": "Journal of Economic Regulation (\"Вопросы регулирования экономики\")",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Journal of Institutional Studies",
|
||||
"vak_row": 69,
|
||||
"vak_name": "Journal of Institutional Studies (\"Журнал институциональных исследований\")",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Siberian Joumal of Life Sciences and Agriculture",
|
||||
"vak_row": 145,
|
||||
"vak_name": "Siberian Journal of Life Sciences and Agriculture (перевод наименования на государственный язык Российской Федерации: Сибирский журнал медико- биологических и сельскохозяйственных исследований)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Terra Economicus",
|
||||
"vak_row": 158,
|
||||
"vak_name": "Terra Economicus («ПРОСТРАНСТВО ЭКОНОМИКИ»)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Theory and Practice of Meat Processing",
|
||||
"vak_row": 2748,
|
||||
"vak_name": "Теория и практика переработки мяса",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Turczaninowia",
|
||||
"vak_row": 166,
|
||||
"vak_name": "Turczaninowia (Турчаниновия)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Альманах Института коррекционной педагогики",
|
||||
"vak_row": 239,
|
||||
"vak_name": "Альманах Института коррекционной педагогики / Almanac Institute of special education",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Анестезиология и реаниматология (Медиа Сфера)",
|
||||
"vak_row": 253,
|
||||
"vak_name": "Анестезиология и реаниматология",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Бизнес-информатика",
|
||||
"vak_row": 325,
|
||||
"vak_name": "Бизнес-информатика Business Informatics",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Бюллетень НЦССХ им. А.Н. Бакулева РАМН. Сердечно-сосудистые заболевания",
|
||||
"vak_row": 353,
|
||||
"vak_name": "Бюллетень Научного центра сердечно-сосудистой хирургии им. А.Н. Бакулева РАМН \"Сердечно-сосудистые заболевания\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Бюллетень Почвенного института им. В.В. Докучаева",
|
||||
"vak_row": 355,
|
||||
"vak_name": "Бюллетень Почвенного института имени В.В. Докучаева",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Бюллетень сибирской медицины",
|
||||
"vak_row": 357,
|
||||
"vak_name": "Бюллетень сибирской медицины/Bulletin of Siberian Medicine",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Ведомости Научного центра экспертизы средств медицинского применения. Регуляторные исследования и экспертиза лекарственных средств",
|
||||
"vak_row": 2421,
|
||||
"vak_name": "Регуляторные исследования и экспертиза лекарственных средств (до 30.10.2024 наименование в Перечене «Ведомости Научного центра экспертизы средств медицинского применения. Регуляторные исследования и экспертиза лекарственных средств» ISSN 1991-2919)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник государственного университета морского и речного флота им. адмирала С.О. Макарова",
|
||||
"vak_row": 468,
|
||||
"vak_name": "Вестник Государственного университета морского и речного флота имени адмирала С.О. Макарова",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Ленинградского государственного университета им. А.С. Пушкина",
|
||||
"vak_row": 548,
|
||||
"vak_name": "Вестник Ленинградского государственного университета имени А.С. Пушкина",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Магнитогорского государственного технического университета им. Г.И. Носова",
|
||||
"vak_row": 554,
|
||||
"vak_name": "Вестник Магнитогорского государственного технического университета им. Г.И. Носова, Vestnik of Nosov Magnitogorsk State Technical University",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник международных организаций: образование, наука, новая экономика",
|
||||
"vak_row": 570,
|
||||
"vak_name": "Вестник международных организаций: образование, наука, новая экономика International Organisations Research Journal",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Московского университета. Серия 23: Антропология",
|
||||
"vak_row": 612,
|
||||
"vak_name": "Вестник Московского университета. Серия ХХIII. Антропология",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Московского университета. Серия 25: Международные отношения и мировая политика",
|
||||
"vak_row": 611,
|
||||
"vak_name": "Вестник Московского университета. Серия XXV. Международные отношения и мировая политика",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник НПО им. С.А. Лавочкина",
|
||||
"vak_row": 642,
|
||||
"vak_name": "Вестник «НПО имени С.А. Лавочкина»",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Пермского университета. История",
|
||||
"vak_row": 672,
|
||||
"vak_name": "Вестник Пермского университета. Серия История",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Пермского университета. Политология",
|
||||
"vak_row": 670,
|
||||
"vak_name": "Вестник Пермского университета. Серия \"Политология\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Пермского университета. Серия: Экономика",
|
||||
"vak_row": 671,
|
||||
"vak_name": "Вестник Пермского университета. Серия \"Экономика\" = Perm University Herald. ECONOMY",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Православного Свято-Тихоновского гуманитарного университета. Серия 1: Богословие. Философия. Религиоведение",
|
||||
"vak_row": 691,
|
||||
"vak_name": "Вестник Православного Свято- Тихоновского гуманитарного университета. Серия I: Богословие. Философия. Религиоведение (До 11.05.2017 г. наименование в Перечне «Вестник Православного Свято- Тихоновского гуманитарного университета. Серия 1: Богословие. Философия» ISSN 1991-640X)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник РГГУ. Серия: Политология. История. Международные отношения",
|
||||
"vak_row": 702,
|
||||
"vak_name": "Вестник РГГУ. Серия \"Политология. История. Международные отношения\" ( до 26.12.2019 г наименование в Перечне «Вестник РГГУ. Серия \"Политология. История. Международные отношения. Зарубежное регионоведение. Востоковедение\"»)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Томского государственного педагогического университета",
|
||||
"vak_row": 836,
|
||||
"vak_name": "Вестник Томского государственного педагогического университета (Tomsk State Pedagogical University Bulletin)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Ветеринария, зоотехния и биотехнология",
|
||||
"vak_row": 1926,
|
||||
"vak_name": "Научно-практический, теоретический журнал \"Ветеринария, зоотехния и биотехнология\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Власть",
|
||||
"vak_row": 2071,
|
||||
"vak_name": "Общенациональный научно- политический журнал \"Власть\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы государственного и муниципального управления",
|
||||
"vak_row": 960,
|
||||
"vak_name": "Вопросы государственного и муниципального управления. Public Administration Issues",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы литературы",
|
||||
"vak_row": 1208,
|
||||
"vak_name": "Журнал критики и литературоведения \"Вопросы литературы\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы образования",
|
||||
"vak_row": 983,
|
||||
"vak_name": "Вопросы образования Educational Studies",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы теории и практики журналистики",
|
||||
"vak_row": 1009,
|
||||
"vak_name": "Вопросы теории и практики журналистики (Theoretical and Practical Issues of Journalism)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы управления",
|
||||
"vak_row": 1011,
|
||||
"vak_name": "Вопросы управления / Management Issues",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вычислительная механика сплошных сред",
|
||||
"vak_row": 1039,
|
||||
"vak_name": "Вычислительная механика сплошных сред — Computational continuum mechanics",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вычислительные методы и программирование",
|
||||
"vak_row": 1040,
|
||||
"vak_name": "Вычислительные методы и программирование: Новые вычислительные технологии (Электронный научный журнал)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Гений ортопедии",
|
||||
"vak_row": 1047,
|
||||
"vak_name": "Гений Ортопедии ORTHOPAEDIC QENIUS",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Геология, геофизика и разработка нефтяных и газовых месторождений",
|
||||
"vak_row": 1934,
|
||||
"vak_name": "Научно-технический журнал \"Геология, геофизика и разработка нефтяных и газовых месторождений\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Гидрометеорологические исследования и прогнозы",
|
||||
"vak_row": 1067,
|
||||
"vak_name": "Гидрометеорологические исследования и прогнозы Hydrometeorological research and forecasting ( до 03.04.2019 наименование в Перечне «Труды Гидрометеорологического научно-исследовательского центра Российской Федерации» ISSN 0371-7089)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Горный информационно-аналитический бюллетень (научно-технический журнал)",
|
||||
"vak_row": 1085,
|
||||
"vak_name": "Горный информационно- аналитический бюллетень (научно-технический журнал) Mining informational and analytical bulletin (scientific and technical journal)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Государственное управление. Электронный вестник",
|
||||
"vak_row": 1093,
|
||||
"vak_name": "Государственное управление. Электронный вестник (E-journal \"Public Administration\")",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Два века русской классики",
|
||||
"vak_row": 1129,
|
||||
"vak_name": "Два века русской классики/ Two centuries of the Russian classics",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Диалог со временем",
|
||||
"vak_row": 1145,
|
||||
"vak_name": "Диалог со временем. Альманах интеллектуальной истории",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Животноводство и кормопроизводство",
|
||||
"vak_row": 1189,
|
||||
"vak_name": "Животноводство и кормопроизводство ( до 03.04.2019 наименование в перечне «Вестник мясного скотоводства (Herald of Beef Cattle Breeding)» ISSN 2079- 6250)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Журнал зарубежного законодательства и сравнительного правоведения",
|
||||
"vak_row": 1200,
|
||||
"vak_name": "Журнал зарубежного законодательства и сравнительного правоведения / Journal of Foreign Legislation and Comparative Law",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Журнал неврологии и психиатрии им. C.C. Корсакова",
|
||||
"vak_row": 1212,
|
||||
"vak_name": "Журнал неврологии и психиатрии им. С.С. Корсакова",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Журнал Сибирского федерального университета. Биология",
|
||||
"vak_row": 1224,
|
||||
"vak_name": "Журнал Сибирского федерального университета. Биология. Journal of Siberian Federal University. Biology",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Журнал Сибирского федерального университета. Серия: Гуманитарные науки",
|
||||
"vak_row": 1225,
|
||||
"vak_name": "Журнал Сибирского федерального университета. Гуманитарные науки. Journal of Siberian Federal University. Humanities & Social Sciences",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Записки Института истории материальной культуры",
|
||||
"vak_row": 1247,
|
||||
"vak_name": "Записки Института истории материальной культуры Российской академии наук",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Здоровье населения и среда обитания - ЗНиСО",
|
||||
"vak_row": 1252,
|
||||
"vak_name": "Здоровье населения и среда обитания",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Известия Тульского государственного университета. Науки о Земле",
|
||||
"vak_row": 1379,
|
||||
"vak_name": "Известия Тульского государственного университета. Серия \"Науки о Земле\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Интеграция образования",
|
||||
"vak_row": 1438,
|
||||
"vak_name": "Интеграция образования Integration of Education",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "История философии",
|
||||
"vak_row": 1518,
|
||||
"vak_name": "История философии / History of Philosophy",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Кардиологический вестник",
|
||||
"vak_row": 1531,
|
||||
"vak_name": "Кардиологический вестник - бюллетень Российского кардиологического научно- производственного комплекса",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Медицинские технологии. Оценка и выбор",
|
||||
"vak_row": 1704,
|
||||
"vak_name": "Медицинские технологии. Оценка и выбор (MEDICAL TECHNOLOGY ASSESSMENT AND CHOICE)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Мелиорация и гидротехника",
|
||||
"vak_row": 1757,
|
||||
"vak_name": "Мелиорация и гидротехника/Land Reclamation and Hydraulic Engineering (До 28.09.2021 г. наименование в Перечне «Научный журнал Российского НИИ проблем мелиорации» ISSN 2222-1816)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "МИР (Модернизация. Инновации. Развитие)",
|
||||
"vak_row": 1776,
|
||||
"vak_name": "МИР (Модернизация. Инновации. Развитие). Научно- практический журнал",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Музыкальное искусство и образование",
|
||||
"vak_row": 1834,
|
||||
"vak_name": "Музыкальное искусство и образование / Musical Art and Education (до 04.02.2020 наименование в Перечне «Вестник кафедры ЮНЕСКО \"Музыкальное искусство и образование\")",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Научно-методический журнал \"Физическая культура, спорт - наука и практика\"",
|
||||
"vak_row": 2962,
|
||||
"vak_name": "Физическая культура, спорт – наука и практика",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Научные проблемы материально-технического обеспечения Вооружённых Сил Российской Федерации",
|
||||
"vak_row": 2531,
|
||||
"vak_name": "Сборник научных трудов «Научные проблемы материально-технического обеспечения Вооруженных Сил Российской Федерации»",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Национальный психологический журнал / National Psychological Journal",
|
||||
"vak_row": 1999,
|
||||
"vak_name": "Национальный психологический журнал/ National psychologica ljournal",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Нефтепромысловое дело. Oilfiеld Engineering",
|
||||
"vak_row": 2029,
|
||||
"vak_name": "Нефтепромысловое дело. Oilfield Engineering",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Общественное здоровье и здравоохранение",
|
||||
"vak_row": 1893,
|
||||
"vak_name": "Научно – практический журнал \"Общественное здоровье и здравоохранение\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "ОРГЗДРАВ: новости. мнения. обучения. Вестник ВШОУЗ",
|
||||
"vak_row": 2108,
|
||||
"vak_name": "ОРГЗДРАВ: новости, мнения, обучение. Вестник ВШОУЗ",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Отечественные архивы",
|
||||
"vak_row": 1919,
|
||||
"vak_name": "Научно-практический журнал \"Отечественные архивы\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "П-Economy",
|
||||
"vak_row": 104,
|
||||
"vak_name": "π-Economy (до 20.12.2022 наименование издания в Перечне «Научно- технические ведомости Санкт- Петербургского государственного политехнического университета. Экономические науки = St. Petersburg State Polytechnical University Journal. Economics» ISSN 1994-2354, 2304-9774)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Педиатрия. Журнал им. Г.Н. Сперанского",
|
||||
"vak_row": 2156,
|
||||
"vak_name": "Педиатрия. Журнал имени Г.Н. Сперанского",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Петербургский исторический журнал",
|
||||
"vak_row": 2164,
|
||||
"vak_name": "Петербургский исторический журнал. Исследования по российской и всеобщей истории",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Пищевые системы",
|
||||
"vak_row": 2175,
|
||||
"vak_name": "Пищевые системы – Food systems",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Полет. Общероссийский научно-технический журнал",
|
||||
"vak_row": 2072,
|
||||
"vak_name": "Общероссийский научно- технический журнал \"Полет\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Полития: Анализ. Хроника. Прогноз (Журнал политической философии и социологии политики)",
|
||||
"vak_row": 2203,
|
||||
"vak_name": "ПОЛИТИЯ. Журнал политической философии и социологии политики",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Правосудие",
|
||||
"vak_row": 2236,
|
||||
"vak_name": "Правосудие / Justice",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Прикладная информатика",
|
||||
"vak_row": 2261,
|
||||
"vak_name": "Прикладная информатика / Journal of Applied Informatics",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Проблемы анализа риска",
|
||||
"vak_row": 1921,
|
||||
"vak_name": "Научно-практический журнал \"Проблемы анализа риска\" Scientific and Practical Journal \"Issues of Risk Analysis\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Проблемы управления",
|
||||
"vak_row": 2314,
|
||||
"vak_name": "Проблемы управления/Control Sciences",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Птица и птицепродукты",
|
||||
"vak_row": 2372,
|
||||
"vak_name": "Птица и птицепродукты / Poultry & Chicken Products",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Птицеводство",
|
||||
"vak_row": 1927,
|
||||
"vak_name": "Научно-производственный журнал \"Птицеводство\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Российский нейрохирургический журнал им. профессора А.Л. Поленова",
|
||||
"vak_row": 2473,
|
||||
"vak_name": "Российский нейрохирургический журнал имени профессора А.Л. Поленова",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Российский офтальмологический журнал",
|
||||
"vak_row": 2476,
|
||||
"vak_name": "Российский офтальмологический журнал (РОЖ)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Российский электронный журнал лучевой диагностики",
|
||||
"vak_row": 127,
|
||||
"vak_name": "Russian Electronic Journal of Radiology (Российский Электронный Журнал Лучевой диагностики)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Русистика",
|
||||
"vak_row": 2503,
|
||||
"vak_name": "Русистика/ Russian language studies (до 03.04.2019 наименование в перечне «Вестник Российского университета дружбы народов. Серия «Русский и иностранные языки и методика их преподавания» ISSN 2313-2264)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Свиноводство",
|
||||
"vak_row": 1928,
|
||||
"vak_name": "Научно-производственный журнал \"Свиноводство\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "СибСкрипт",
|
||||
"vak_row": 2577,
|
||||
"vak_name": "СибСкрипт = SibScript (до 25.04.2023 наименование издания в Перечне «Вестник Кемеровского государственного университета» ISSN 2078-8975)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Современная конкуренция",
|
||||
"vak_row": 2599,
|
||||
"vak_name": "Современная конкуренция / Journal of Modern Competition",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Социология власти",
|
||||
"vak_row": 1980,
|
||||
"vak_name": "Научный и общественно- политический журнал \"Социология власти\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Социология науки и технологий",
|
||||
"vak_row": 2667,
|
||||
"vak_name": "Социология науки и технологий Sociology of Science and Technology",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Средние века",
|
||||
"vak_row": 2684,
|
||||
"vak_name": "Средние века. Исследования по истории Средневековья и раннего Нового времени",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Техника и технология пищевых производств",
|
||||
"vak_row": 2765,
|
||||
"vak_name": "Техника и технология пищевых производств (Food Processing: Techniques and Technology)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Томский журнал лингвистических и антропологических исследований",
|
||||
"vak_row": 2788,
|
||||
"vak_name": "Томский журнал лингвистических и антропологических исследований. Tomsk Journal of Linguistics and Anthropology",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Транспорт Российской Федерации",
|
||||
"vak_row": 2802,
|
||||
"vak_name": "Транспорт Российской Федерации. Журнал о науке, практике, экономике",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Труды Зоологического института РАН",
|
||||
"vak_row": 2829,
|
||||
"vak_name": "Труды зоологического института Российской академии наук",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Труды Института государства и права Российской академии наук",
|
||||
"vak_row": 2830,
|
||||
"vak_name": "Труды Института государства и права РАН",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Труды Института математики и механики УpО РАН",
|
||||
"vak_row": 2832,
|
||||
"vak_name": "Труды Института математики и механики УрО РАН",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Труды Крыловского государственного научного центра",
|
||||
"vak_row": 2839,
|
||||
"vak_name": "Труды Крыловского государственного научного центра. Transactions Of The Krylov State Research Centre (До 29.05.2017 г. наименование в Перечне «Труды Центрального научно- исследовательского института имени академика А.Н. Крылова» ISSN 0869-8422)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Управление большими системами: сборник трудов",
|
||||
"vak_row": 2877,
|
||||
"vak_name": "Управление большими системами",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Ученые записки университета им. П.Ф. Лесгафта",
|
||||
"vak_row": 2933,
|
||||
"vak_name": "Ученые записки университета имени П.Ф. Лесгафта",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Фармация и фармакология",
|
||||
"vak_row": 2948,
|
||||
"vak_name": "Фармация и фармакология / Pharmacy&Pharmacology",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Философский журнал",
|
||||
"vak_row": 2990,
|
||||
"vak_name": "Философский журнал / Philosophy Journal",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Финансы: теория и практика",
|
||||
"vak_row": 3006,
|
||||
"vak_name": "Финансы: теория и практика / Finance: Theory and Practice (До 11.10.2017 г. наименование в Перечне «Вестник Финансового университета» ISSN 2221-1632)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Форсайт",
|
||||
"vak_row": 3012,
|
||||
"vak_name": "ФОРСАЙТ/Foresight and STI Governance",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Человек. Спорт. Медицина",
|
||||
"vak_row": 3062,
|
||||
"vak_name": "Человек. Спорт. Медицина / Human. Sport. Medicine (До 14.12.2016 г. наименование в Перечне Вестник Южно- Уральского государственного университета. Серия \"Образование, здравоохранение, физическая культура\" ISSN 1991-9786, 2410-7026)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Экология промышленного производства",
|
||||
"vak_row": 3080,
|
||||
"vak_name": "Экология промышленного производства – Межотраслевой научно-практический журнал",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Экономическая политика",
|
||||
"vak_row": 3122,
|
||||
"vak_name": "Экономическая политика (Economic Policy)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Экспериментальная и клиническая урология",
|
||||
"vak_row": 3143,
|
||||
"vak_name": "Экспериментальная и клиническая урология (Experimental and Сlinical Urology)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Эпистемология и философия науки",
|
||||
"vak_row": 40,
|
||||
"vak_name": "Epistemology & Philosophy of Sciences /Эпистемология и философия науки",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Японские исследования",
|
||||
"vak_row": 3169,
|
||||
"vak_name": "Электронный научный журнал «Японские исследования»",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Prepare a readable, indexed council JSON from the owner-supplied CSV.
|
||||
|
||||
python scripts/prepare_council_articles.py app/data/council_articles.csv
|
||||
No database access or app startup; only Python's standard library is required.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from app.services.council_source import load_reference, prepare_reference
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("--output", type=Path, default=Path("app/data/council_articles.json"))
|
||||
args = parser.parse_args()
|
||||
if args.source.resolve() == args.output.resolve():
|
||||
parser.error("Output must not overwrite the source CSV")
|
||||
result = prepare_reference(args.source)
|
||||
text = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
|
||||
with NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n", dir=args.output.parent,
|
||||
prefix=args.output.name + ".", suffix=".tmp", delete=False) as stream:
|
||||
stream.write(text)
|
||||
temporary = Path(stream.name)
|
||||
try:
|
||||
load_reference(temporary)
|
||||
temporary.replace(args.output)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
print(f"Prepared {result['source_rows']} publications and {len(result['by_author'])} author names")
|
||||
@@ -4,6 +4,7 @@ Requires Playwright/Chromium; BROWSER_EXECUTABLE may select an installed browser
|
||||
Synthetic employees and intercepted requests; no production access.
|
||||
"""
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from urllib.parse import urlsplit
|
||||
@@ -11,6 +12,7 @@ from urllib.parse import urlsplit
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from openpyxl import load_workbook
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@@ -36,11 +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
|
||||
{"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)
|
||||
@@ -54,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": [], "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)
|
||||
@@ -66,7 +70,8 @@ def main():
|
||||
url = urlsplit(route.request.url)
|
||||
response = client.get(url.path + ("?" + url.query if url.query else ""))
|
||||
route.fulfill(status=response.status_code, body=response.content,
|
||||
content_type=response.headers.get("content-type", "text/plain"))
|
||||
headers={key: value for key, value in response.headers.items()
|
||||
if key in {"content-type", "content-disposition"}})
|
||||
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
@@ -77,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")
|
||||
@@ -85,8 +93,8 @@ 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.get_by_text("Q1/Q2, К1 и RSCI не подтверждены доступными данными").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)
|
||||
page.screenshot(path=str(output / "sorting-desktop.png"), full_page=False)
|
||||
@@ -101,7 +109,7 @@ def main():
|
||||
("full_name", "desc", "Сотрудник 27"),
|
||||
("db_count", "desc", "Сотрудник 27"),
|
||||
("count", "desc", "Сотрудник 26"),
|
||||
("csv_count", "desc", "Сотрудник 26")]:
|
||||
("total_count", "desc", "Сотрудник 26")]:
|
||||
page.get_by_label("Сортировать по").select_option(sort)
|
||||
page.get_by_label("Направление").select_option(direction)
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
@@ -113,11 +121,23 @@ def main():
|
||||
expect(page.get_by_label("Сортировать по")).to_have_value("full_name")
|
||||
expect(page.get_by_label("Направление")).to_have_value("desc")
|
||||
expect(page.locator(".council__candidate-identity strong").first).to_have_text("Сотрудник 02")
|
||||
with page.expect_download() as pending_download:
|
||||
page.get_by_role("button", name="Скачать XLSX", exact=True).click()
|
||||
download = pending_download.value
|
||||
assert download.suggested_filename == "council.xlsx"
|
||||
download.save_as(str(output / "council.xlsx"))
|
||||
workbook = load_workbook(output / "council.xlsx")
|
||||
assert workbook["Сотрудники"].max_row == 29
|
||||
assert workbook["Сотрудники"]["B2"].value == "Сотрудник 27"
|
||||
page.get_by_label("Поиск по ФИО").fill("Сотрудник 27")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28")
|
||||
expect(page.get_by_label("Сортировать по")).to_have_value("full_name")
|
||||
expect(page.get_by_label("Направление")).to_have_value("desc")
|
||||
with page.expect_download() as pending_download:
|
||||
page.get_by_role("button", name="Скачать XLSX", exact=True).click()
|
||||
filtered = load_workbook(BytesIO(Path(pending_download.value.path()).read_bytes()))
|
||||
assert filtered["Сотрудники"].max_row == 2
|
||||
page.get_by_label("Поиск по ФИО").fill("Нет совпадений")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_text("Сотрудники не найдены", exact=True)).to_be_visible()
|
||||
@@ -125,6 +145,35 @@ def main():
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 1–25 из 28")
|
||||
expect(page.get_by_label("Сортировать по")).to_have_value("full_name")
|
||||
expect(page.get_by_label("Направление")).to_have_value("desc")
|
||||
page.get_by_label("Специальность ВАК").select_option("1.2.2")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28")
|
||||
expect(page.locator(".council__candidate-identity strong")).to_have_text("Сотрудник 27")
|
||||
page.locator(".council__candidate-summary").click()
|
||||
expect(page.get_by_text("Публикаций по выбранной специальности: 1", exact=True)).to_be_visible()
|
||||
expect(page.get_by_text("По специальности 1.2.2:", exact=False)).to_be_visible()
|
||||
for width in (1440, 768, 390, 320):
|
||||
page.set_viewport_size({"width": width, "height": 900})
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), width
|
||||
page.screenshot(path=str(output / f"specialty-{width}.png"), full_page=False)
|
||||
with page.expect_download() as pending_download:
|
||||
page.get_by_role("button", name="Скачать XLSX", exact=True).click()
|
||||
filtered = load_workbook(BytesIO(Path(pending_download.value.path()).read_bytes()))
|
||||
assert filtered["Сотрудники"].max_row == 2
|
||||
assert dict(filtered["Параметры"].values)["Специальность ВАК"].startswith("1.2.2.")
|
||||
headers = {cell.value: cell.column for cell in filtered["Сотрудники"][1]}
|
||||
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.12.0"
|
||||
assert response.json()["backend_version"] == "0.18.1"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
|
||||
+378
-22
@@ -1,28 +1,37 @@
|
||||
import csv
|
||||
from datetime import date
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import Employee, EmployeePublication
|
||||
from app.services import council
|
||||
from app.services.council_export import _append
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def export(monkeypatch, tmp_path):
|
||||
path = tmp_path / "articles.csv"
|
||||
prepared = tmp_path / "articles.json"
|
||||
def write(rows):
|
||||
fields = ["Название публикации", "Год", "DOI", "EID (Scopus)", "UT (WoS)",
|
||||
fields = ["Название публикации", "Год", "DOI", "EID (Scopus)", "UT (WoS)", "Название журнала",
|
||||
"Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"]
|
||||
with path.open("w", encoding="utf-8-sig", newline="") as output:
|
||||
writer = csv.DictWriter(output, fieldnames=fields, delimiter=";")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
from app.services.council_source import prepare_reference
|
||||
import json
|
||||
prepared.write_text(json.dumps(prepare_reference(path), ensure_ascii=False), encoding="utf-8")
|
||||
council._articles_by_author.cache_clear()
|
||||
monkeypatch.setattr(council, "EXPORT", path)
|
||||
monkeypatch.setattr(council, "EXPORT", prepared)
|
||||
write([])
|
||||
yield write
|
||||
council._articles_by_author.cache_clear()
|
||||
@@ -58,25 +67,26 @@ def test_council_union_counts_provenance_and_types(db_session, export):
|
||||
publication_id="100", url="https://publications.hse.ru/view/100")
|
||||
publication(db_session, person, title=" shared WORK ", kind="ARTICLE")
|
||||
publication(db_session, person, title="Book only", kind="BOOK", year=2020)
|
||||
publication(db_session, person, title="Imported", raw_data={"csv_enrichment": {"Квартиль wos": "Q2"}})
|
||||
publication(db_session, person, title="Imported", doi="https://doi.org/10.1234/imported",
|
||||
raw_data={"csv_enrichment": {"Квартиль wos": "Q2", "DOI": "10.1234/imported"}})
|
||||
db_session.commit()
|
||||
result = council.council_candidates(db_session, date(2026, 9, 30))
|
||||
assert result["total"] == 1
|
||||
candidate = result["candidates"][0]
|
||||
assert (candidate["csv_count"], candidate["db_count"], candidate["count"]) == (2, 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 not imported["csv_qualifies"]
|
||||
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"),
|
||||
@@ -94,10 +104,13 @@ 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 next(c for c in items if c["name"] == "Доктор")["csv_count"] == 1
|
||||
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"] == "Две степени")["csv_count"] == 0
|
||||
assert next(c for c in items if c["name"] == "Две степени")["total_count"] == 0
|
||||
assert next(c for c in items if c["name"] == "Уволен")["dismissed"]
|
||||
|
||||
|
||||
@@ -108,7 +121,7 @@ def test_no_false_merge_when_title_has_conflicting_dois(db_session, export):
|
||||
db_session.commit()
|
||||
item = council.council_candidates(db_session, date(2026, 9, 30))["candidates"][0]
|
||||
assert item["count"] == 3
|
||||
assert item["csv_count"] == 3 and item["db_count"] == 1
|
||||
assert item["total_count"] == 3 and item["db_count"] == 1
|
||||
|
||||
|
||||
def test_csv_same_name_does_not_attribute_to_wrong_employee(db_session, export):
|
||||
@@ -119,7 +132,7 @@ def test_csv_same_name_does_not_attribute_to_wrong_employee(db_session, export):
|
||||
db_session.commit()
|
||||
items = council.council_candidates(db_session, date(2026, 9, 30))["candidates"]
|
||||
assert len(items) == 2
|
||||
assert all(c["ambiguous"] and c["csv_count"] == 0 and c["count"] == 1 for c in items)
|
||||
assert all(c["ambiguous"] and c["total_count"] == 0 and c["count"] == 1 for c in items)
|
||||
|
||||
|
||||
def test_missing_database_year_merges_only_unambiguous_title_and_non_article_excluded(db_session, export):
|
||||
@@ -129,10 +142,10 @@ def test_missing_database_year_merges_only_unambiguous_title_and_non_article_exc
|
||||
publication(db_session, person, title="Article", year=None, kind="ARTICLE")
|
||||
db_session.commit()
|
||||
candidate = council.council_candidates(db_session, date(2026, 9, 30))["candidates"][0]
|
||||
assert (candidate["count"], candidate["csv_count"], candidate["db_count"]) == (2, 1, 2)
|
||||
assert (candidate["count"], candidate["total_count"], candidate["db_count"]) == (2, 1, 2)
|
||||
book = next(a for a in candidate["articles"] if a["type_label"] == "Книга")
|
||||
assert not book["csv_qualifies"]
|
||||
assert "не включён в счётчик CSV" in book["criteria"][-1]
|
||||
assert not book["qualifies"]
|
||||
assert "не включён в счётчики Q1/Q2/К1" in book["criteria"][-1]
|
||||
|
||||
|
||||
def test_search_and_pagination_include_all_workers(db_session, export):
|
||||
@@ -166,7 +179,7 @@ def test_sort_by_name_and_each_unique_publication_count(db_session, export):
|
||||
db_session.commit()
|
||||
expected = {
|
||||
"full_name": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Борис", "Анна"]},
|
||||
"csv_count": {"asc": ["Вера", "Борис", "Анна"], "desc": ["Анна", "Борис", "Вера"]},
|
||||
"total_count": {"asc": ["Вера", "Борис", "Анна"], "desc": ["Анна", "Борис", "Вера"]},
|
||||
"db_count": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Борис", "Анна"]},
|
||||
"count": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Анна", "Борис"]},
|
||||
}
|
||||
@@ -175,8 +188,8 @@ def test_sort_by_name_and_each_unique_publication_count(db_session, export):
|
||||
result = council.council_candidates(db_session, sort=sort, direction=direction)
|
||||
assert [c["name"] for c in result["candidates"]] == names
|
||||
invalid = council.council_candidates(db_session, sort="unknown", direction="unknown")
|
||||
assert (invalid["sort"], invalid["direction"]) == ("csv_count", "desc")
|
||||
assert [c["name"] for c in invalid["candidates"]] == expected["csv_count"]["desc"]
|
||||
assert (invalid["sort"], invalid["direction"]) == ("total_count", "desc")
|
||||
assert [c["name"] for c in invalid["candidates"]] == expected["total_count"]["desc"]
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
response = TestClient(app).get("/admin/council?sort=full_name&direction=desc")
|
||||
@@ -200,13 +213,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 "CSV по критериям" in response.text and "В БД" in response.text and "Всего без дублей" 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.12.0 · Frontend 0.12.0" in response.text
|
||||
assert "Backend 0.18.1 · Frontend 0.18.1" 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
|
||||
@@ -227,3 +240,346 @@ def test_public_error_state_missing_export_and_database_failure(db_session, expo
|
||||
assert response.status_code == 503 and "private error" not in response.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db_session, export):
|
||||
export([row(author="Петров 27"), row(author="Петров 27", **{"Квартиль wos": "Q2"})])
|
||||
for number in range(28):
|
||||
person = employee(db_session, f"Петров {number:02d}", key=f"xlsx-{number}")
|
||||
publication(db_session, person, title="Shared work" if number == 27 else f"Work {number}",
|
||||
doi="https://doi.org/10.1234/shared" if number == 27 else None,
|
||||
kind="ARTICLE", publication_id=str(number), url=f"https://publications.hse.ru/view/{number}")
|
||||
excluded = employee(db_session, "Без степени", degree=None, key="excluded")
|
||||
publication(db_session, excluded)
|
||||
db_session.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/admin/council/export.xlsx?q=Петров&sort=full_name&direction=desc&offset=25")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
assert response.headers["content-disposition"] == 'attachment; filename="council.xlsx"'
|
||||
book = load_workbook(BytesIO(response.content))
|
||||
assert book.sheetnames == ["Сотрудники", "Публикации", "Параметры"]
|
||||
people, works = book["Сотрудники"], book["Публикации"]
|
||||
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 == 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 "Данные ВШЭ" 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"
|
||||
assert works.auto_filter.ref == works.dimensions
|
||||
text = " ".join(str(cell.value) for sheet in book for row in sheet for cell in row)
|
||||
assert "private@example.org" not in text and "Без степени" not in text
|
||||
assert load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=Петров%2027").content))["Сотрудники"].max_row == 2
|
||||
empty = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=missing").content))
|
||||
assert empty["Сотрудники"].max_row == 1 and empty["Публикации"].max_row == 1
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_xlsx_treats_profile_text_as_text_and_rejects_silent_truncation(db_session, export):
|
||||
person = employee(db_session, '=HYPERLINK("https://example.org","name")')
|
||||
publication(db_session, person, title="=1+1\x01")
|
||||
publication(db_session, person, title="#N/A")
|
||||
db_session.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/admin/council/export.xlsx")
|
||||
assert response.status_code == 200
|
||||
book = load_workbook(BytesIO(response.content))
|
||||
assert book["Сотрудники"]["B2"].value == person.full_name
|
||||
assert book["Сотрудники"]["B2"].data_type == "s"
|
||||
titles = {book["Публикации"].cell(n, 3).value: book["Публикации"].cell(n, 3).data_type for n in [2, 3]}
|
||||
assert titles == {"=1+1": "s", "#N/A": "s"}
|
||||
with ZipFile(BytesIO(response.content)) as archive:
|
||||
assert all(b"<f>" not in archive.read(n) for n in archive.namelist() if n.startswith("xl/worksheets/"))
|
||||
person.full_name = "Long " + "x" * 32768
|
||||
db_session.commit()
|
||||
failure = client.get("/admin/council/export.xlsx")
|
||||
assert failure.status_code == 503 and "Не удалось создать XLSX" in failure.text
|
||||
assert "content-disposition" not in failure.headers
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_xlsx_appending_rows_does_not_scan_growing_sheet(monkeypatch):
|
||||
def fail_on_scan(sheet):
|
||||
pytest.fail("Appending rows must not recompute worksheet bounds by scanning existing cells")
|
||||
monkeypatch.setattr(Worksheet, "max_row", property(fail_on_scan))
|
||||
monkeypatch.setattr(Worksheet, "max_column", property(fail_on_scan))
|
||||
sheet = Workbook().active
|
||||
for number in range(1000):
|
||||
_append(sheet, [number, "text"])
|
||||
assert sheet.cell(1000, 1).value == 999
|
||||
assert sheet.cell(1000, 2).value == "text"
|
||||
|
||||
|
||||
def test_category_counts_union_k1_journal_matching_and_period(db_session, export):
|
||||
export([
|
||||
row(title="All categories", doi="10.1234/all", **{"Квартиль wos": "Q2", "Название журнала": "Бизнес-информатика"}),
|
||||
row(title="All categories", doi="10.1234/all"),
|
||||
row(title="Q2 only", doi="10.1234/q2", **{"Квартиль scopus": "", "Квартиль wos": "Q2"}),
|
||||
row(title="Old K1", doi="10.1234/old", year="2020", **{"Квартиль scopus": "", "Название журнала": "Бизнес-информатика"}),
|
||||
row(title="Unknown year", doi="10.1234/unknown", year="", **{"Название журнала": "Бизнес-информатика"}),
|
||||
row(title="Future", doi="10.1234/future", year="2027"),
|
||||
row(title="Known book", doi="10.1234/book", **{"Название журнала": "Бизнес-информатика"}),
|
||||
])
|
||||
person = employee(db_session)
|
||||
publication(db_session, person, title="All categories", doi="https://doi.org/10.1234/all", kind="ARTICLE")
|
||||
publication(db_session, person, title="Known book", doi="https://doi.org/10.1234/book", kind="BOOK")
|
||||
publication(db_session, person, title="K1 only", kind="ARTICLE",
|
||||
description={"short": {"ru": "<b>Бизнес-информатика</b>. 2024. № 2."}})
|
||||
publication(db_session, person, title="Similar journal", kind="ARTICLE",
|
||||
description={"short": {"ru": "Бизнес-информатика и технологии. 2024."}})
|
||||
publication(db_session, person, title="Mention in title is not journal Бизнес-информатика", kind="ARTICLE")
|
||||
db_session.commit()
|
||||
candidate = council.council_candidates(db_session, date(2026, 10, 8))["candidates"][0]
|
||||
assert [candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "total")] == [1, 2, 2, 3]
|
||||
assert candidate["count"] == 9
|
||||
works = {article["title"]: article for article in candidate["articles"]}
|
||||
assert works["All categories"]["q1"] and works["All categories"]["q2"] and works["All categories"]["k1"]
|
||||
assert works["K1 only"]["k1"] and "К1 · Бизнес-информатика (справочник ВАК)" in works["K1 only"]["criteria"]
|
||||
for title in ("Old K1", "Unknown year", "Future", "Known book", "Similar journal"):
|
||||
assert not works[title]["qualifies"]
|
||||
|
||||
|
||||
def test_k1_reference_integrity():
|
||||
import json
|
||||
data = json.loads(council.K1_JOURNALS.read_text(encoding="utf-8"))
|
||||
assert data["source_rows"] == 3134
|
||||
assert sum(data["category_counts"].values()) == 3134
|
||||
assert data["category_counts"]["К1"] == len(data["journals"]) == 773
|
||||
assert len(council._k1_journals()) == 774 # 773 journals and one documented previous name.
|
||||
assert "вестник российских университетов математика" not in council._k1_journals()
|
||||
assert "бизнес информатика" in council._k1_journals()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("journal", [
|
||||
"Ученые записки Казанской академии ветеринарной медицины им. Н.Э. Баумана",
|
||||
"Ученые записки Казанской государственной академии ветеринарной медицины им. Н.Э. Баумана",
|
||||
])
|
||||
@pytest.mark.parametrize("source", ["csv", "journal", "description"])
|
||||
def test_renamed_k1_journal_matches_current_and_documented_previous_name(db_session, export, journal, source):
|
||||
person = employee(db_session)
|
||||
if source == "csv":
|
||||
export([row(**{"Квартиль scopus": "", "Название журнала": journal})])
|
||||
else:
|
||||
fields = {"raw_data": {"journal": journal}} if source == "journal" else {
|
||||
"description": {"short": {"ru": f"{journal}. 2024. № 1."}}
|
||||
}
|
||||
publication(db_session, person, kind="ARTICLE", **fields)
|
||||
db_session.commit()
|
||||
result = council.council_candidates(db_session, date(2026, 10, 8))
|
||||
candidate = result["candidates"][0]
|
||||
assert [candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "total")] == [0, 0, 1, 1]
|
||||
canonical = "Ученые записки Казанской академии ветеринарной медицины им. Н.Э. Баумана"
|
||||
assert f"К1 · {canonical} (справочник ВАК)" in candidate["articles"][0]["criteria"]
|
||||
from app.services.council_export import council_xlsx
|
||||
book = load_workbook(BytesIO(council_xlsx(result)))
|
||||
assert [book["Сотрудники"].cell(2, column).value for column in (6, 7)] == [1, 1]
|
||||
|
||||
|
||||
def test_k1_rename_note_is_preserved_separately():
|
||||
import json
|
||||
data = json.loads(council.K1_JOURNALS.read_text(encoding="utf-8"))
|
||||
journal = next(item for item in data["journals"] if item["source_row"] == 699)
|
||||
assert "наименование в Перечне" not in journal["name"]
|
||||
assert journal["note"].startswith("до 10.04.2026 г. наименование в Перечне")
|
||||
assert len(journal["aliases"]) == 1
|
||||
assert council._k1_journals()[council._journal_key(journal["aliases"][0])] == journal["name"]
|
||||
bilingual = next(item for item in data["journals"] if item["source_row"] == 2)
|
||||
assert bilingual["name"] == "Acta Biomedica Scientifica (East Siberian Biomedical Journal)"
|
||||
|
||||
|
||||
def test_ambiguous_k1_alias_is_rejected(monkeypatch, tmp_path):
|
||||
import json
|
||||
reference = tmp_path / "k1.json"
|
||||
reference.write_text(json.dumps({"journals": [
|
||||
{"name": "One", "aliases": ["Previous"]}, {"name": "Two", "aliases": ["Previous"]}
|
||||
]}), encoding="utf-8")
|
||||
monkeypatch.setattr(council, "K1_JOURNALS", reference)
|
||||
council._k1_journals.cache_clear()
|
||||
try:
|
||||
with pytest.raises(ValueError, match="Ambiguous K1 journal alias"):
|
||||
council._k1_journals()
|
||||
finally:
|
||||
council._k1_journals.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reverse", [False, True])
|
||||
def test_conflicting_source_years_keep_categories_with_their_year(db_session, export, reverse):
|
||||
rows = [row(year="2020", **{"Название журнала": "Бизнес-информатика"}),
|
||||
row(year="2024", **{"Квартиль scopus": "Q2"})]
|
||||
export(list(reversed(rows)) if reverse else rows)
|
||||
person = employee(db_session)
|
||||
# Evidence with no year must not inherit an arbitrary conflicting year.
|
||||
publication(db_session, person, doi="https://doi.org/10.1234/shared", year=None,
|
||||
raw_data={"csv_enrichment": {"Квартиль scopus": "Q1", "Журнал": "Бизнес-информатика"}})
|
||||
db_session.commit()
|
||||
candidate = council.council_candidates(db_session, date(2026, 10, 8))["candidates"][0]
|
||||
assert [candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "total")] == [0, 1, 0, 1]
|
||||
assert candidate["count"] == 1
|
||||
article = candidate["articles"][0]
|
||||
assert article["year"] is None
|
||||
assert any("Годы источников расходятся: 2020, 2024" in criterion for criterion in article["criteria"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sort", ["q1_count", "q2_count", "k1_count", "total_count"])
|
||||
@pytest.mark.parametrize("direction", ["asc", "desc"])
|
||||
def test_sort_category_counters_and_xlsx(db_session, export, sort, direction):
|
||||
export([row(author="Анна", **{"Квартиль wos": "Q2", "Название журнала": "Бизнес-информатика"})])
|
||||
employee(db_session, "Анна", key="anna")
|
||||
person = employee(db_session, "Борис", key="boris")
|
||||
publication(db_session, person, title="Without criteria")
|
||||
db_session.commit()
|
||||
result = council.council_candidates(db_session, date(2026, 10, 8), sort=sort, direction=direction)
|
||||
expected = ["Борис", "Анна"] if direction == "asc" else ["Анна", "Борис"]
|
||||
assert [candidate["name"] for candidate in result["candidates"]] == expected
|
||||
from app.services.council_export import council_xlsx
|
||||
book = load_workbook(BytesIO(council_xlsx(result)))
|
||||
headers = [cell.value for cell in book["Сотрудники"][1]]
|
||||
assert headers[3:7] == ["Q1", "Q2", "К1", "ВСЕГО"]
|
||||
assert "CSV по критериям" not in headers
|
||||
anna_row = 3 if direction == "asc" else 2
|
||||
assert [book["Сотрудники"].cell(anna_row, column).value for column in range(4, 8)] == [1, 1, 1, 1]
|
||||
|
||||
|
||||
def test_specialty_filters_use_eligible_source_journal_not_merged_evidence(db_session, export):
|
||||
export([
|
||||
row(title="Matching", doi="10.1234/matching", **{"Название журнала": "Бизнес-информатика"}),
|
||||
row(title="Matching", doi="10.1234/matching"),
|
||||
row(title="Old journal, new Q1", doi="10.1234/conflict", year="2020", **{"Название журнала": "Бизнес-информатика"}),
|
||||
row(title="Old journal, new Q1", doi="10.1234/conflict", year="2024"),
|
||||
row(title="Book", doi="10.1234/book", **{"Название журнала": "Бизнес-информатика"}),
|
||||
row(title="Unknown year", doi="10.1234/unknown", year="", **{"Название журнала": "Бизнес-информатика"}),
|
||||
])
|
||||
person = employee(db_session)
|
||||
publication(db_session, person, title="Book", doi="10.1234/book", kind="BOOK")
|
||||
publication(db_session, person, title="Unknown year", doi="10.1234/unknown", year=None)
|
||||
other = employee(db_session, name="Другой доктор", key="other")
|
||||
publication(db_session, other, title="Q1 alone", raw_data={"csv_enrichment": {"Квартиль scopus": "Q1"}})
|
||||
db_session.commit()
|
||||
result = council.council_candidates(db_session, date(2026, 10, 8), specialty="1.2.2")
|
||||
assert (result["total"], result["matched"]) == (2, 1)
|
||||
candidate = result["candidates"][0]
|
||||
assert candidate["specialty_count"] == 1
|
||||
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
|
||||
with pytest.raises(council.CouncilInputError):
|
||||
council.council_candidates(db_session, specialty="<script>")
|
||||
|
||||
|
||||
def test_specialty_page_pagination_search_and_export_agree(db_session, export):
|
||||
for number in range(28):
|
||||
person = employee(db_session, name=f"Доктор {number:02d}", key=str(number))
|
||||
publication(db_session, person, title=f"Work {number}", kind="ARTICLE",
|
||||
raw_data={"journal": "Бизнес-информатика"})
|
||||
person = employee(db_session, name="Не подходит", key="unrelated")
|
||||
publication(db_session, person, raw_data={"journal": "Неизвестный журнал"})
|
||||
db_session.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/admin/council?specialty=1.2.2&sort=full_name&direction=asc")
|
||||
assert response.status_code == 200
|
||||
assert '<option value="1.2.2" selected>' in response.text
|
||||
assert response.text.count('<option value="2.3.5"') == 1
|
||||
assert "Публикаций по выбранной специальности: 1" in response.text
|
||||
assert "Не подходит" not in response.text
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
next_page = soup.find("a", string="Далее")["href"]
|
||||
assert "specialty=1.2.2" in next_page
|
||||
assert "Доктор 27" in client.get("/admin/council" + next_page).text
|
||||
filtered = client.get("/admin/council?specialty=1.2.2&q=27")
|
||||
assert "Показаны 1–1 из 1" in filtered.text
|
||||
book = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?specialty=1.2.2&sort=full_name&direction=asc").content))
|
||||
assert book["Сотрудники"].max_row == 29
|
||||
params = dict(book["Параметры"].values)
|
||||
assert params["Специальность ВАК"].startswith("1.2.2.")
|
||||
assert "По специальности 1.2.2" in book["Публикации"]["G2"].value
|
||||
empty = client.get("/admin/council?specialty=2.2.14")
|
||||
assert "Сотрудники не найдены" in empty.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_specialty_reference_integrity():
|
||||
import hashlib
|
||||
import json
|
||||
from scripts.extract_k1_specialties import active_codes
|
||||
data = json.loads(council.K1_SPECIALTIES.read_text(encoding="utf-8"))
|
||||
k1 = json.loads(council.K1_JOURNALS.read_text(encoding="utf-8"))
|
||||
assert data["k1_source_sha256"] == hashlib.sha256(council.K1_JOURNALS.read_bytes().replace(b"\r\n", b"\n")).hexdigest()
|
||||
assert [j["name"] for j in data["journals"]] == [j["name"] for j in k1["journals"]]
|
||||
assert len(data["journals"]) == 773 and len(data["specialties"]) == 12
|
||||
assert data["source_rows"] == 3231
|
||||
for journal in data["journals"]:
|
||||
assert journal["vak_entries"]
|
||||
if journal["status"] == "matched":
|
||||
active = [entry for entry in journal["vak_entries"] if active_codes(entry, data["as_of"])]
|
||||
assert len(active) == 1
|
||||
assert journal["specialty_codes"] == active_codes(active[0], data["as_of"])
|
||||
else:
|
||||
assert journal["status"] == "ambiguous" and not journal["specialty_codes"]
|
||||
for specialty in data["specialties"]:
|
||||
assert any(specialty["code"] in j["specialty_codes"] for j in data["journals"])
|
||||
|
||||
|
||||
def test_xlsx_specialties_are_explicit_with_and_without_filter(db_session, export):
|
||||
export([row(title="Old journal", doi="10.1234/conflict", year="2020", **{"Название журнала": "Бизнес-информатика"}),
|
||||
row(title="Old journal", doi="10.1234/conflict", year="2024")])
|
||||
person = employee(db_session)
|
||||
for title in ['Matching', 'Second matching']:
|
||||
publication(db_session, person, title=title, kind='ARTICLE', raw_data={'journal': 'Бизнес-информатика'})
|
||||
publication(db_session, person, title='Book', kind='BOOK', raw_data={'journal': 'Бизнес-информатика'})
|
||||
publication(db_session, person, title='Unknown journal', kind='ARTICLE', raw_data={'journal': 'Other'})
|
||||
other = employee(db_session, name='Без соответствия', key='other')
|
||||
publication(db_session, other, title='Unknown', kind='ARTICLE')
|
||||
db_session.commit()
|
||||
from app.services.council_export import council_xlsx
|
||||
for specialty in ('', '1.2.2'):
|
||||
result = council.council_candidates(db_session, date(2026, 10, 8), specialty=specialty, paginate=False)
|
||||
book = load_workbook(BytesIO(council_xlsx(result)))
|
||||
staff = list(book['Сотрудники'].values)
|
||||
works = list(book['Публикации'].values)
|
||||
candidate = next(dict(zip(staff[0], values)) for values in staff[1:] if values[1] == person.full_name)
|
||||
assert candidate['Специальности ВАК'].count('1.2.2.') == 1
|
||||
assert 'Математическое моделирование' in candidate['Специальности ВАК']
|
||||
assert '2.3.5.' in candidate['Специальности ВАК']
|
||||
assert '5.2.2.' not in candidate['Специальности ВАК'] # Only the site's twelve specialties.
|
||||
assert candidate['Работ по выбранной специальности'] == (2 if specialty else None)
|
||||
records = {values[2]: dict(zip(works[0], values)) for values in works[1:]}
|
||||
assert '1.2.2.' in records['Matching']['Специальности ВАК']
|
||||
assert records['Matching']['По выбранной специальности'] == ('Да' if specialty else None)
|
||||
for title in ('Book', 'Unknown journal', 'Old journal'):
|
||||
assert records[title]['Специальности ВАК'] == 'Не подтверждены'
|
||||
assert records[title]['По выбранной специальности'] == ('Нет' if specialty else None)
|
||||
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'W{len(staff)}')
|
||||
assert book['Публикации'].auto_filter.ref.endswith(f'AA{len(works)}')
|
||||
|
||||
|
||||
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):
|
||||
pytest.fail('Ineligible employee publications must not be parsed')
|
||||
monkeypatch.setattr(council, '_database_article', unexpected)
|
||||
assert council.council_candidates(db_session)['total'] == 0
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,124 @@
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import council
|
||||
from app.services.council_source import load_reference, prepare_reference
|
||||
|
||||
|
||||
def write_csv(path, rows):
|
||||
fields = ['Название публикации', 'Год', 'DOI', 'Авторы НИУ ВШЭ', 'Квартиль scopus', 'Квартиль wos', 'Название журнала', 'EID (Scopus)']
|
||||
with path.open('w', encoding='utf-8-sig', newline='') as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=fields, delimiter=';')
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def test_prepared_reference_preserves_quoted_fields_metadata_and_matching(tmp_path):
|
||||
source = tmp_path / 'source.csv'
|
||||
write_csv(source, [
|
||||
{'Название публикации': 'Quoted; title\ncontinuation', 'Год': '2024', 'DOI': 'https://doi.org/10.1234/TEST',
|
||||
'Авторы НИУ ВШЭ': ' Ёлкин Пётр; ЕЛКИН ПЕТР; Другой автор ', 'Квартиль scopus': ' q1 ',
|
||||
'Квартиль wos': 'Q2', 'Название журнала': 'Бизнес-информатика', 'EID (Scopus)': 'source-id'},
|
||||
{'Название публикации': '', 'Авторы НИУ ВШЭ': 'Другой автор'},
|
||||
{'Название публикации': 'Unknown', 'Год': '', 'DOI': 'invalid', 'Авторы НИУ ВШЭ': ''},
|
||||
])
|
||||
before = source.read_bytes()
|
||||
data = prepare_reference(source)
|
||||
assert source.read_bytes() == before
|
||||
assert data['source_rows'] == 3
|
||||
assert data['source_sha256'] == hashlib.sha256(before.replace(b'\r\n', b'\n')).hexdigest()
|
||||
assert data['by_author'] == {'другой автор': [0], 'елкин петр': [0]}
|
||||
record = data['publications'][0]
|
||||
assert record['title'] == 'Quoted; title\ncontinuation'
|
||||
assert record['doi'] == '10.1234/test' and record['year'] == 2024
|
||||
assert record['quartiles'] == ['Scopus Q1', 'Web of Science Q2']
|
||||
assert record['journals'] == ['Бизнес-информатика']
|
||||
assert record['metadata']['EID (Scopus)'] == 'source-id'
|
||||
output = tmp_path / 'data.json'
|
||||
output.write_text(json.dumps(data, ensure_ascii=False), encoding='utf-8')
|
||||
articles = load_reference(output)
|
||||
assert articles['елкин петр'][0]['rows'] == [2]
|
||||
assert articles['другой автор'][0]['title'] == record['title']
|
||||
assert len(articles['елкин петр']) == 1
|
||||
assert articles['елкин петр'][0]['origins'] == ['Данные ВШЭ']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change', ['author', 'index', 'bool_index', 'year', 'schema', 'source_row', 'list'])
|
||||
def test_corrupt_prepared_reference_is_rejected(tmp_path, change):
|
||||
source = tmp_path / 'source.csv'
|
||||
write_csv(source, [{'Название публикации': 'First', 'Авторы НИУ ВШЭ': 'Author'},
|
||||
{'Название публикации': 'Second', 'Авторы НИУ ВШЭ': 'Other'}])
|
||||
data = prepare_reference(source)
|
||||
if change == 'author': data['by_author'] = {'stranger': [0], 'other': [1]}
|
||||
if change == 'index': data['by_author']['author'] = [3]
|
||||
if change == 'bool_index': data['by_author']['other'] = [True]
|
||||
if change == 'year': data['publications'][0]['year'] = True
|
||||
if change == 'schema': data['schema_version'] = True
|
||||
if change == 'source_row': data['publications'][0]['source_row'] = 77
|
||||
if change == 'list': data['publications'][0]['journals'] = 'Journal'
|
||||
path = tmp_path / 'data.json'
|
||||
path.write_text(json.dumps(data), encoding='utf8')
|
||||
with pytest.raises(ValueError): load_reference(path)
|
||||
|
||||
|
||||
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'] == 1000 and len(data['by_author']) == 776
|
||||
calls = []
|
||||
original = council.load_reference
|
||||
def counted(path):
|
||||
calls.append(path)
|
||||
return original(path)
|
||||
monkeypatch.setattr(council, 'load_reference', counted)
|
||||
council._articles_by_author.cache_clear()
|
||||
try:
|
||||
first = council._articles_by_author()
|
||||
assert council._articles_by_author() is first
|
||||
assert calls == [council.EXPORT]
|
||||
finally:
|
||||
council._articles_by_author.cache_clear()
|
||||
|
||||
|
||||
def test_converter_does_not_replace_output_when_input_is_invalid(tmp_path):
|
||||
import subprocess
|
||||
import sys
|
||||
source = tmp_path / 'invalid.csv'
|
||||
source.write_text('Wrong header\nvalue\n', encoding='utf8')
|
||||
output = tmp_path / 'prepared.json'
|
||||
output.write_text('previous reference', encoding='utf8')
|
||||
result = subprocess.run([sys.executable, 'scripts/prepare_council_articles.py', str(source), '--output', str(output)],
|
||||
capture_output=True, text=True)
|
||||
assert result.returncode != 0
|
||||
assert output.read_text(encoding='utf8') == 'previous reference'
|
||||
assert not list(tmp_path.glob('*.tmp'))
|
||||
same = subprocess.run([sys.executable, 'scripts/prepare_council_articles.py', str(source), '--output', str(source)],
|
||||
capture_output=True, text=True)
|
||||
assert same.returncode != 0 and source.read_text(encoding='utf8') == 'Wrong header\nvalue\n'
|
||||
|
||||
|
||||
def test_preparation_is_identical_for_lf_and_crlf_sources(tmp_path):
|
||||
lf_dir = tmp_path / 'lf'
|
||||
crlf_dir = tmp_path / 'crlf'
|
||||
lf_dir.mkdir()
|
||||
crlf_dir.mkdir()
|
||||
lf = lf_dir / 'source.csv'
|
||||
crlf = crlf_dir / 'source.csv'
|
||||
write_csv(crlf, [{'Название публикации': 'Multiline\npublication', 'Год': '2024',
|
||||
'Авторы НИУ ВШЭ': 'Author; Coauthor', 'Квартиль scopus': 'Q1'}])
|
||||
canonical = crlf.read_bytes().replace(b'\r\n', b'\n')
|
||||
lf.write_bytes(canonical)
|
||||
crlf.write_bytes(canonical.replace(b'\n', b'\r\n'))
|
||||
before = crlf.read_bytes()
|
||||
from_lf = prepare_reference(lf)
|
||||
from_crlf = prepare_reference(crlf)
|
||||
assert from_lf == from_crlf
|
||||
assert from_crlf['source_sha256'] == hashlib.sha256(canonical).hexdigest()
|
||||
assert from_crlf['source_hash_eol'] == 'LF'
|
||||
assert from_crlf['publications'][0]['title'] == 'Multiline\npublication'
|
||||
assert crlf.read_bytes() == before
|
||||
@@ -0,0 +1,69 @@
|
||||
from scripts.extract_k1_specialties import active_codes, build_reference, parse_rows
|
||||
import pytest
|
||||
|
||||
|
||||
def test_vak_continuation_and_historical_dates():
|
||||
rows = [
|
||||
{"page": 1, "cells": ["1.", "Journal", "1234-5678", "1.2.2. First", "с 01.02.2022\nпо 01.02.2023"]},
|
||||
{"page": 2, "cells": ["", "continued", "", "2.3.5. Second", ""]},
|
||||
{"page": 2, "cells": ["", "", "", "2.3.6. Current", "с 02.02.2023"]},
|
||||
{"page": 3, "cells": ["", "", "", "2.3.1. Future", "с 01.02.2027"]},
|
||||
]
|
||||
journal = parse_rows(rows)[0]
|
||||
assert journal["name"] == "Journal continued" and journal["pages"] == [1, 2, 3]
|
||||
assert journal["assignments"][0]["codes"] == ["1.2.2", "2.3.5"]
|
||||
assert active_codes(journal, "2026-09-15") == ["2.3.6"]
|
||||
assert active_codes(journal, "2023-02-01") == ["1.2.2", "2.3.5"]
|
||||
with pytest.raises(ValueError, match="numbering"):
|
||||
parse_rows([{"page": 1, "cells": ["2.", "Journal", "1234-5678", "1.2.2. Test", "с 01.02.2022"]}])
|
||||
|
||||
|
||||
def test_ambiguous_titles_do_not_union_specialties_and_reviewed_matches_are_checked():
|
||||
rows = [{"page": n, "cells": [f"{n}.", "Same title", str(n), code + ". Test", "с 01.02.2022"]}
|
||||
for n, code in [(1, "1.2.2"), (2, "2.3.1")]]
|
||||
journals = parse_rows(rows)
|
||||
k1 = {"journals": [{"name": "Same title"}, {"name": "Similar title"}]}
|
||||
result = build_reference(journals, k1, [], "2026-09-15")
|
||||
assert result[0]["status"] == "ambiguous" and result[0]["specialty_codes"] == []
|
||||
assert result[1]["status"] == "not_found" and result[1]["specialty_codes"] == []
|
||||
with pytest.raises(ValueError, match="title changed"):
|
||||
build_reference(journals, k1, [{"k1_name": "Similar title", "vak_row": 1, "vak_name": "Wrong title"}], "2026-09-15")
|
||||
|
||||
|
||||
def test_malformed_source_start_date_is_preserved_without_inventing_date():
|
||||
journal = parse_rows([{"page": 1, "cells": ["1.", "Journal", "1234-5678", "1.3.8. Test", "с 01.02.200"]}])[0]
|
||||
assignment = journal["assignments"][0]
|
||||
assert assignment["source_dates"] == "с 01.02.200"
|
||||
assert assignment["valid_from"] is None and assignment["date_note"]
|
||||
assert active_codes(journal, "2026-09-15") == ["1.3.8"]
|
||||
|
||||
|
||||
def test_k1_extraction_is_reproducible_for_lf_and_crlf(tmp_path):
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
source = tmp_path / 'list.pdf'
|
||||
source.write_bytes(b'PDF source bytes for cached-row extraction')
|
||||
rows = tmp_path / 'rows.json'
|
||||
rows.write_text(json.dumps([{'page': 1, 'cells': ['1.', 'Journal', '1234-5678', '1.2.2. Test', 'с 01.02.2022']}]), encoding='utf8')
|
||||
matches = tmp_path / 'matches.json'
|
||||
matches.write_text('[]', encoding='utf8')
|
||||
canonical = (json.dumps({'journals': [{'name': 'Journal'}]}, indent=2) + '\n').encode('utf8')
|
||||
outputs = []
|
||||
for name, content in [('lf', canonical), ('crlf', canonical.replace(b'\n', b'\r\n'))]:
|
||||
directory = tmp_path / name
|
||||
directory.mkdir()
|
||||
k1 = directory / 'k1.json'
|
||||
k1.write_bytes(content)
|
||||
output = directory / 'specialties.json'
|
||||
subprocess.run([sys.executable, 'scripts/extract_k1_specialties.py', str(source), '--k1', str(k1),
|
||||
'--matches', str(matches), '--rows-cache', str(rows), '--output', str(output)], check=True, capture_output=True)
|
||||
data = json.loads(output.read_text(encoding='utf8'))
|
||||
assert data['k1_source_sha256'] == hashlib.sha256(canonical).hexdigest()
|
||||
assert data['source_sha256'] == hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
assert data['journals'][0]['specialty_codes'] == ['1.2.2']
|
||||
assert k1.read_bytes() == content
|
||||
assert b'\r\n' not in output.read_bytes()
|
||||
outputs.append(output.read_bytes())
|
||||
assert outputs[0] == outputs[1]
|
||||
@@ -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")
|
||||
@@ -0,0 +1,299 @@
|
||||
from copy import deepcopy
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, inspect, text, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import _ensure_runtime_schema, Base
|
||||
from app.models import EmployeePublication, Employee
|
||||
from app.services import publication_quartiles as quartiles
|
||||
from app.services.crawler import _apply_publication
|
||||
from scripts.enrich_publication_quartiles import resolve_doi, load_sjr, main
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scopus,wos", [("Q1", "Q2"), ("q3", "Q4"), ("Q4", "")])
|
||||
def test_hse_backfill_keeps_systems_q1_to_q4_and_unknown_metric_year(scopus, wos):
|
||||
raw = {"other": "preserve", "csv_enrichment": {"Квартиль scopus": scopus, "Квартиль wos": wos, "Год": "2024"}}
|
||||
before = deepcopy(raw)
|
||||
result = quartiles.hse_entries(raw, 2024)
|
||||
assert raw == before
|
||||
assert [e["quartile"] for e in result] == [v.upper() for v in (scopus, wos) if v]
|
||||
assert all(e["metric_year"] is None and e["publication_year"] == 2024 for e in result)
|
||||
assert [e["system"] for e in result] == (["Scopus", "Web of Science"] if wos else ["Scopus"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["Q5", "1", "", None])
|
||||
def test_invalid_hse_value_does_not_invent_a_quartile(value):
|
||||
assert quartiles.hse_entries({"csv_enrichment": {"Квартиль scopus": value}}, 2024) == []
|
||||
|
||||
|
||||
def test_merge_is_idempotent_and_preserves_conflicting_evidence():
|
||||
first = quartiles.hse_entries({"csv_enrichment": {"Квартиль scopus": "Q1", "DOI": "10.1234/work"}}, 2024)[0]
|
||||
payload = quartiles.merge(None, [first])
|
||||
assert quartiles.merge(payload, [{**first, "checked_on": "2099-01-01"}]) == payload
|
||||
assert len(quartiles.merge(payload, [{**first, "quartile": "Q2"}])["entries"]) == 2
|
||||
assert quartiles.labels(payload, 2024, doi="10.1234/work") == ["Scopus Q1"]
|
||||
assert quartiles.labels(payload, 2023) == []
|
||||
|
||||
|
||||
def test_catalog_metric_requires_exact_year_unless_latest_explicitly_allowed(monkeypatch):
|
||||
monkeypatch.setattr(quartiles, "publication_catalog", lambda ids, names: ([{
|
||||
"name": "Journal", "metrics": [{"year": 2025, "system": "Scopus", "quartile": "Q1", "metric": "CiteScore",
|
||||
"category": "Mathematics", "source": "Snapshot", "source_url": "https://example.org/metrics"}]}], []))
|
||||
metadata = {"type": "journal-article", "years": [2024], "issns": ["0006-3835"]}
|
||||
assert quartiles.catalog_entries(metadata, 2024)[0] == []
|
||||
found, status = quartiles.catalog_entries(metadata, 2024, True)
|
||||
assert status == "found_latest" and found[0]["metric_year"] == 2025 and found[0]["publication_year"] == 2024
|
||||
assert quartiles.labels(quartiles.merge(None, found), 2024) == []
|
||||
assert quartiles.catalog_entries(metadata, 2023, True)[1] == "publication_year_conflict"
|
||||
assert quartiles.catalog_entries({**metadata, "type": "book-chapter"}, 2024, True)[1] == "not_journal_article"
|
||||
|
||||
|
||||
def sjr_record(year, categories="Analysis (Q1); Applied Mathematics (Q2)"):
|
||||
return {"journal": "Journal", "issns": ["0006-3835"], "quartile": "Q1", "categories": categories,
|
||||
"source_url": f"https://opendata.unex.es/recurso/ciencia-tecnologia/investigacion/revistas/Revista/{year}-1"}
|
||||
|
||||
|
||||
def test_sjr_keeps_historical_year_categories_and_system_distinct():
|
||||
metadata = {"type": "journal-article", "years": [2020], "issns": ["0006-3835"]}
|
||||
found, status = quartiles.sjr_entries(metadata, 2020, quartiles.index_sjr({2020: [sjr_record(2020)], 2025: [sjr_record(2025)]}), True)
|
||||
assert status == "found" and [e["quartile"] for e in found] == ["Q1", "Q2"]
|
||||
assert all(e["system"] == "SCImago" and e["metric_year"] == 2020 for e in found)
|
||||
assert quartiles.labels(quartiles.merge(None, found), 2020) == []
|
||||
latest, status = quartiles.sjr_entries(metadata, 2020, quartiles.index_sjr({2025: [sjr_record(2025)]}), True)
|
||||
assert status == "found_latest" and latest[0]["metric_year"] == 2025
|
||||
assert quartiles.sjr_entries(metadata, 2020, quartiles.index_sjr({2025: [sjr_record(2025)]}))[0] == []
|
||||
|
||||
|
||||
def test_sjr_identity_and_category_conflicts_do_not_resolve_by_order():
|
||||
metadata = {"type": "journal-article", "years": [2020], "issns": ["0006-3835"]}
|
||||
first = sjr_record(2020)
|
||||
second = {**first, "source_url": first["source_url"] + "2"}
|
||||
assert quartiles.sjr_entries(metadata, 2020, quartiles.index_sjr({2020: [first, second]}))[1] == "journal_identity_conflict"
|
||||
assert quartiles.sjr_entries(metadata, 2020, quartiles.index_sjr({2020: [sjr_record(2020, "Analysis (Q3)")]} ))[1] == "ranking_category_conflict"
|
||||
|
||||
|
||||
def test_schema_addition_is_repeatable_and_preserves_existing_data(monkeypatch):
|
||||
engine = create_engine("sqlite://")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE TABLE employee_publications (id INTEGER PRIMARY KEY, title TEXT NOT NULL)"))
|
||||
connection.execute(text("INSERT INTO employee_publications (id,title) VALUES (1,'Keep')"))
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
_ensure_runtime_schema()
|
||||
_ensure_runtime_schema()
|
||||
assert "quartiles" in {c["name"] for c in inspect(engine).get_columns("employee_publications")}
|
||||
with engine.connect() as connection:
|
||||
assert connection.execute(text("SELECT title,quartiles FROM employee_publications")).one() == ("Keep", None)
|
||||
|
||||
|
||||
def test_crawler_preserves_quartiles_while_replacing_source_owned_raw_data():
|
||||
payload = quartiles.merge(None, quartiles.hse_entries({"csv_enrichment": {"Квартиль scopus": "Q4"}}, 2024))
|
||||
publication = EmployeePublication(title="Old", source_hash="old", quartiles=deepcopy(payload))
|
||||
publication.vak_categories = {"schema_version": 1, "entries": [{"category": "К1"}], "status": "found"}
|
||||
_apply_publication(publication, {"id": "42", "title": "Updated", "year": 2024, "raw_data": {"title": "Updated"}}, "new")
|
||||
assert publication.quartiles == payload
|
||||
assert publication.vak_categories["entries"] == [{"category": "К1"}]
|
||||
assert publication.title == "Updated" and publication.source_hash == "new"
|
||||
|
||||
|
||||
def test_council_reads_new_field_without_raw_enrichment_and_keeps_historical_filter():
|
||||
from app.services.council import _database_article
|
||||
values = quartiles.hse_entries({"csv_enrichment": {"Квартиль scopus": "Q2", "DOI": "10.1234/work"}}, 2024)
|
||||
publication = EmployeePublication(title="Work", year=2024, publication_type="ARTICLE", source_hash="hash",
|
||||
quartiles=quartiles.merge(None, values), raw_data={}, doi_url="https://doi.org/10.1234/work")
|
||||
assert _database_article(publication)["quartiles"] == ["Scopus Q2"]
|
||||
publication.quartiles = quartiles.merge(None, [{**values[0], "metric_year": 2025}])
|
||||
assert _database_article(publication)["quartiles"] == []
|
||||
|
||||
|
||||
def test_doi_cache_uses_verified_identity_and_does_not_need_network_on_repeat(monkeypatch, tmp_path):
|
||||
calls = []
|
||||
class Response:
|
||||
status_code = 200
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
def json(self):
|
||||
return {"message": {"DOI": "10.1234/work", "type": "journal-article", "container-title": ["Journal"],
|
||||
"ISSN": ["0006-3835"], "published": {"date-parts": [[2024, 2, 1]]}}}
|
||||
def get(url, **kwargs):
|
||||
calls.append(url)
|
||||
assert kwargs["allow_redirects"] is False
|
||||
return Response()
|
||||
monkeypatch.setattr("scripts.enrich_publication_quartiles.requests.get", get)
|
||||
result = resolve_doi("10.1234/work", tmp_path)
|
||||
assert result["status"] == "resolved" and result["years"] == [2024]
|
||||
assert resolve_doi("10.1234/work", tmp_path) == result and len(calls) == 1
|
||||
assert calls[0] == "https://api.crossref.org/works/10.1234%2Fwork"
|
||||
|
||||
|
||||
def test_sjr_source_combines_multiple_categories_of_the_same_resource(monkeypatch, tmp_path):
|
||||
url = "https://opendata.unex.es/recurso/ciencia-tecnologia/investigacion/revistas/Revista/2020-1"
|
||||
def binding(category, quartile="Q1"):
|
||||
return {key: {"value": value} for key, value in {
|
||||
"uri": url, "title": "Journal", "issn": "0006-3835", "quartile": quartile, "categories": category}.items()}
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
def json(self):
|
||||
return {"results": {"bindings": [binding("Analysis (Q1)"), binding("Applied Mathematics (Q2)")]}}
|
||||
monkeypatch.setattr("scripts.enrich_publication_quartiles.requests.get", lambda *args, **kwargs: Response())
|
||||
rankings, errors = load_sjr(["0006-3835"], [2020], tmp_path)
|
||||
assert not errors
|
||||
metadata = {"type": "journal-article", "years": [2020], "issns": ["0006-3835"]}
|
||||
found, status = quartiles.sjr_entries(metadata, 2020, rankings)
|
||||
assert status == "found" and {e["quartile"] for e in found} == {"Q1", "Q2"}
|
||||
|
||||
|
||||
def test_doi_identity_conflict_does_not_supply_journal_metrics(monkeypatch, tmp_path):
|
||||
class Response:
|
||||
status_code = 200
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
def json(self):
|
||||
return {"message": {"DOI": "10.1234/another", "type": "journal-article", "ISSN": ["0006-3835"]}}
|
||||
monkeypatch.setattr("scripts.enrich_publication_quartiles.requests.get", lambda *args, **kwargs: Response())
|
||||
result = resolve_doi("10.1234/work", tmp_path)
|
||||
assert result["status"] == "doi_identity_conflict" and "issns" not in result
|
||||
assert not list(tmp_path.glob("*.json"))
|
||||
|
||||
|
||||
def test_enrichment_cli_dry_run_apply_and_repeat_preserve_original_data(monkeypatch, tmp_path):
|
||||
url = "sqlite:///" + str(tmp_path / "publications.db")
|
||||
engine = create_engine(url)
|
||||
Base.metadata.create_all(engine)
|
||||
raw = {"original": "keep", "csv_enrichment": {"Квартиль scopus": "Q3", "Год": "2024"}}
|
||||
with Session(engine) as session:
|
||||
employee = Employee(profile_key="test", canonical_url="https://example.org/person")
|
||||
session.add(employee)
|
||||
session.flush()
|
||||
session.add(EmployeePublication(employee_id=employee.id, title="Work", year=2024, source_hash="hash", raw_data=raw))
|
||||
session.commit()
|
||||
monkeypatch.setenv("DATABASE_URL", url)
|
||||
arguments = ["enrich", "--cache-dir", str(tmp_path / "cache"), "--report", str(tmp_path / "report.json")]
|
||||
monkeypatch.setattr("sys.argv", arguments)
|
||||
main()
|
||||
with Session(engine) as session:
|
||||
assert session.scalar(select(EmployeePublication)).quartiles is None
|
||||
monkeypatch.setattr("sys.argv", arguments + ["--apply"])
|
||||
main()
|
||||
with Session(engine) as session:
|
||||
publication = session.scalar(select(EmployeePublication))
|
||||
payload = deepcopy(publication.quartiles)
|
||||
assert publication.raw_data == raw and payload["entries"][0]["quartile"] == "Q3"
|
||||
main()
|
||||
with Session(engine) as session:
|
||||
publication = session.scalar(select(EmployeePublication))
|
||||
assert publication.quartiles == payload and publication.raw_data == raw
|
||||
|
||||
|
||||
def test_issn_succession_requires_explicit_relation_and_unique_doi_journal_title():
|
||||
old = {"history": "Continued as", "related": ["Physical Review B"]}
|
||||
new = {"issns": {"2469-9950", "2469-9969"}}
|
||||
metadata = {"issns": ["1098-0121", "1550-235X"], "journals": ["Physical Review B"]}
|
||||
result = quartiles.with_title_history(metadata, {"10980121": [old]}, {"physical review b": [new]}, "https://example.org/source-titles")
|
||||
assert result["issns"] == metadata["issns"] and "2469-9950" in result["ranking_issns"]
|
||||
assert metadata == {"issns": ["1098-0121", "1550-235X"], "journals": ["Physical Review B"]}
|
||||
assert quartiles.with_title_history(metadata, {"10980121": [old]}, {"physical review b": [new, new]}, "url") == metadata
|
||||
assert quartiles.with_title_history({**metadata, "journals": ["Other journal"]}, {"10980121": [old]}, {"physical review b": [new]}, "url") == {**metadata, "journals": ["Other journal"]}
|
||||
|
||||
|
||||
def test_crossref_not_found_falls_back_to_datacite_journal_metadata(monkeypatch, tmp_path):
|
||||
class Response:
|
||||
def __init__(self, status):
|
||||
self.status_code = status
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
def json(self):
|
||||
return {"data": {"attributes": {"doi": "10.1234/work", "publicationYear": 2024,
|
||||
"types": {"resourceTypeGeneral": "JournalArticle"},
|
||||
"container": {"title": "Journal", "identifierType": "ISSN", "identifier": "0006-3835"}}}}
|
||||
calls = []
|
||||
def get(url, **kwargs):
|
||||
calls.append(url)
|
||||
return Response(404 if "crossref.org" in url else 200)
|
||||
monkeypatch.setattr("scripts.enrich_publication_quartiles.requests.get", get)
|
||||
result = resolve_doi("10.1234/work", tmp_path)
|
||||
assert result["status"] == "resolved" and result["type"] == "journal-article"
|
||||
assert result["issns"] == ["0006-3835"] and result["years"] == [2024]
|
||||
assert result["datacite_checked"] and len(calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("new_doi,new_journal", [("10.1234/new", "Different journal"), ("10.1234/old", "Different journal")])
|
||||
def test_crawler_doi_or_journal_change_excludes_preserved_quartile(new_doi, new_journal):
|
||||
from app.services.council import _database_article
|
||||
evidence = {"system": "Scopus", "quartile": "Q1", "publication_year": 2024, "metric_year": 2024,
|
||||
"journal": "Old journal", "issns": [], "source": "Checked"}
|
||||
payload = {"schema_version": 1, "entries": [evidence], "lookup": {"doi": "10.1234/old"}}
|
||||
publication = EmployeePublication(title="Work", year=2024, source_hash="old", publication_type="ARTICLE",
|
||||
doi_url="https://doi.org/10.1234/old", quartiles=deepcopy(payload), raw_data={"journal": "Old journal"})
|
||||
assert _database_article(publication)["quartiles"] == ["Scopus Q1"]
|
||||
_apply_publication(publication, {"id": "42", "title": "Work", "year": 2024, "type": "ARTICLE",
|
||||
"doi_url": "https://doi.org/" + new_doi, "raw_data": {"journal": new_journal}}, "new")
|
||||
article = _database_article(publication)
|
||||
assert publication.quartiles == payload
|
||||
assert article["quartiles"] == [] and article["quartile_issues"]
|
||||
assert all("перепроверка" in issue for issue in article["quartile_issues"])
|
||||
|
||||
|
||||
def test_updating_lookup_freezes_legacy_binding_instead_of_rebinding_old_entries():
|
||||
old = {"system": "Scopus", "quartile": "Q1", "publication_year": 2024, "metric_year": 2024,
|
||||
"journal": "Old journal", "issns": [], "source": "Checked"}
|
||||
payload = {"schema_version": 1, "entries": [old], "lookup": {"doi": "10.1234/old"}}
|
||||
updated = quartiles.merge(payload, [], lookup={"doi": "10.1234/new"})
|
||||
assert updated["entries"][0]["doi"] == "10.1234/old"
|
||||
assert quartiles.labels(updated, 2024, "10.1234/new", ["Old journal"]) == []
|
||||
fresh = {**old, "doi": "10.1234/new", "quartile": "Q2"}
|
||||
refreshed = quartiles.merge(updated, [fresh], lookup={"doi": "10.1234/new"})
|
||||
assert quartiles.labels(refreshed, 2024, "10.1234/new", ["Old journal"]) == ["Scopus Q2"]
|
||||
assert len(refreshed["entries"]) == 2
|
||||
|
||||
|
||||
def test_unbound_hse_fallback_does_not_bypass_quartile_identity_checks():
|
||||
from app.services.council import _database_article
|
||||
publication = EmployeePublication(title="Work", year=2024, source_hash="hash", publication_type="ARTICLE",
|
||||
doi_url="10.1234/new", raw_data={"journal": "New journal", "csv_enrichment": {
|
||||
"DOI": "10.1234/old", "Название журнала": "Old journal", "Квартиль scopus": "Q1"}})
|
||||
article = _database_article(publication)
|
||||
assert article["quartiles"] == [] and article["quartile_issues"]
|
||||
assert "Old journal" not in article["journals"]
|
||||
|
||||
|
||||
def test_journal_issn_allows_explicit_rename_but_conflicting_issn_is_rejected():
|
||||
value = {"system": "Scopus", "quartile": "Q1", "publication_year": 2024, "metric_year": 2024,
|
||||
"doi": "10.1234/work", "journal": "Old title", "issns": ["0006-3835"]}
|
||||
payload = quartiles.merge(None, [value])
|
||||
assert quartiles.labels(payload, 2024, "10.1234/work", ["Renamed title"], ["00063835"]) == ["Scopus Q1"]
|
||||
assert quartiles.labels(payload, 2024, "10.1234/work", ["Old title"], ["11111111"]) == []
|
||||
|
||||
|
||||
def test_default_enrichment_rechecks_filled_evidence_after_doi_change(monkeypatch, tmp_path):
|
||||
url = "sqlite:///" + str(tmp_path / "stale.db")
|
||||
engine = create_engine(url)
|
||||
Base.metadata.create_all(engine)
|
||||
old = {"system": "Scopus", "quartile": "Q1", "publication_year": 2024, "metric_year": 2024,
|
||||
"journal": "Old journal", "issns": [], "source": "Checked"}
|
||||
payload = {"schema_version": 1, "entries": [old], "lookup": {"doi": "10.1234/old", "status": "found"}}
|
||||
with Session(engine) as session:
|
||||
person = Employee(profile_key="test", canonical_url="https://example.org/person")
|
||||
session.add(person)
|
||||
session.flush()
|
||||
session.add(EmployeePublication(employee_id=person.id, title="Work", year=2024, source_hash="hash",
|
||||
doi_url="10.1234/new", raw_data={"journal": "New journal"}, quartiles=payload))
|
||||
session.commit()
|
||||
calls = []
|
||||
def resolve(doi, cache):
|
||||
calls.append(doi)
|
||||
return {"status": "resolved", "doi": doi, "type": "journal-article", "journals": ["New journal"]}
|
||||
monkeypatch.setattr("scripts.enrich_publication_quartiles.resolve_doi", resolve)
|
||||
monkeypatch.setattr("scripts.enrich_publication_quartiles.catalog_entries", lambda metadata, year, latest: ([
|
||||
{**old, "doi": metadata["doi"], "journal": "New journal", "quartile": "Q2"}], "found"))
|
||||
monkeypatch.setenv("DATABASE_URL", url)
|
||||
monkeypatch.setattr("sys.argv", ["enrich", "--apply", "--lookup-doi", "--cache-dir", str(tmp_path / "cache"),
|
||||
"--report", str(tmp_path / "report.json")])
|
||||
main()
|
||||
with Session(engine) as session:
|
||||
stored = session.scalar(select(EmployeePublication)).quartiles
|
||||
assert calls == ["10.1234/new"] and len(stored["entries"]) == 2
|
||||
assert stored["entries"][0]["doi"] == "10.1234/old"
|
||||
assert quartiles.labels(stored, 2024, "10.1234/new", ["New journal"]) == ["Scopus Q2"]
|
||||
@@ -0,0 +1,112 @@
|
||||
import pytest
|
||||
|
||||
from app.services import publication_vak as vak
|
||||
from app.services.publication_quartiles import catalog_entries, sjr_entries, index_sjr
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def index(monkeypatch):
|
||||
k1 = {"category": "К1", "journal": "Первый журнал", "source_row": 1, "issns": ["00063835"]}
|
||||
k2 = {"category": "К2", "journal": "Второй журнал", "source_row": 2, "issns": []}
|
||||
monkeypatch.setattr(vak, "category_index", lambda: ({"первый журнал": [k1], "второй журнал": [k2]}, {"00063835": [k1]}))
|
||||
return k1, k2
|
||||
|
||||
|
||||
def test_vak_identifies_k1_by_issn_without_guessing_translated_names(index):
|
||||
result = vak.assess_vak(["English title"], ["0006-3835"])
|
||||
assert result["status"] == "found" and result["entries"][0]["category"] == "К1"
|
||||
assert result["entries"][0]["identity_confirmed"] and not result["historical_category_confirmed"]
|
||||
|
||||
|
||||
def test_k2_name_match_is_explicitly_preliminary(index):
|
||||
result = vak.assess_vak([" Второй журнал. "], [])
|
||||
assert result["entries"][0]["category"] == "К2"
|
||||
assert not result["entries"][0]["identity_confirmed"]
|
||||
assert result["entries"][0]["match_method"] == "normalized_name"
|
||||
|
||||
|
||||
def test_vak_conflicting_identifiers_and_multiple_journals_are_not_unioned(index):
|
||||
assert vak.assess_vak(["Первый журнал"], ["1111-1111"])["status"] == "journal_identity_conflict"
|
||||
assert vak.assess_vak(["Первый журнал", "Второй журнал"], [])["status"] == "ambiguous_journal"
|
||||
assert vak.assess_vak([], [])["status"] == "unknown_journal"
|
||||
assert vak.assess_vak(["Другой журнал"], [])["status"] == "not_found_in_k1_k2"
|
||||
|
||||
|
||||
def test_committed_category_snapshots_match_known_journal_and_record_sources():
|
||||
vak.category_index.cache_clear()
|
||||
result = vak.assess_vak(["Бизнес-информатика"], [])
|
||||
assert result["status"] == "found" and result["entries"][0]["category"] == "К1"
|
||||
assert result["entries"][0]["source_sha256"] and result["entries"][0]["categorized_on"]
|
||||
|
||||
|
||||
def test_all_catalog_systems_keep_their_own_metric_year_and_multiple_categories(monkeypatch):
|
||||
metrics = [{"year": year, "system": system, "quartile": quarter, "metric": metric,
|
||||
"category": category, "source": "Snapshot", "source_url": "https://example.org/source"}
|
||||
for year, system, quarter, metric, category in [(2024, "Scopus", "Q1", "CiteScore", "Analysis"),
|
||||
(2024, "Scopus", "Q2", "CiteScore", "Applied Mathematics"), (2025, "Web of Science", "Q3", "JCR", "Mathematics")]]
|
||||
monkeypatch.setattr("app.services.publication_quartiles.publication_catalog", lambda ids, names: ([{"name": "Journal", "metrics": metrics}], []))
|
||||
metadata = {"type": "journal-article", "years": [2024], "issns": ["0006-3835"]}
|
||||
result, status = catalog_entries(metadata, 2024, True)
|
||||
assert status == "found_latest" and len(result) == 3
|
||||
assert {(r["system"], r["quartile"], r["metric_year"]) for r in result} == {
|
||||
("Scopus", "Q1", 2024), ("Scopus", "Q2", 2024), ("Web of Science", "Q3", 2025)}
|
||||
|
||||
|
||||
def test_book_container_title_is_not_treated_as_a_vak_journal(monkeypatch, tmp_path):
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db import Base
|
||||
from app.models import Employee, EmployeePublication
|
||||
from scripts.enrich_publication_quartiles import main
|
||||
url = "sqlite:///" + str(tmp_path / "books.db")
|
||||
engine = create_engine(url)
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
employee = Employee(profile_key="test", canonical_url="https://example.org/person")
|
||||
session.add(employee)
|
||||
session.flush()
|
||||
session.add(EmployeePublication(employee_id=employee.id, title="Chapter", year=2024, publication_type="CHAPTER",
|
||||
source_hash="hash", doi_url="10.1234/chapter", raw_data={}))
|
||||
session.commit()
|
||||
monkeypatch.setenv("DATABASE_URL", url)
|
||||
monkeypatch.setattr("scripts.enrich_publication_quartiles.resolve_doi", lambda *args: {
|
||||
"status": "resolved", "type": "book-chapter", "journals": ["Бизнес-информатика"], "issns": [], "years": [2024]})
|
||||
monkeypatch.setattr("sys.argv", ["enrich", "--apply", "--lookup-doi", "--check-vak", "--all-systems",
|
||||
"--cache-dir", str(tmp_path / "cache"), "--report", str(tmp_path / "report.json")])
|
||||
main()
|
||||
with Session(engine) as session:
|
||||
publication = session.scalar(select(EmployeePublication))
|
||||
assert publication.vak_categories["status"] == "unknown_journal"
|
||||
assert publication.vak_categories["entries"] == [] and publication.quartiles["entries"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
({"journal": "Первый журнал", "issn": "1111-1111"}, "journal_identity_conflict"),
|
||||
({"issn": "0006-3835"}, "found"),
|
||||
({"eissn": "0006-3835"}, "found"),
|
||||
({"csv_enrichment": {"ISSN": "0006-3835"}}, "found"),
|
||||
])
|
||||
def test_cli_checks_original_issn_even_without_doi(index, monkeypatch, tmp_path, raw, expected):
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db import Base
|
||||
from app.models import Employee, EmployeePublication
|
||||
from scripts.enrich_publication_quartiles import main
|
||||
url = "sqlite:///" + str(tmp_path / "vak.db")
|
||||
engine = create_engine(url)
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
person = Employee(profile_key="test", canonical_url="https://example.org/person")
|
||||
session.add(person)
|
||||
session.flush()
|
||||
session.add(EmployeePublication(employee_id=person.id, title="Work", year=2024, source_hash="hash", raw_data=raw))
|
||||
session.commit()
|
||||
monkeypatch.setenv("DATABASE_URL", url)
|
||||
monkeypatch.setattr("sys.argv", ["enrich", "--apply", "--check-vak", "--cache-dir", str(tmp_path / "cache"),
|
||||
"--report", str(tmp_path / "report.json")])
|
||||
main()
|
||||
with Session(engine) as session:
|
||||
publication = session.scalar(select(EmployeePublication))
|
||||
assert publication.vak_categories["status"] == expected
|
||||
if expected == "found":
|
||||
assert publication.vak_categories["entries"][0]["identity_confirmed"]
|
||||
Reference in new issue
Block a user