fix: match documented previous K1 journal names
This commit is contained in:
1 parent
9324639c58
commit
5bda837ee2
8 files changed
+88
-14
No files matched your search
@@ -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; перевод и нечёткое сопоставление не применяются.
|
||||
CSV `app/data/council_articles.csv` сопоставляется по полному ФИО только при единственном совпадении. Публикации объединяются по 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.14.1`. Админка всегда показывает версии backend и frontend в footer.
|
||||
Версия сервиса: `0.14.2`. Админка всегда показывает версии backend и frontend в footer.
|
||||
|
||||
## Разовый импорт публикаций
|
||||
|
||||
|
||||
@@ -3499,9 +3499,13 @@
|
||||
"source_row": 698
|
||||
},
|
||||
{
|
||||
"name": "Ученые записки Казанской академии ветеринарной медицины им. Н.Э. Баумана (до 10.04.2026 г. наименование в Перечне «Ученые записки Казанской государственной академии ветеринарной медицины им. Н.Э. Баумана»)",
|
||||
"name": "Ученые записки Казанской академии ветеринарной медицины им. Н.Э. Баумана",
|
||||
"categorized_on": "01.01.2024",
|
||||
"source_row": 699
|
||||
"source_row": 699,
|
||||
"note": "до 10.04.2026 г. наименование в Перечне «Ученые записки Казанской государственной академии ветеринарной медицины им. Н.Э. Баумана»",
|
||||
"aliases": [
|
||||
"Ученые записки Казанской государственной академии ветеринарной медицины им. Н.Э. Баумана"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Ученые записки университета им. П.Ф. Лесгафта",
|
||||
|
||||
@@ -66,7 +66,14 @@ def _journal_key(value: str) -> str:
|
||||
@lru_cache(maxsize=1)
|
||||
def _k1_journals() -> dict[str, str]:
|
||||
data = json.loads(K1_JOURNALS.read_text(encoding="utf-8"))
|
||||
return {_journal_key(journal["name"]): journal["name"] for journal in data["journals"]}
|
||||
journals = {}
|
||||
for journal in data["journals"]:
|
||||
for name in [journal["name"], *journal.get("aliases", [])]:
|
||||
key = _journal_key(name)
|
||||
if key in journals and journals[key] != journal["name"]:
|
||||
raise ValueError("Ambiguous K1 journal alias")
|
||||
journals[key] = journal["name"]
|
||||
return journals
|
||||
|
||||
|
||||
def _journal_names(data: dict) -> list[str]:
|
||||
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.14.1"
|
||||
FRONTEND_VERSION = "0.14.1"
|
||||
BACKEND_VERSION = "0.14.1"
|
||||
APP_VERSION = "0.14.2"
|
||||
FRONTEND_VERSION = "0.14.2"
|
||||
BACKEND_VERSION = "0.14.2"
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.14.1"
|
||||
version = "0.14.2"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -8,6 +8,7 @@ from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import pdfplumber
|
||||
|
||||
@@ -28,8 +29,18 @@ def extract(source: Path) -> dict:
|
||||
rows.append(row)
|
||||
if not rows or [int(row[0]) for row in rows] != list(range(1, len(rows) + 1)):
|
||||
raise ValueError("Journal numbering is incomplete or duplicated")
|
||||
journals = [{"name": " ".join(row[1].split()), "categorized_on": row[3], "source_row": int(row[0])}
|
||||
for row in rows if row[2] == "К1"]
|
||||
journals = []
|
||||
for row in rows:
|
||||
if row[2] != "К1":
|
||||
continue
|
||||
name = " ".join(row[1].split())
|
||||
journal = {"name": name, "categorized_on": row[3], "source_row": int(row[0])}
|
||||
# Only an explicit administrative rename note supplies an alias.
|
||||
# Parentheses in journal names (e.g. bilingual titles) stay unchanged.
|
||||
rename = re.fullmatch(r"(.+?) \((до \d{2}\.\d{2}\.\d{4} г\. наименование в Перечне «(.+)»)\)", name)
|
||||
if rename:
|
||||
journal.update(name=rename[1], note=rename[2], aliases=[rename[3]])
|
||||
journals.append(journal)
|
||||
if not journals:
|
||||
raise ValueError("No K1 journals found")
|
||||
return {"source": source.name, "source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ def test_health_returns_versions():
|
||||
response = TestClient(app).get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["backend_version"] == "0.14.1"
|
||||
assert response.json()["backend_version"] == "0.14.2"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
|
||||
+54
-2
@@ -211,7 +211,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
|
||||
assert "<script>" in response.text and "<script>" not in response.text
|
||||
assert "javascript:" not in response.text and "private@example.org" not in response.text
|
||||
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
|
||||
assert "Backend 0.14.1 · Frontend 0.14.1" in response.text
|
||||
assert "Backend 0.14.2 · Frontend 0.14.2" 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
|
||||
@@ -345,11 +345,63 @@ def test_k1_reference_integrity():
|
||||
assert data["source_rows"] == 3134
|
||||
assert sum(data["category_counts"].values()) == 3134
|
||||
assert data["category_counts"]["К1"] == len(data["journals"]) == 773
|
||||
assert len(council._k1_journals()) == 773
|
||||
assert len(council._k1_journals()) == 774 # 773 journals and one documented previous name.
|
||||
assert "вестник российских университетов математика" not in council._k1_journals()
|
||||
assert "бизнес информатика" in council._k1_journals()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("journal", [
|
||||
"Ученые записки Казанской академии ветеринарной медицины им. Н.Э. Баумана",
|
||||
"Ученые записки Казанской государственной академии ветеринарной медицины им. Н.Э. Баумана",
|
||||
])
|
||||
@pytest.mark.parametrize("source", ["csv", "journal", "description"])
|
||||
def test_renamed_k1_journal_matches_current_and_documented_previous_name(db_session, export, journal, source):
|
||||
person = employee(db_session)
|
||||
if source == "csv":
|
||||
export([row(**{"Квартиль scopus": "", "Название журнала": journal})])
|
||||
else:
|
||||
fields = {"raw_data": {"journal": journal}} if source == "journal" else {
|
||||
"description": {"short": {"ru": f"{journal}. 2024. № 1."}}
|
||||
}
|
||||
publication(db_session, person, kind="ARTICLE", **fields)
|
||||
db_session.commit()
|
||||
result = council.council_candidates(db_session, date(2026, 10, 8))
|
||||
candidate = result["candidates"][0]
|
||||
assert [candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "total")] == [0, 0, 1, 1]
|
||||
canonical = "Ученые записки Казанской академии ветеринарной медицины им. Н.Э. Баумана"
|
||||
assert f"К1 · {canonical} (справочник ВАК)" in candidate["articles"][0]["criteria"]
|
||||
from app.services.council_export import council_xlsx
|
||||
book = load_workbook(BytesIO(council_xlsx(result)))
|
||||
assert [book["Сотрудники"].cell(2, column).value for column in (6, 7)] == [1, 1]
|
||||
|
||||
|
||||
def test_k1_rename_note_is_preserved_separately():
|
||||
import json
|
||||
data = json.loads(council.K1_JOURNALS.read_text(encoding="utf-8"))
|
||||
journal = next(item for item in data["journals"] if item["source_row"] == 699)
|
||||
assert "наименование в Перечне" not in journal["name"]
|
||||
assert journal["note"].startswith("до 10.04.2026 г. наименование в Перечне")
|
||||
assert len(journal["aliases"]) == 1
|
||||
assert council._k1_journals()[council._journal_key(journal["aliases"][0])] == journal["name"]
|
||||
bilingual = next(item for item in data["journals"] if item["source_row"] == 2)
|
||||
assert bilingual["name"] == "Acta Biomedica Scientifica (East Siberian Biomedical Journal)"
|
||||
|
||||
|
||||
def test_ambiguous_k1_alias_is_rejected(monkeypatch, tmp_path):
|
||||
import json
|
||||
reference = tmp_path / "k1.json"
|
||||
reference.write_text(json.dumps({"journals": [
|
||||
{"name": "One", "aliases": ["Previous"]}, {"name": "Two", "aliases": ["Previous"]}
|
||||
]}), encoding="utf-8")
|
||||
monkeypatch.setattr(council, "K1_JOURNALS", reference)
|
||||
council._k1_journals.cache_clear()
|
||||
try:
|
||||
with pytest.raises(ValueError, match="Ambiguous K1 journal alias"):
|
||||
council._k1_journals()
|
||||
finally:
|
||||
council._k1_journals.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reverse", [False, True])
|
||||
def test_conflicting_source_years_keep_categories_with_their_year(db_session, export, reverse):
|
||||
rows = [row(year="2020", **{"Название журнала": "Бизнес-информатика"}),
|
||||
|
||||
Reference in new issue
Block a user