Подготовленные публикации и привязка к специальностям в XLSX #64

Merged
admin merged 8 commits from fix/council-xlsx-specialties into main 2026-10-08 12:50:33 +00:00
18 changed files with 49568 additions and 76 deletions

No files matched your search

+4
View File
@@ -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
+17
View File
@@ -1,5 +1,22 @@
# Changelog
## 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 сохраняют выбранную специальность.
+36 -3
View File
@@ -92,7 +92,7 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
Счётчики **Q1**, **Q2**, **К1** и **ВСЕГО** учитывают уникальные публикации за последние пять лет по году. Q1/Q2 берутся из отметок Scopus или Web of Science в CSV и сохранённом CSV-обогащении БД. К1 определяется по совпадению нормализованного названия журнала с итоговой категорией в предоставленном справочнике ВАК. ВСЕГО — объединение Q1 ИЛИ Q2 ИЛИ К1: работа в нескольких категориях учитывается один раз. При расхождении годов источников каждый критерий проверяется по году своей записи; неизвестный год наследуется только при единственном согласованном годе. Счётчики «В БД» и «Все публикации» включают все годы независимо от категорий.
CSV `app/data/council_articles.csv` сопоставляется по полному ФИО только при единственном совпадении. Публикации объединяются по DOI, затем по нормализованным названию и году, если нет конфликтующих DOI. Импорт CSV в БД не выдаётся за данные сайта. Для К1 поддерживаются необязательные колонки CSV «Название журнала» или «Журнал»; в исходном CSV их нет. В БД название берётся из поля `journal` в `raw_data` или короткого описания ВШЭ (`description.short`/`shortLeft`, название перед годом). Название самой публикации для определения журнала не используется. Неизвестный или несовпавший журнал не считается К1; перевод и нечёткое сопоставление не применяются. Явно указанное в PDF прежнее название журнала сохраняется как проверенный алиас; служебное примечание о переименовании хранится отдельно от названия.
Подготовленный JSON `app/data/council_articles.json` сопоставляется по полному ФИО только при единственном совпадении. Публикации объединяются по DOI, затем по нормализованным названию и году, если нет конфликтующих DOI. Импорт CSV в БД не выдаётся за данные сайта. Для К1 поддерживаются необязательные колонки CSV «Название журнала» или «Журнал»; в исходном CSV их нет. В БД название берётся из поля `journal` в `raw_data` или короткого описания ВШЭ (`description.short`/`shortLeft`, название перед годом). Название самой публикации для определения журнала не используется. Неизвестный или несовпавший журнал не считается К1; перевод и нечёткое сопоставление не применяются. Явно указанное в PDF прежнее название журнала сохраняется как проверенный алиас; служебное примечание о переименовании хранится отдельно от названия.
Справочник `app/data/k1_journals.json` содержит 773 журнала К1, номера строк, даты категорирования и SHA256 исходного «ВАК категории.pdf». При извлечении проверены все 3134 строки; для записей с историей смены категории взята последняя категория. PDF при запросах не читается. Для обновления справочника разово установите `pdfplumber` в среду разработки и выполните:
@@ -155,7 +155,7 @@ node --check app/static/admin.js
Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
Версия сервиса: `0.15.0`. Админка всегда показывает версии backend и frontend в footer.
Версия сервиса: `0.15.4`. Админка всегда показывает версии backend и frontend в footer.
## Разовый импорт публикаций
@@ -173,6 +173,9 @@ node --check app/static/admin.js
известным типом, отличным от статьи, не подтверждают специальность. Счётчики Q1/Q2/К1
сохраняют прежний смысл; число работ по выбранной специальности указано внутри
карточки сотрудника. Фильтр сохраняется при поиске, смене страницы и экспорте XLSX.
На листах «Сотрудники» и «Публикации» XLSX отдельная колонка содержит коды и названия
подтверждённых специальностей, включая выгрузку без фильтра. При выбранном фильтре
добавлены число подходящих работ сотрудника и отметка «Да/Нет» для каждой публикации.
`app/data/k1_specialties.json` содержит все 773 журнала из `k1_journals.json`, коды
действующих специальностей, ISSN, страницы и строки исходного перечня, историю
@@ -193,4 +196,34 @@ python scripts/extract_k1_specialties.py "app/data/ВАК Перечень.pdf"
Скрипт проверяет последовательность всех 3231 строк. Проверенные различия названий
сохранены в `scripts/k1_specialty_matches.json`; каждое сопоставление проверяется по
номеру строки и точному исходному названию. Нечёткий поиск не применяется.
Сервис читает готовый JSON и не требует PDF или `pdfplumber` при запуске.
Сервис читает готовый JSON и не требует PDF или `pdfplumber` при запуске. Хеш
`k1_source_sha256` рассчитывается для исходного JSON с окончаниями строк LF; оба
JSON-справочника записываются с LF для воспроизводимости на Windows и Linux.
### Подготовка публикаций для портала
Портал читает `app/data/council_articles.json`: публикации представлены отдельными
объектами с названием, годом, DOI, массивами авторов, квартилей и журналов.
Дополнительные поля исходного экспорта сохранены в `metadata`; `source_row` связывает
запись со строкой исходного CSV. `by_author` — готовый индекс нормализованных ФИО
со ссылками на публикации, без копирования одной работы для каждого соавтора.
Исходный `app/data/council_articles.csv` сохранён для проверки и отдельного импорта
в БД. Данные файла и индекс кэшируются на процесс, CSV не преобразуется при
HTTP-запросах. Название источника «Файл CSV» и номера строк в интерфейсе и XLSX
сохранены как происхождение данных. Перед разбором публикаций сотрудников из БД
проверяется докторская степень; публикации остальных сотрудников не разбираются.
После обновления CSV пересоздайте 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, чтобы сбросить их кэш. Обновления
публикаций из БД продолжают отображаться без перезапуска.
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -3,7 +3,8 @@
"source": "ВАК Перечень.pdf",
"source_sha256": "d16ab69668fb4c4cab80eb75809928cca63b447df5ee246e4f9d56cae4d3273d",
"as_of": "2026-09-15",
"k1_source_sha256": "ae6c18ae283d89b7703264bd0047f0466207fd535b59c8c50288f0315c546392",
"k1_source_sha256": "f0f4514b132e486e821ae64c92cc23dccc55b641d590946de53bc7199e6f4caa",
"k1_source_hash_eol": "LF",
"source_rows": 3231,
"specialties": [
{
+6 -53
View File
@@ -1,10 +1,8 @@
"""Public publication evidence from the council export and employee database."""
import calendar
import csv
import json
import re
import unicodedata
from collections import Counter, defaultdict
from datetime import date, datetime
from functools import lru_cache
@@ -18,27 +16,18 @@ from sqlalchemy.orm import Session, selectinload
from app.models import Employee
from app.services.academic_degrees import academic_degrees
from app.services.council_source import _doi, _journal_names, _name, _quartiles, load_reference
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")
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 ""
def _safe_url(value: str | None) -> str | None:
try:
url = urlsplit(value or "")
@@ -54,12 +43,6 @@ 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)))
@@ -83,17 +66,6 @@ def _k1_specialties() -> dict:
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"):
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]:
names = []
for field in ("short", "shortLeft"):
@@ -109,26 +81,7 @@ def _description_journals(description: dict) -> list[str]:
@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), "journals": _journal_names(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 {}
@@ -245,9 +198,6 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
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)
@@ -255,6 +205,9 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
degrees = []
if not any(DOCTOR.fullmatch(degree) for degree in degrees):
continue
articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end)
if not articles:
continue
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"])
+15 -6
View File
@@ -22,21 +22,28 @@ def council_xlsx(result: dict) -> bytes:
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", "ВСЕГО", "Страница ВШЭ", "Все публикации",
"Профиль ВШЭ", "Примечание"])
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности"])
_append(publications, ["№ сотрудника", "ФИО", "Название публикации", "Год", "Тип материала", "Источники",
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "Страница ВШЭ", "DOI", "Ссылка на сайте", "Строки CSV"])
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "Страница ВШЭ", "DOI", "Ссылка на сайте", "Строки CSV", "Специальности ВАК", "По выбранной специальности"])
for number, candidate in enumerate(result["candidates"], 1):
note = "Совпадение ФИО: публикации CSV не присвоены автоматически" if candidate["ambiguous"] else ""
specialty_codes = {code for article in candidate["articles"] for code in article["specialty_codes"]}
_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])
candidate["profile_url"], note, specialties_text(specialty_codes),
candidate["specialty_count"] if result["specialty"] else None])
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"]))])
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])
_append(parameters, ["Параметр", "Значение"])
for values in [
["Специальность ВАК", f"{result['specialty']}. — {result['specialty_name']}" if result["specialty"] else "Все специальности"],
@@ -52,13 +59,15 @@ def council_xlsx(result: dict) -> bytes:
"Материалы с известным другим типом исключены. К1 — совпадение названия журнала с итоговой категорией "
"в справочнике из ВАК категории.pdf; историческая категория не подтверждается. "
"Без названия журнала К1 не подтверждён."],
["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — специальности релевантного журнала К1, учитываемые за пять лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
["Работ / По выбранной специальности", "При выбранном фильтре показаны число соответствующих публикаций сотрудника и отметка Да/Нет для каждой работы. Без фильтра ячейки пустые; список специальностей остаётся заполненным."],
["ВСЕГО", "Уникальные публикации, входящие в Q1 ИЛИ Q2 ИЛИ К1; пересечения учитываются один раз."],
["Страница ВШЭ / Все публикации", "Все годы и категории; общий перечень объединяет CSV и БД без дублей. "
"Страница ВШЭ учитывает сохранённые публикации, включая импортированные из CSV; источник указан на листе Публикации."],
]:
_append(parameters, values)
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 12, 20, 22, 45, 55]),
(publications, [16, 34, 65, 12, 40, 45, 65, 10, 10, 10, 12, 20, 45, 45, 22]),
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 12, 20, 22, 45, 55, 80, 28]),
(publications, [16, 34, 65, 12, 40, 45, 65, 10, 10, 10, 12, 20, 45, 45, 22, 80, 28]),
(parameters, [32, 95])]:
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = sheet.dimensions
+105
View File
@@ -0,0 +1,105 @@
"""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 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": ["Файл CSV"], "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()}
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "0.15.0"
FRONTEND_VERSION = "0.15.0"
BACKEND_VERSION = "0.15.0"
APP_VERSION = "0.15.4"
FRONTEND_VERSION = "0.15.4"
BACKEND_VERSION = "0.15.4"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "0.15.0"
version = "0.15.4"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+1 -1
View File
@@ -54,5 +54,5 @@ if __name__ == "__main__":
parser.add_argument("--output", type=Path, default=Path("app/data/k1_journals.json"))
args = parser.parse_args()
result = extract(args.source)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
print(f"Saved {len(result['journals'])} K1 journals; verified {result['source_rows']} source rows")
+2 -2
View File
@@ -169,8 +169,8 @@ if __name__ == "__main__":
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),
"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")
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)))
+33
View File
@@ -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
View File
@@ -157,6 +157,10 @@ def main():
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"))
assert not errors, errors
browser.close()
finally:
+1 -1
View File
@@ -18,7 +18,7 @@ def test_health_returns_versions():
response = TestClient(app).get("/api/health")
assert response.status_code == 200
assert response.json()["backend_version"] == "0.15.0"
assert response.json()["backend_version"] == "0.15.4"
def test_api_employees_and_stats_require_admin_session():
+55 -5
View File
@@ -19,6 +19,7 @@ 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)", "Название журнала",
"Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"]
@@ -26,8 +27,11 @@ def export(monkeypatch, tmp_path):
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()
@@ -211,7 +215,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.15.0 · Frontend 0.15.0" in response.text
assert "Backend 0.15.4 · Frontend 0.15.4" 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
@@ -256,11 +260,11 @@ def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db
people, works = book["Сотрудники"], book["Публикации"]
assert [cell.value for cell in people[1]] == [
"№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "Страница ВШЭ", "Все публикации",
"Профиль ВШЭ", "Примечание"
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности"
]
assert people["J2"].value == "https://www.hse.ru/staff/test"
assert people["K2"].value is None
assert people.max_column == 11
assert people.max_column == 13
assert works["L1"].value == "Страница ВШЭ"
assert people.max_row == 29 and works.max_row == 29
assert people["B2"].value == "Петров 27" and people["B29"].value == "Петров 00"
@@ -515,7 +519,7 @@ def test_specialty_reference_integrity():
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 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
@@ -529,3 +533,49 @@ def test_specialty_reference_integrity():
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'M{len(staff)}')
assert book['Публикации'].auto_filter.ref.endswith(f'Q{len(works)}')
def test_non_doctor_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
+124
View File
@@ -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'] == ['Файл CSV']
@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'] == 1797 and len(data['by_author']) == 825
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
+31
View File
@@ -36,3 +36,34 @@ def test_malformed_source_start_date_is_preserved_without_inventing_date():
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]