Merge pull request 'Подбор кандидатов в диссертационный совет по специальностям ВАК' (#63) from feature/council-specialties into main
CI / test (push) Failing after 4m45s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been skipped

Reviewed-on: #63
Reviewed-by: agent <agent@vakanaut.ru>
This commit was merged in pull request #63.
This commit is contained in:
admin committed 2026-10-08 09:52:43 +00:00
commit 675a3b5c6d
15 files changed
+40612 -22

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.15.0
- На странице «Диссертационный совет» добавлен подбор докторов наук по 12 специальностям ВАК: учитывается хотя бы одна публикация за последние пять лет в релевантном журнале К1. Показаны основания соответствия и количество подходящих работ; поиск, пагинация и XLSX сохраняют выбранную специальность.
- Добавлен справочник связей всех 773 журналов К1 со специальностями из Перечня ВАК на 15.09.2026, с ISSN, страницами, строками источника и датами назначений. Исключены истёкшие назначения; одинаковое название «Нефтегазовое дело» для двух ISSN помечено как неоднозначное и не используется в автоматическом подборе.
- Добавлен воспроизводимый скрипт извлечения и проверенные варианты названий. Сервис читает готовый JSON без обработки PDF при запросах.
## 0.14.3
- Из XLSX диссертационного совета удалён столбец «Статус». Колонка «В БД» переименована в «Страница ВШЭ» на листах «Сотрудники» и «Публикации»; расчёт значений сохранён.
+31 -1
View File
@@ -155,7 +155,7 @@ node --check app/static/admin.js
Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
Версия сервиса: `0.14.3`. Админка всегда показывает версии backend и frontend в footer.
Версия сервиса: `0.15.0`. Админка всегда показывает версии backend и frontend в footer.
## Разовый импорт публикаций
@@ -164,3 +164,33 @@ 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.
`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` при запуске.
+4 -4
View File
@@ -135,10 +135,10 @@ def directory(
@router.get("/council", response_class=HTMLResponse)
def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_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)
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503)
@@ -146,10 +146,10 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_c
@router.get("/council/export.xlsx")
def export_council(request: Request, q: str = "", sort: str = "total_count", direction: str = "desc",
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)
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty)
content = council_xlsx(result)
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
File diff suppressed because it is too large. Load diff
+27 -4
View File
@@ -22,6 +22,7 @@ from app.services.academic_degrees import academic_degrees
EXPORT = Path(__file__).resolve().parents[1] / "data" / "council_articles.csv"
K1_JOURNALS = EXPORT.with_name("k1_journals.json")
K1_SPECIALTIES = EXPORT.with_name("k1_specialties.json")
DOCTOR = re.compile(r"^доктор\b.+\bнаук$", re.IGNORECASE)
DOI = re.compile(r"10\.\d{4,9}/\S+", re.IGNORECASE)
TYPES = {"ARTICLE": "Статья", "BOOK": "Книга", "CHAPTER": "Глава книги",
@@ -76,6 +77,12 @@ def _k1_journals() -> dict[str, str]:
return journals
@lru_cache(maxsize=1)
def _k1_specialties() -> dict:
data = json.loads(K1_SPECIALTIES.read_text(encoding="utf-8"))
return {**data, "by_journal": {j["name"]: j["specialty_codes"] for j in data["journals"]}}
def _journal_names(data: dict) -> list[str]:
names = []
for field in ("Название журнала", "Журнал", "journal"):
@@ -147,6 +154,7 @@ def _database_article(publication) -> dict:
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:
@@ -190,13 +198,18 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
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"])
article["k1"] |= any(_journal_key(name) in k1_journals for name in item["journals"])
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"]
if len(years) > 1:
article["criteria"].append("Годы источников расходятся: " + ", ".join(map(str, years))
@@ -218,9 +231,12 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0,
sort: str = "total_count", direction: str = "desc", paginate: bool = True) -> dict:
sort: str = "total_count", direction: str = "desc", paginate: bool = True, specialty: str = "") -> dict:
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
start, end = period(today)
specialties = _k1_specialties()["specialties"]
specialty = specialty if specialty in {s["code"] for s in specialties} else ""
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()
@@ -239,7 +255,12 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
degrees = []
if not any(DOCTOR.fullmatch(degree) for degree in degrees):
continue
candidates.append({"name": employee.full_name or "ФИО не указано", "degree": "; ".join(degrees) or "Учёная степень не указана",
specialty_count = sum(specialty in a["specialty_codes"] for a in articles) if specialty else 0
for article in articles:
article["matches_specialty"] = bool(specialty and specialty in article["specialty_codes"])
if article["matches_specialty"]:
article["criteria"].append(f"По специальности {specialty}: {specialty_name} (журнал К1, Перечень ВАК)")
candidates.append({"specialty_count": specialty_count,"name": employee.full_name or "ФИО не указано", "degree": "; ".join(degrees) or "Учёная степень не указана",
"count": len(articles),
**{f"{key}_count": sum(a[key] for a in articles) for key in ("q1", "q2", "k1")},
"total_count": sum(a["qualifies"] for a in articles),
@@ -253,10 +274,12 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
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] if paginate else candidates,
"start": start, "end": end, "total": total,
"specialties": specialties, "specialty": specialty, "specialty_name": specialty_name,
"matched": matched, "q": q, "offset": offset, "limit": limit, "sort": sort, "direction": direction}
+2
View File
@@ -39,6 +39,8 @@ def council_xlsx(result: dict) -> bytes:
article["doi_url"], article["url"], ", ".join(map(str, article["rows"]))])
_append(parameters, ["Параметр", "Значение"])
for values in [
["Специальность ВАК", f"{result['specialty']}. — {result['specialty_name']}" if result["specialty"] else "Все специальности"],
["Подбор по специальности", "Хотя бы одна публикация за пять лет в релевантном журнале К1 по Перечню ВАК на 15.09.2026; содержание статьи и исторические специальности не проверяются."],
["Начало периода", result["start"]], ["Конец периода", result["end"]], ["Поиск по ФИО", result["q"]],
["Сортировать по", {"full_name": "ФИО", "q1_count": "Q1", "q2_count": "Q2", "k1_count": "К1",
"total_count": "ВСЕГО", "db_count": "Страница ВШЭ", "count": "Все публикации"}[result["sort"]]],
+18 -7
View File
@@ -21,9 +21,18 @@
<p>Q1 и Q2 — уникальные публикации с соответствующей отметкой Scopus или Web of Science. К1 — публикации в журналах, название которых совпало со справочником из «ВАК категории.pdf». ВСЕГО — количество уникальных публикаций, входящих хотя бы в одну из категорий Q1, Q2 или К1. Работа в нескольких категориях учитывается в итоге один раз.</p>
<p>Все четыре счётчика используют период {{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }} по году публикации. Для пограничных лет нужно проверить точную дату, для неизвестного типа — подтвердить, что это статья. Материалы с известным другим типом исключены. К1 определяется по итоговой категории журнала в предоставленном справочнике, а не по его исторической категории на дату публикации. Если название журнала отсутствует или не совпало, К1 не подтверждён. Отсутствие отметки означает недостаток данных.</p>
<p>«В БД» и «Все публикации» показывают количество работ за все годы, независимо от категорий. Общий перечень объединяет CSV и БД без дублей. Наличие публикаций не подтверждает соответствие всем требованиям совета.</p>
<p>При выборе специальности показаны сотрудники с хотя бы одной учитываемой за пять лет публикацией в журнале К1, которому эта специальность присвоена в «ВАК Перечень.pdf» на 15.09.2026. Это соответствие по журналу; содержание статьи и исторический перечень специальностей на дату публикации не проверяются.</p>
</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>
@@ -42,12 +51,13 @@
</label>
<button class="button" type="submit">Применить</button>
<button class="button button--secondary" type="submit" formaction="/admin/council/export.xlsx">Скачать XLSX</button>
{% if q %}<a class="admin__link" href="?sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;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">Откройте сотрудника, чтобы увидеть публикации, источники и основания. XLSX содержит всех найденных сотрудников и их публикации, включая другие страницы списка.</p>
<div class="council__list">
@@ -66,6 +76,7 @@
<span class="council__candidate-cue" aria-hidden="true"></span>
</summary>
<div class="council__candidate-body">
{% 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 %}
<ol class="council__articles">
@@ -94,16 +105,16 @@
</div>
{% if matched > limit %}
<nav class="directory__pagination" aria-label="Страницы сотрудников">
{% if offset > 0 %}<a class="admin__link" href="?q={{ q|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset - limit }}">Назад</a>{% endif %}
{% if offset > 0 %}<a class="admin__link" href="?q={{ q|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset - limit }}">Назад</a>{% endif %}
<span class="directory__page">{{ offset // limit + 1 }} / {{ (matched + limit - 1) // limit }}</span>
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset + limit }}">Далее</a>{% endif %}
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset + limit }}">Далее</a>{% endif %}
</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 }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
<h4>{% if q or specialty %}Сотрудники не найдены{% else %}Сотрудники с докторской степенью и публикациями пока не найдены{% endif %}</h4>
<p>{% if q or specialty %}Попробуйте другую фамилию или специальность. Без названия журнала, года или подтверждённого соответствия журналу К1 публикация не подтверждает специальность.{% else %}Для включения в список нужны докторская степень в профиле и хотя бы одна публикация, однозначно связанная с сотрудником.{% endif %}</p>
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
</div>
{% endif %}
{% endif %}
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "0.14.3"
FRONTEND_VERSION = "0.14.3"
BACKEND_VERSION = "0.14.3"
APP_VERSION = "0.15.0"
FRONTEND_VERSION = "0.15.0"
BACKEND_VERSION = "0.15.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "0.14.3"
version = "0.15.0"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+176
View File
@@ -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()).hexdigest(), "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")
from collections import Counter
print(len(entries), dict(Counter(j['status'] for j in entries)))
+662
View File
@@ -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": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
}
]
+17
View File
@@ -38,6 +38,7 @@ 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 {},
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,
@@ -140,6 +141,22 @@ 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.")
assert not errors, errors
browser.close()
finally:
+1 -1
View File
@@ -18,7 +18,7 @@ def test_health_returns_versions():
response = TestClient(app).get("/api/health")
assert response.status_code == 200
assert response.json()["backend_version"] == "0.14.3"
assert response.json()["backend_version"] == "0.15.0"
def test_api_employees_and_stats_require_admin_session():
+84 -1
View File
@@ -211,7 +211,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
assert "&lt;script&gt;" in response.text and "<script>" not in response.text
assert "javascript:" not in response.text and "private@example.org" not in response.text
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
assert "Backend 0.14.3 · Frontend 0.14.3" in response.text
assert "Backend 0.15.0 · Frontend 0.15.0" in response.text
assert "Вход в админку" in response.text
assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403)
assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text
@@ -446,3 +446,86 @@ def test_sort_category_counters_and_xlsx(db_session, export, sort, direction):
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
invalid = council.council_candidates(db_session, specialty="<script>")
assert invalid["specialty"] == "" and invalid["matched"] == 2
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()).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"])
+38
View File
@@ -0,0 +1,38 @@
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"]