Квартили и категории ВАК публикаций: отдельные поля и обогащение #66

Merged
admin merged 3 commits from feature/publication-quartiles into main 2026-10-09 08:14:22 +00:00
19 changed files with 1252 additions and 30 deletions

No files matched your search

+16
View File
@@ -1,5 +1,21 @@
# Changelog
## 0.18.1
- Проверка К1/К2 учитывает ISSN/eISSN исходной записи вместе с идентификаторами DOI, в том числе без сетевого поиска. Противоречие названия и ISSN не превращается в предварительное совпадение.
- Квартильные основания привязаны к собственному DOI и журналу. После изменения публикации парсером старые основания сохраняются, но исключаются из счётчиков при конфликте; обновление общего результата поиска не перепривязывает старые записи.
## 0.18.0
- Добавлено поле `vak_categories` с результатом проверки журнала по предоставленным спискам К1/К2, источником и способом сопоставления. Совпадение по названию отмечается отдельно от подтверждённого ISSN; текущий снимок не объявляется исторической категорией.
- Инструмент обогащения сохраняет все доступные квартильные категории всех систем, включая дополнительные сведения для публикаций с уже известным квартилем.
## 0.17.0
- Добавлено отдельное поле публикации `quartiles` с квартилями Q1–Q4, системой, годом метрики и источником. Данные ВШЭ переносятся без удаления исходных значений; новые сведения сопоставляются по DOI и ISSN.
- Добавлена возобновляемая проверка DOI и обогащение по квартильным каталогам: при отсутствии исторической метрики можно явно разрешить последний доступный год. Недоступные сведения и конфликты фиксируются отдельно; текущий квартиль не подменяет исторический зачёт в совете.
- Состав полей публикации, миграция и порядок обогащения описаны в документации.
## 0.16.5
- Закреплён обязательный перенос недостающих данных перед будущим production-выпуском: резервная копия, предварительная сверка, сохранение заполненных значений, отдельная обработка конфликтов и проверка полноты до обновления приложения.
+7 -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.16.5`. Админка всегда показывает версии backend и frontend в footer.
Версия сервиса: `0.18.1`. Админка всегда показывает версии backend и frontend в footer.
## Разовый импорт публикаций
@@ -200,6 +200,12 @@ python scripts/extract_k1_specialties.py "app/data/ВАК Перечень.pdf"
`k1_source_sha256` рассчитывается для исходного JSON с окончаниями строк LF; оба
JSON-справочника записываются с LF для воспроизводимости на Windows и Linux.
### Квартили публикаций и схема БД
Полный состав полей `employee_publications`, структура отдельного поля `quartiles`, ограничения сопоставления и команды обогащения описаны в [docs/publication-database.md](docs/publication-database.md). Для существующей БД добавлены nullable JSONB-поля миграциями `010_publication_quartiles.sql` и `011_publication_vak_categories.sql`; данные переносятся отдельным инструментом, а не при старте приложения.
Квартили Q1–Q4 из данных ВШЭ сохраняются с исходной системой; неизвестный год метрики остаётся неизвестным. Для остальных публикаций журнал устанавливается по DOI, рейтинг — по ISSN. Исторические SJR доступны через открытые данные университета Эстремадуры за 2009–2025; CiteScore/JCR читаются из проверенных каталогов. При отсутствии метрики за год публикации допускается явно разрешённый последний доступный год, с сохранением его года и источника. Отсутствие DOI, неоднозначность и недоступная метрика фиксируются без присвоения вымышленного квартиля. Парсер сохраняет новое поле при обновлении записи.
### Подготовка публикаций для портала
Портал читает `app/data/council_articles.json`: публикации представлены отдельными
+8
View File
@@ -37,6 +37,14 @@ def _ensure_runtime_schema() -> None:
models.EmployeePublication.__table__.create(bind=engine, checkfirst=True)
inspector = inspect(engine)
table_names = set(inspector.get_table_names())
if "employee_publications" in table_names:
publication_columns = {column["name"] for column in inspector.get_columns("employee_publications")}
for column in ("quartiles", "vak_categories"):
if column not in publication_columns:
with engine.begin() as connection:
connection.execute(text("ALTER TABLE employee_publications ADD COLUMN " +
("IF NOT EXISTS " if engine.dialect.name == "postgresql" else "") +
column + " " + ("JSONB" if engine.dialect.name == "postgresql" else "JSON")))
if "employees" in table_names and "employee_news_links" not in table_names:
models.EmployeeNewsLink.__table__.create(bind=engine, checkfirst=True)
inspector = inspect(engine)
+2
View File
@@ -108,6 +108,8 @@ class EmployeePublication(Base):
other_url: Mapped[str | None] = mapped_column(Text)
document_url: Mapped[str | None] = mapped_column(Text)
citation_text: Mapped[str | None] = mapped_column(Text)
quartiles: Mapped[dict | None] = mapped_column(json_type)
vak_categories: Mapped[dict | None] = mapped_column(json_type)
annotation: Mapped[dict | None] = mapped_column(json_type)
description: Mapped[dict | None] = mapped_column(json_type)
authors: Mapped[list | None] = mapped_column(json_type)
+19 -21
View File
@@ -11,14 +11,14 @@ from pathlib import Path
from urllib.parse import quote, urlsplit
from zoneinfo import ZoneInfo
from bs4 import BeautifulSoup
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.models import Employee
from app.services.academic_degrees import academic_degrees
from app.services.council_source import _doi, _journal_names, _name, _quartiles, load_reference
from app.services.council_source import _doi, _journal_names, _name, load_reference, _description_journals
from app.services.council_catalog import catalog, publication_catalog
from app.services.publication_quartiles import labels as quartile_labels, identity_issues, hse_entries, merge as merge_quartiles
from app.services.council_assessment import (
BRANCHES, assess_candidate, degree_level, evidence, publication_proofs,
publication_type_allowed, sourced,
@@ -96,19 +96,6 @@ def _k2_journals() -> dict:
return result
def _description_journals(description: dict) -> list[str]:
names = []
for field in ("short", "shortLeft"):
value = description.get(field)
values = value.values() if isinstance(value, dict) else [value]
for text in values:
if isinstance(text, str):
text = BeautifulSoup(text, "html.parser").get_text(" ", strip=True)
# HSE short descriptions start with the journal, followed by the year.
names.append(re.split(r"\b(?:19|20)\d{2}\b", text, maxsplit=1)[0].strip(" .,:;"))
return names
@lru_cache(maxsize=1)
def _articles_by_author() -> dict[str, list[dict]]:
return load_reference(EXPORT)
@@ -123,14 +110,24 @@ def _database_article(publication) -> dict:
kind = publication.publication_type
description = publication.description if isinstance(publication.description, dict) else raw.get("description")
journals = _journal_names(raw)
if isinstance(imported, dict):
journals.extend(_journal_names(imported))
if isinstance(description, dict):
journals.extend(_description_journals(description))
doi = _doi(publication.doi_url or "")
identifiers = [raw[key] for key in ("issn", "eissn", "ISSN", "EISSN") if isinstance(raw.get(key), str)]
current_journals = list(journals)
imported_quartiles = merge_quartiles(None, hse_entries(raw, publication.year))
issues = identity_issues(publication.quartiles, doi, current_journals, identifiers)
Outdated
Review

[P2] Проверьте идентичность сохранённого квартильного основания перед его зачётом. quartile_labels принимает только payload и год, поэтому изменение DOI или журнала при сохранённом годе не исключает старый квартиль. Воспроизведено цепочкой _apply_publication → _database_article: quartiles.lookup.doi='10.1234/old', запись Scopus Q1 за 2024 для Old journal; парсер обновляет ту же публикацию на DOI 10.1234/new, 2024 год и Different journal. Поле quartiles намеренно сохраняется, но совет выводит Scopus Q1 уже для нового DOI и тем самым может увеличить предварительные Q1/ВСЕГО на основании другого журнала. Обычный enrichment также не запускает DOI-поиск для заполненного entries без --all-systems. Сохранение прежних доказательств допустимо, однако их привязку нужно сверять с текущей публикацией: при конфликте DOI/журнала исключать их из зачёта и требовать перепроверки. Учтите привязку каждого основания, чтобы последующее обновление общего lookup не сделало старые записи основаниями нового DOI. Добавьте тест исправления DOI/журнала парсером при неизменном годе.

[P2] Проверьте идентичность сохранённого квартильного основания перед его зачётом. quartile_labels принимает только payload и год, поэтому изменение DOI или журнала при сохранённом годе не исключает старый квартиль. Воспроизведено цепочкой _apply_publication → _database_article: quartiles.lookup.doi='10.1234/old', запись Scopus Q1 за 2024 для Old journal; парсер обновляет ту же публикацию на DOI 10.1234/new, 2024 год и Different journal. Поле quartiles намеренно сохраняется, но совет выводит Scopus Q1 уже для нового DOI и тем самым может увеличить предварительные Q1/ВСЕГО на основании другого журнала. Обычный enrichment также не запускает DOI-поиск для заполненного entries без --all-systems. Сохранение прежних доказательств допустимо, однако их привязку нужно сверять с текущей публикацией: при конфликте DOI/журнала исключать их из зачёта и требовать перепроверки. Учтите привязку каждого основания, чтобы последующее обновление общего lookup не сделало старые записи основаниями нового DOI. Добавьте тест исправления DOI/журнала парсером при неизменном годе.
imported_issues = identity_issues(imported_quartiles, doi, current_journals, identifiers)
imported_doi = _doi(str(imported.get("DOI") or "")) if isinstance(imported, dict) else ""
if isinstance(imported, dict) and not imported_issues and (not imported_doi or imported_doi == doi):
journals.extend(_journal_names(imported))
issues = list(dict.fromkeys([*issues, *imported_issues]))
return {"title": publication.title, "year": publication.year, "doi": _doi(publication.doi_url or ""),
"types": [TYPES.get(kind.upper(), f"Другой тип: {kind}")] if kind else [],
"quartiles": _quartiles(imported) if isinstance(imported, dict) else [],
"journals": journals, "issns": [raw[key] for key in ("issn", "eissn") if isinstance(raw.get(key), str)],
"quartiles": list(dict.fromkeys([*quartile_labels(publication.quartiles, publication.year, doi, current_journals, identifiers),
*quartile_labels(imported_quartiles, publication.year, doi, current_journals, identifiers)])),
"quartile_issues": issues,
"journals": journals, "issns": [raw[key] for key in ("issn", "eissn", "ISSN", "EISSN") if isinstance(raw.get(key), str)],
"source_hashes": [publication.source_hash],
"origins": origins, "rows": [], "in_csv": bool(imported), "in_db": from_site,
"url": _safe_url(publication.url)}
@@ -163,9 +160,10 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
if key not in unique:
unique[key] = {**article, "doi": identifier,
**{field: list(article[field]) for field in ("types", "quartiles", "journals", "origins", "rows")},
"evidence": [], "issns": [], "source_hashes": []}
"evidence": [], "issns": [], "source_hashes": [], "quartile_issues": []}
merged = unique[key]
merged["evidence"].append(article)
merged["quartile_issues"] = list(dict.fromkeys([*merged["quartile_issues"], *article.get("quartile_issues", [])]))
for field in ("issns", "source_hashes"):
merged[field] = list(dict.fromkeys([*merged[field], *article.get(field, [])]))
for field in ("types", "quartiles", "journals", "origins", "rows"):
@@ -179,7 +177,7 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
# Never pick a conflicting source year merely because that row came first.
year = article["year"] = years[0] if len(years) == 1 else None
article["source_years"] = years
article["criteria"] = list(article["quartiles"])
article["criteria"] = [*article["quartiles"], *article["quartile_issues"]]
matches = list(dict.fromkeys(k1_journals[key] for name in article["journals"]
if (key := _journal_key(name)) in k1_journals))
article["criteria"].extend(f"К1 · {name} (справочник ВАК)" for name in matches)
+1 -1
View File
@@ -126,7 +126,7 @@ def publication_proofs(employee_evidence, article):
def assess_article(article, proofs, start, end, level, branch, specialty, policy):
reasons = list(article.get("catalog_issues", []))
reasons = [*article.get("catalog_issues", []), *article.get("quartile_issues", [])]
years = article["source_years"]
dates = set()
date_conflict = False
+15
View File
@@ -38,6 +38,21 @@ def _journal_names(data: dict) -> list[str]:
return names
def _description_journals(description: dict) -> list[str]:
from bs4 import BeautifulSoup
names = []
for field in ("short", "shortLeft"):
value = description.get(field)
values = value.values() if isinstance(value, dict) else [value]
for text in values:
if isinstance(text, str):
text = BeautifulSoup(text, "html.parser").get_text(" ", strip=True)
# HSE short descriptions start with the journal, followed by the year.
names.append(re.split(r"\b(?:19|20)\d{2}\b", text, maxsplit=1)[0].strip(" .,:;"))
return names
def prepare_reference(source: Path) -> dict:
publications = []
by_author = defaultdict(list)
+217
View File
@@ -0,0 +1,217 @@
"""Publication quartiles with source, system and year kept separately."""
from datetime import date
from collections import defaultdict
import re
from app.services.council_catalog import publication_catalog, safe_url, issn
from app.services.council_source import _name, _doi
SYSTEM_FIELDS = {"Scopus": "Квартиль scopus", "Web of Science": "Квартиль wos"}
QUARTILES = {"Q1", "Q2", "Q3", "Q4"}
def entries(payload):
if payload is None:
return []
if (not isinstance(payload, dict) or type(payload.get("schema_version")) is not int or payload["schema_version"] != 1
or not isinstance(payload.get("entries"), list)
or (payload.get("lookup") is not None and not isinstance(payload["lookup"], dict))
or (isinstance(payload.get("lookup"), dict) and payload["lookup"].get("doi") is not None
and not isinstance(payload["lookup"]["doi"], str))
or not all(isinstance(item, dict) and item.get("quartile") in QUARTILES
and item.get("system") in {*SYSTEM_FIELDS, "SCImago"}
and (item.get("doi") is None or isinstance(item["doi"], str))
and (item.get("journal") is None or isinstance(item["journal"], str))
and isinstance(item.get("issns", []), list)
and all(isinstance(value, str) for value in item.get("issns", []))
for item in payload["entries"])):
raise ValueError("Invalid publication quartiles")
return payload["entries"]
def hse_entries(raw, publication_year):
metadata = raw.get("csv_enrichment", {}) if isinstance(raw, dict) else {}
if not isinstance(metadata, dict):
return []
source_year = str(metadata.get("Год") or "").strip()
applicable_year = int(source_year) if source_year.isdigit() else publication_year
output = []
for system, field in SYSTEM_FIELDS.items():
quartile = str(metadata.get(field) or "").strip().upper()
if quartile in QUARTILES:
output.append({"system": system, "quartile": quartile, "metric": None,
"doi": _doi(str(metadata.get("DOI") or "")) or None,
"metric_year": None, "publication_year": applicable_year,
"category": None, "journal": metadata.get("Название журнала") or None,
"issns": [], "source": "Данные ВШЭ", "source_url": None,
"checked_on": date.today().isoformat()})
return output
def catalog_entries(doi_metadata, publication_year, allow_latest=False):
"""Only a journal identified by DOI/ISSN and the exact requested metric year."""
if doi_metadata.get("type") != "journal-article":
return [], "not_journal_article"
if publication_year is None:
return [], "missing_publication_year"
if publication_year not in doi_metadata.get("years", []):
return [], "publication_year_conflict"
if not doi_metadata.get("issns"):
return [], "missing_journal_issn"
records, issues = publication_catalog(doi_metadata["issns"], [])
if issues:
return [], "journal_identity_conflict"
metrics = [(record, metric) for record in records for metric in record["metrics"]]
selected_years = {}
for system in SYSTEM_FIELDS:
years = {metric["year"] for _, metric in metrics if metric["system"] == system}
selected_years[system] = publication_year if publication_year in years else max(years, default=None) if allow_latest else None
output = []
for record in records:
for metric in record["metrics"]:
if metric["year"] == selected_years.get(metric["system"]) and metric["system"] in SYSTEM_FIELDS:
output.append({"system": metric["system"], "quartile": metric["quartile"],
"doi": doi_metadata.get("doi"),
"metric": metric["metric"], "metric_year": metric["year"],
"publication_year": publication_year, "category": metric["category"],
"journal": record["name"], "issns": doi_metadata["issns"],
"source": metric["source"], "source_url": safe_url(metric["source_url"]),
"checked_on": date.today().isoformat()})
return output, ("found_latest" if any(item["metric_year"] != publication_year for item in output) else "found") if output else "historical_quartile_unavailable"
def merge(existing, additions, lookup=None):
current = entries(existing)
previous_doi = _doi(((existing or {}).get("lookup") or {}).get("doi") or "") or None
# Freeze the previous lookup's identity on legacy evidence before replacing it.
output = [{**item, "doi": previous_doi} if "doi" not in item else dict(item) for item in current]
# Repeating a lookup must not duplicate evidence merely because its date changed.
def key(item):
return tuple(item.get(field) for field in ("system", "quartile", "metric", "metric_year",
"publication_year", "category", "journal", "source", "source_url", "doi"))
seen = {key(item) for item in output}
for item in additions:
if key(item) not in seen:
output.append(item)
seen.add(key(item))
result = {**(existing or {}), "schema_version": 1, "entries": output}
if lookup is not None:
result["lookup"] = lookup
return result
def binding_issue(item, payload, doi="", journals=(), identifiers=()):
value = item.get("doi") if "doi" in item else ((payload or {}).get("lookup") or {}).get("doi")
source_doi = _doi(value or "")
if value and not source_doi:
return "DOI сохранённого квартиля некорректен — требуется перепроверка"
current_doi = _doi(doi or "")
if source_doi and source_doi != current_doi:
return "Сохранённый квартиль относится к другому DOI — требуется перепроверка"
source_ids = {issn(value) for value in item.get("issns", [])} - {""}
current_ids = {issn(value) for value in identifiers} - {""}
ids_match = bool(source_ids & current_ids)
if source_ids and current_ids and not ids_match:
return "ISSN сохранённого квартиля и публикации расходятся — требуется перепроверка"
source_name = _name(item.get("journal") or "")
names_match = bool(source_name and source_name in {_name(name) for name in journals if name})
if source_name and journals and not names_match and not ids_match:
return "Журнал сохранённого квартиля и публикации расходятся — требуется перепроверка"
if not (source_doi and source_doi == current_doi) and not ids_match and not names_match:
return "Привязка сохранённого квартиля к публикации не подтверждена — требуется перепроверка"
return None
def identity_issues(payload, doi="", journals=(), identifiers=()):
return list(dict.fromkeys(issue for item in entries(payload)
if (issue := binding_issue(item, payload, doi, journals, identifiers))))
def labels(payload, publication_year, doi="", journals=(), identifiers=()):
return list(dict.fromkeys(f"{item['system']} {item['quartile']}" for item in entries(payload)
if item["quartile"] in {"Q1", "Q2"}
and item["system"] in SYSTEM_FIELDS
and item.get("publication_year") == publication_year
and item.get("metric_year") in {None, publication_year}
and not binding_issue(item, payload, doi, journals, identifiers)))
def index_sjr(rankings):
from app.services.council_catalog import issn
indexed = {}
for year, records in rankings.items():
by_issn = defaultdict(list)
for record in records:
for identifier in set(map(issn, record["issns"])) - {""}:
by_issn[identifier].append(record)
indexed[int(year)] = dict(by_issn)
return indexed
def with_title_history(metadata, by_issn, by_title, source_url):
"""Use explicit Scopus title succession, never a fuzzy journal-name match."""
from app.services.council_catalog import issn
if not safe_url(source_url):
return metadata
extra = set()
names = {_name(name) for name in metadata.get("journals", [])}
for identifier in metadata.get("issns", []):
for old in by_issn.get(issn(identifier), []):
if old["history"] not in {"Continued as", "Continues"}:
continue
for related in old["related"]:
candidates = by_title.get(_name(related), [])
if _name(related) in names and len(candidates) == 1:
extra.update(candidates[0]["issns"])
if not extra:
return metadata
return {**metadata, "ranking_issns": sorted(set(metadata["issns"]) | extra),
"journal_identity_source_url": source_url}
def sjr_entries(metadata, publication_year, rankings, allow_latest=False):
from app.services.council_catalog import issn
if metadata.get("type") != "journal-article":
return [], "not_journal_article"
if publication_year is None or publication_year not in metadata.get("years", []):
return [], "publication_year_conflict"
matches = {}
identifiers = set(map(issn, metadata.get("ranking_issns", metadata.get("issns", [])))) - {""}
for year, by_issn in rankings.items():
journals = {record["source_url"]: record for identifier in identifiers for record in by_issn.get(identifier, [])}
if journals:
matches[int(year)] = journals
year = publication_year if publication_year in matches else max(matches, default=None) if allow_latest else None
if year is None:
return [], "historical_quartile_unavailable"
if len(matches[year]) != 1:
return [], "journal_identity_conflict"
record = next(iter(matches[year].values()))
if record.get("conflict"):
return [], "ranking_category_conflict"
categories = []
for category in record.get("categories", "").split(";"):
if not category.strip():
continue
match = re.fullmatch(r"\s*(.+?)\s*\((Q[1-4])\)\s*", category)
if match:
categories.append((match[1].strip(), match[2]))
else:
return [], "ranking_category_conflict"
if categories and min(quartile for _, quartile in categories) != record["quartile"]:
return [], "ranking_category_conflict"
if not categories and record.get("quartile") in QUARTILES:
categories = [(None, record["quartile"])]
result = [{"system": "SCImago", "quartile": quartile, "metric": "SJR" if category else "SJR best quartile",
"doi": metadata.get("doi"),
"metric_year": year, "publication_year": publication_year, "category": category,
"journal": record["journal"], "issns": record["issns"],
"source": "SCImago Journal & Country Rank · Universidad de Extremadura",
"source_url": record["source_url"], "checked_on": date.today().isoformat()}
for category, quartile in categories]
if metadata.get("journal_identity_source_url"):
for item in result:
item["journal_identity_source_url"] = metadata["journal_identity_source_url"]
return result, "found" if year == publication_year else "found_latest"
+71
View File
@@ -0,0 +1,71 @@
"""Match a publication's journal to the supplied K1/K2 category snapshots."""
from collections import defaultdict
from datetime import date
from functools import lru_cache
import hashlib
import json
from pathlib import Path
import re
from app.services.council_catalog import issn
from app.services.council_source import _name
DATA = Path(__file__).resolve().parents[1] / "data"
def journal_key(value):
return " ".join(re.findall(r"\w+", _name(value)))
@lru_cache(maxsize=1)
def category_index():
files = {category: DATA / filename for category, filename in
(("К1", "k1_journals.json"), ("К2", "k2_journals.json"))}
documents = {category: json.loads(path.read_text(encoding="utf8")) for category, path in files.items()}
if documents["К1"]["source_sha256"] != documents["К2"]["source_sha256"]:
raise ValueError("VAK category source mismatch")
identities = json.loads((DATA / "k1_specialties.json").read_text(encoding="utf8"))
if identities["k1_source_sha256"] != hashlib.sha256(files["К1"].read_bytes().replace(b"\r\n", b"\n")).hexdigest():
raise ValueError("VAK journal identity source mismatch")
known = {item["name"]: sorted({issn(entry["issn"]) for entry in item["vak_entries"]})
for item in identities["journals"] if item.get("status") == "matched"}
by_name, by_issn = defaultdict(list), defaultdict(list)
for category, document in documents.items():
for journal in document["journals"]:
record = {"category": category, "journal": journal["name"], "source": document["source"],
"source_sha256": document["source_sha256"], "source_row": journal["source_row"],
"categorized_on": journal["categorized_on"], "issns": known.get(journal["name"], []) if category == "К1" else []}
if record["issns"]:
record.update(identity_source=identities["source"], identity_source_sha256=identities["source_sha256"],
identity_as_of=identities["as_of"])
for name in [journal["name"], *journal.get("aliases", [])]:
by_name[journal_key(name)].append(record)
for identifier in record["issns"]:
by_issn[identifier].append(record)
return dict(by_name), dict(by_issn)
def assess_vak(names, identifiers):
by_name, by_issn = category_index()
matches = {}
normalized = {issn(value) for value in identifiers} - {""}
for identifier in normalized:
for record in by_issn.get(identifier, []):
matches[(record["category"], record["source_row"])] = {**record, "match_method": "issn", "identity_confirmed": True}
for name in names:
for record in by_name.get(journal_key(name), []):
if record["issns"] and normalized and not normalized.intersection(record["issns"]):
return {"schema_version": 1, "entries": [], "status": "journal_identity_conflict",
"scope": "supplied_category_snapshot", "historical_category_confirmed": False, "checked_on": date.today().isoformat()}
key = record["category"], record["source_row"]
matches.setdefault(key, {**record, "match_method": "normalized_name", "identity_confirmed": False})
if len(matches) > 1:
status, output = "ambiguous_journal", []
elif matches:
status, output = "found", list(matches.values())
else:
status, output = ("not_found_in_k1_k2" if names or identifiers else "unknown_journal"), []
return {"schema_version": 1, "entries": output, "status": status,
"scope": "supplied_category_snapshot", "historical_category_confirmed": False,
"checked_on": date.today().isoformat()}
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "0.16.5"
FRONTEND_VERSION = "0.16.5"
BACKEND_VERSION = "0.16.5"
APP_VERSION = "0.18.1"
FRONTEND_VERSION = "0.18.1"
BACKEND_VERSION = "0.18.1"
+133
View File
@@ -0,0 +1,133 @@
# Запись публикации в БД
Объект `EmployeePublication`, таблица `employee_publications`. Одна запись относится к одному сотруднику; одна работа у нескольких соавторов может иметь несколько записей с одним DOI. Типы ниже указаны для PostgreSQL; в SQLite JSONB представлен JSON.
| Поле | Тип | Обязательное | Назначение |
|---|---|:---:|---|
| `id` | integer | Да | Первичный ключ |
| `employee_id` | integer | Да | Внешний ключ `employees.id`; удаление сотрудника удаляет связанные записи |
| `publication_id` | varchar(64) | Нет | Идентификатор публикации в источнике ВШЭ |
| `title` | text | Да | Название |
| `year` | integer | Нет | Год публикации |
| `publication_type` | varchar(64) | Нет | Тип материала: ARTICLE, CHAPTER, BOOK, PREPRINT и другие |
| `language` | varchar(16) | Нет | Язык |
| `status` | integer | Нет | Код статуса из источника |
| `url` | text | Нет | Основная ссылка |
| `doi_url` | text | Нет | DOI или ссылка DOI |
| `other_url` | text | Нет | Дополнительная ссылка |
| `document_url` | text | Нет | Ссылка на документ |
| `citation_text` | text | Нет | Библиографическое описание |
| `quartiles` | jsonb | Нет | Квартили Q1–Q4 с системой, годом метрики, источником и результатом поиска |
| `vak_categories` | jsonb | Нет | Результат проверки журнала по предоставленным спискам К1/К2, способ идентификации и источник |
| `annotation` | jsonb | Нет | Аннотация |
| `description` | jsonb | Нет | Структурированное описание |
| `authors` | jsonb | Нет | Список авторов |
| `raw_data` | jsonb | Нет | Исходные данные, включая обогащение из данных ВШЭ |
| `source_hash` | varchar(64) | Да | Хеш исходной записи |
| `created_at` | timestamptz | Да | Создание записи |
| `updated_at` | timestamptz | Да | Обновление записи |
Уникальны пары `(employee_id, publication_id)` и `(employee_id, source_hash)`. Индексы: `employee_id`, `publication_id`, `doi_url`, `year`, `publication_type`. Атрибут ORM `employee` ссылается на объект сотрудника и отдельным столбцом не является.
## Поле квартилей
`NULL` означает, что обогащение ещё не выполнялось. После проверки поле содержит объект со схемой версии 1. Пустой `entries` означает отсутствие найденного квартиля; причина сохраняется в `lookup`. Пустой результат не равнозначен Q4 и не доказывает отсутствие индексации.
Пример структуры (иллюстративные данные):
```json
{
"schema_version": 1,
"entries": [
{
"system": "Scopus",
"quartile": "Q2",
"doi": "10.1234/example",
"metric": "CiteScore",
"metric_year": 2024,
"publication_year": 2024,
"category": "Applied Mathematics",
"journal": "Название журнала",
"issns": ["0006-3835"],
"source": "Название проверенного источника",
"source_url": "https://example.org/metrics",
"checked_on": "2026-10-08"
}
],
"lookup": {
"doi": "10.1234/example",
"status": "found",
"requested_year": 2024,
"checked_on": "2026-10-08"
}
}
```
- `entries` сохраняет все найденные категории и системы отдельно, без выбора одного «лучшего» квартиля вместо остальных. Возможные системы: Scopus, Web of Science, SCImago. Метрики CiteScore, JCR и SJR не подменяют друг друга. При наличии только лучшего квартиля SJR используется метрика `SJR best quartile`, категория остаётся `null`.
- Каждое основание хранит собственный `doi`. При дополнении старого payload основания без этого ключа получают DOI из прежнего `lookup` до его обновления; при отсутствии прежнего DOI сохраняется `null`. Новый общий `lookup` не перепривязывает прежние записи. DOI данных ВШЭ берётся из их собственных исходных метаданных.
- Перед использованием в счётчиках основание сверяется с текущими DOI, журналом и ISSN публикации. Конфликт исключает это основание и выводится как причина перепроверки; доказательства сохраняются. Совпадающий ISSN позволяет подтвердить переименование журнала, но не снимает конфликт DOI. Основание без проверяемой привязки не засчитывается по одному совпадению года. Это также относится к обратному чтению `raw_data.csv_enrichment`.
- Значения `Квартиль scopus` и `Квартиль wos` из `raw_data.csv_enrichment` переносятся в отдельные записи Q1–Q4. Их точная метрика и год метрики неизвестны: `metric` и `metric_year` остаются `null`. Год публикации не объявляется годом метрики. Исходное обогащение не удаляется.
- При поиске новых значений журнал определяется по DOI через [Crossref](https://www.crossref.org/documentation/retrieve-metadata/rest-api/), а при отсутствии записи — через публичный [DataCite API](https://support.datacite.org/docs/rest-api). ISSN связывает журнал с рейтингом. Проверяются идентичность DOI, тип материала и соответствие года публикации данным DOI. Книге или главе не присваивается журнальный квартиль по одному похожему названию. Препринт DataCite не получает квартиль журнала связанной статьи автоматически.
- Для старого ISSN допускается подтверждённая преемственность названия по официальному [списку Scopus за август 2026](https://downloads.ctfassets.net/o78em1y1w4i4/7xtaTxNiNcWRTeZkV86eNy/69cf2d506c905dc299531fdc93049dbb/ext_list_Aug_2026.xlsx). Нужны явная связь Continued as/Continues и единственное связанное название, совпадающее с журналом в ответе DOI. Похожесть названий не используется. Исходные ISSN Crossref сохраняются; дополнительные ISSN для рейтинга и ссылка на историю записываются отдельно в `lookup`, ссылка `journal_identity_source_url` также сохраняется в найденных квартилях.
- Для каждой системы сначала используется метрика за год публикации. При явно разрешённом `--allow-latest` и отсутствии исторической метрики допускается последний доступный год: он остаётся в `metric_year`, а результат имеет статус `found_latest`. Все категории CiteScore/JCR и SJR сохраняются вместе; разные системы могут иметь разные годы метрики. Это последний год в доступных проверенных источниках, а не обещание глобально самого нового рейтинга.
- Источники: проверяемые каталоги проекта (CiteScore/JCR) и исторические SJR-данные [Universidad de Extremadura](https://opendata.unex.es/dataset/revistas-cientificas-2025) за 2009–2025. Портал указывает SCImago как автора и поддерживающую организацию и публикует данные по CC BY 3.0; атрибуция и ссылка сохраняются в каждой записи. [SJR](https://www.scimagolab.com/products/sjr-scimago-journal-country-rank/) хранится с системой SCImago, даже несмотря на происхождение исходных данных из Scopus.
`lookup` также может содержать полученные по DOI тип материала, названия журнала, ISSN, годы и ссылку на регистратора DOI. Состояния поиска:
| Статус | Значение |
|---|---|
| `found` | Найдена метрика за год публикации |
| `found_latest` | Использован другой, последний доступный год |
| `missing_doi` / `invalid_doi` | DOI отсутствует или некорректен |
| `doi_not_found` / `doi_lookup_failed` | DOI не найден в Crossref и DataCite / источник не ответил успешно; DOI может относиться к другому регистратору |
| `doi_identity_conflict` | Ответ источника относится к другому DOI |
| `not_journal_article` | DOI относится к материалу, не определённому как журнальная статья |
| `missing_publication_year` / `publication_year_conflict` | Год неизвестен / отличается от годов источника DOI |
| `missing_journal_issn` | Журнал по ISSN не установлен |
| `journal_identity_conflict` | Идентификаторы связываются с несколькими журналами |
| `ranking_category_conflict` | Сведения о категориях SJR противоречат друг другу |
| `historical_quartile_unavailable` | Проверенные источники не дали допустимой метрики |
## Миграция и обогащение
Миграции `migrations/010_publication_quartiles.sql` и `migrations/011_publication_vak_categories.sql` добавляют nullable JSONB-поля без удаления или перезаписи данных. Инициализация приложения также добавляет поля на существующей схеме PostgreSQL/SQLite. Обогащение не запускается при старте API или обработке HTTP-запроса.
Перед изменением заполненной БД создать и проверить резервную копию. Подключение инструмента — только через `DATABASE_URL` из окружения; `.env` и файлы с credentials инструмент не читает. Не передавать секреты в аргументах и не коммитить отчёты, кэши и резервные копии.
Сначала выполнить предварительное сравнение без записи, затем перенос известных значений:
```sh
python scripts/enrich_publication_quartiles.py --cache-dir .impeccable/quartiles/cache --report .impeccable/quartiles/hse-plan.json
python scripts/enrich_publication_quartiles.py --apply --cache-dir .impeccable/quartiles/cache --report .impeccable/quartiles/hse-applied.json
```
Затем проверить DOI у записей без квартиля. Для предварительного сравнения убрать `--apply`:
```sh
python scripts/enrich_publication_quartiles.py --apply --lookup-doi --all-systems --check-vak --sjr-history --allow-latest --workers 4 --cache-dir .impeccable/quartiles/cache --report .impeccable/quartiles/doi-applied.json
```
Для проверки смены ISSN добавить `--scopus-title-history /путь/ext_list_Aug_2026.xlsx`. Используется конкретный официальный снимок: SHA-256 `11e81f686401c89fbef28de31d1880388bb7122f35c89e09db6e1848237e9afb`; несоответствующий файл отклоняется. Это список названий и их истории, не источник квартилей.
`--all-systems` проверяет также публикации с уже известным квартилем: добавляются остальные доступные системы и предметные категории. Без этого флага проверяются записи без квартиля, ранее найденные последние метрики и заполненные основания с изменившейся или неподтверждённой привязкой. Новые доказательства дополняют список, не удаляя прежние. Несколько категорий с одинаковым Q1 сохраняются отдельно; один квартиль не заменяет остальные.
## Поле категорий ВАК
`vak_categories` имеет `schema_version: 1`, массив `entries`, `status`, `scope: "supplied_category_snapshot"`, `historical_category_confirmed: false` и дату `checked_on`. `NULL` — проверка ещё не проводилась. В каждом совпадении сохраняются `category` (К1/К2), каноническое название журнала, название и SHA-256 источника, строка источника, дата категоризации, ISSN при наличии, `match_method` и `identity_confirmed`.
Проверяются `k1_journals.json` и `k2_journals.json`, извлечённые из предоставленного документа «ВАК категории.pdf». Для К1 доступна дополнительная идентификация по ISSN из `k1_specialties.json`; её источник, SHA-256 и дата списка сохраняются отдельно. В проверку передаются ISSN/eISSN исходного `raw_data`, данных ВШЭ и актуального ответа DOI; исходные идентификаторы используются и без сетевого поиска или DOI. Ответ, сохранённый для другого DOI, не считается актуальным источником идентификаторов. Поиск К2 в текущих данных выполняется по нормализованному названию и явным административным переименованиям. Транслитерация, похожесть названий и предположение о соответствии русской и переводной версии не используются.
- `match_method: "issn"`, `identity_confirmed: true` — журнал идентифицирован по ISSN.
- `match_method: "normalized_name"`, `identity_confirmed: false` — название совпало со списком; идентичность по ISSN не подтверждена. Это предварительное совпадение.
- `status: "found"` — одно совпадение; `ambiguous_journal` или `journal_identity_conflict` — неоднозначность/конфликт, категория автоматически не выбирается.
- `not_found_in_k1_k2` — совпадений в этих двух снимках нет; это не доказывает отсутствие журнала в ВАК или наличие К3. `unknown_journal` — данных для идентификации журнала нет.
Дата категоризации в документе не объявляется датой всего снимка, а категория по имеющемуся списку не подтверждает её наличие в год публикации. Книги и главы не становятся журнальными статьями из-за совпадения названия. Проверка К1/К2 не является подтверждением зачёта конкретной работы по критериям совета.
`--check-vak` сохраняет этот результат в отдельном поле. Уже заполненные отличающиеся значения не перезаписываются; число таких случаев отражается в `vak_existing_value_kept`. Их нужно отдельно сравнить с актуальными источниками. Проверить результаты и причины неоднозначности до использования данных в заключении.
Запросы DOI кэшируются по нормализованному DOI и переиспользуются для соавторов. Ошибки сети допускают повторную попытку; для полного обновления ранее найденных ответов использовать новый каталог кэша. Исторические рейтинги запрашиваются только для ISSN найденных журналов; ошибки источника отражаются в `ranking_failures` отчёта. Существующие квартильные записи не перезаписываются; повторный перенос не дублирует доказательства только из-за новой даты проверки. Перед записью инструмент проверяет, что публикация не изменилась параллельно; при изменении пропускает её и фиксирует `concurrent_change_skipped`. Коммит выполняется по публикациям: прерванную обработку можно возобновить. Проверить итоговый отчёт и полноту переноса после завершения.
Парсер сохраняет `quartiles`, обновляя остальные исходные поля публикации. Страница совета читает отдельное поле с обратной совместимостью для исходных данных ВШЭ. В предварительные Q1/Q2 совета попадают Scopus/Web of Science с применимым годом; последние метрики другого года и SJR автоматически не превращаются в исторический зачёт или документальное подтверждение критериев.
Миграция и обогащение локальной БД не обновляют production. Порядок переноса только недостающих данных в production и сохранения его заполненных значений описан в README.
+2
View File
@@ -0,0 +1,2 @@
-- Source/year-aware quartiles; NULL means enrichment has not run yet.
ALTER TABLE employee_publications ADD COLUMN IF NOT EXISTS quartiles JSONB;
@@ -0,0 +1,2 @@
-- Matches to the supplied category snapshot, with provenance and identity state.
ALTER TABLE employee_publications ADD COLUMN IF NOT EXISTS vak_categories JSONB;
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "0.16.5"
version = "0.18.1"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+340
View File
@@ -0,0 +1,340 @@
"""Backfill HSE quartiles and resolve remaining DOI journals. No .env or DDL."""
import argparse
from collections import Counter, defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import date, datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import re
import sys
import time
from urllib.parse import quote
import requests
from sqlalchemy import MetaData, Table, create_engine, select, update
from openpyxl import load_workbook
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.services.council_source import _doi, _name, _journal_names, _description_journals
from app.services.council_catalog import issn
from app.services.publication_quartiles import catalog_entries, entries, hse_entries, merge, sjr_entries, index_sjr, with_title_history, identity_issues
from app.services.publication_vak import assess_vak
SOURCE_TITLES_URL = "https://downloads.ctfassets.net/o78em1y1w4i4/7xtaTxNiNcWRTeZkV86eNy/69cf2d506c905dc299531fdc93049dbb/ext_list_Aug_2026.xlsx"
SOURCE_TITLES_SHA256 = "11e81f686401c89fbef28de31d1880388bb7122f35c89e09db6e1848237e9afb"
def load_title_history(path):
if hashlib.sha256(path.read_bytes()).hexdigest() != SOURCE_TITLES_SHA256:
raise ValueError("Scopus title-list snapshot checksum mismatch")
by_issn, by_title = defaultdict(list), defaultdict(list)
workbook = load_workbook(path, read_only=True, data_only=True)
try:
rows = workbook.worksheets[0].iter_rows(values_only=True)
columns = next(rows)
required = {"Source Title", "ISSN", "EISSN", "Source Type", "Title History Indication", "Related Title 1"}
if not required <= set(columns):
raise ValueError("Invalid Scopus title list")
for row in rows:
data = dict(zip(columns, row))
if data["Source Type"] != "Journal":
continue
identifiers = {issn(data.get(key)) for key in ("ISSN", "EISSN")} - {""}
identifiers = {identifier[:4] + "-" + identifier[4:] for identifier in identifiers if len(identifier) == 8}
record = {"title": str(data["Source Title"] or ""), "issns": identifiers,
"history": data["Title History Indication"],
"related": [str(value) for key, value in data.items() if key and "Related Title" in key and value]}
by_title[_name(record["title"])].append(record)
for identifier in identifiers:
by_issn[issn(identifier)].append(record)
finally:
workbook.close()
return dict(by_issn), dict(by_title)
def load_sjr(identifiers, years, cache):
"""Read the university's licensed SCImago dataset for the requested ISSNs."""
rankings = {}
failures = []
for year in sorted(set(years) & set(range(2009, 2026))):
records = {}
for offset in range(0, len(identifiers), 200):
batch = identifiers[offset:offset + 200]
digest = hashlib.sha256(json.dumps([year, batch]).encode()).hexdigest()
path = cache / f"sjr-{digest}.json"
if path.exists():
bindings = json.loads(path.read_text(encoding="utf8"))
else:
values = " ".join(json.dumps(value) for value in batch)
query = f'''PREFIX bibo: <http://purl.org/ontology/bibo/>
PREFIX dct: <http://purl.org/dc/terms/>
PREFIX ou: <https://opendata.unex.es/def/ontouniversidad#>
SELECT DISTINCT ?uri ?title ?issn ?eissn ?quartile ?categories WHERE {{
VALUES ?wanted {{ {values} }}
{{ ?uri bibo:issn ?wanted }} UNION {{ ?uri bibo:eissn ?wanted }}
?uri a bibo:Journal; dct:title ?title; dct:identifier ?id; ou:sjrMejorCuartil ?quartile .
FILTER(STRSTARTS(STR(?id), "{year}-"))
OPTIONAL {{ ?uri bibo:issn ?issn }} OPTIONAL {{ ?uri bibo:eissn ?eissn }}
OPTIONAL {{ ?uri ou:categoriaSJR ?categories }}
}} LIMIT 10001'''
try:
response = requests.get("https://opendata.unex.es/sparql/", timeout=45,
params={"query": query, "format": "application/sparql-results+json"},
headers={"Accept": "application/sparql-results+json"})
response.raise_for_status()
bindings = response.json()["results"]["bindings"]
if len(bindings) >= 10000:
raise ValueError("Truncated ranking query")
path.write_text(json.dumps(bindings, ensure_ascii=False), encoding="utf8")
except (requests.RequestException, ValueError, KeyError, TypeError):
failures.append({"year": year, "batch": offset, "status": "ranking_source_unavailable"})
continue
for item in bindings:
value = lambda field: item.get(field, {}).get("value", "")
url = value("uri")
if not url.startswith(f"https://opendata.unex.es/recurso/ciencia-tecnologia/investigacion/revistas/Revista/{year}-"):
raise ValueError("Ranking year/identity mismatch")
record = {"journal": value("title"), "issns": sorted({value("issn"), value("eissn")} - {""}),
"quartile": value("quartile"), "categories": value("categories"), "source_url": url}
if record["quartile"] not in {"Q1", "Q2", "Q3", "Q4"}:
continue
previous = records.get(url)
if previous:
record["journal"] = min(previous["journal"], record["journal"])
record["issns"] = sorted(set(previous["issns"]) | set(record["issns"]))
record["categories"] = "; ".join(sorted({category.strip() for value in
(previous["categories"], record["categories"]) for category in value.split(";") if category.strip()}))
record["conflict"] = previous.get("conflict", False) or previous["quartile"] != record["quartile"]
records[url] = record
rankings[year] = list(records.values())
print(f"SJR {year}: {len(records)} matching journals", flush=True)
return index_sjr(rankings), failures
def resolve_datacite(doi, timeout):
url = "https://api.datacite.org/dois/" + quote(doi, safe="")
result = {"doi": doi, "status": "doi_lookup_failed", "source_url": url, "datacite_checked": True}
for attempt in range(3):
try:
response = requests.get(url, timeout=timeout, allow_redirects=False,
headers={"User-Agent": "MIEM-workers/0.18 publication-quartiles", "Accept": "application/json"})
if response.status_code == 404:
return {**result, "status": "doi_not_found"}
if response.status_code == 429 or response.status_code >= 500:
time.sleep(min(10, 2 ** attempt))
continue
response.raise_for_status()
metadata = response.json()["data"]["attributes"]
if _doi(metadata.get("doi", "")) != doi:
return {**result, "status": "doi_identity_conflict"}
types = metadata.get("types", {})
general = types.get("resourceTypeGeneral")
journal_article = general == "JournalArticle" or (general in {None, "Text"} and types.get("citeproc") == "article-journal")
container = metadata.get("container") or {}
identifiers = []
if container.get("identifierType") == "ISSN":
identifiers.append(container.get("identifier", ""))
for related in metadata.get("relatedIdentifiers", []):
if related.get("relatedIdentifierType") == "ISSN" and related.get("relationType") in {"IsPartOf", "IsPublishedIn"}:
identifiers.append(related.get("relatedIdentifier", ""))
identifiers = sorted({value[:4] + "-" + value[-4:] for value in identifiers if isinstance(value, str)
and re.fullmatch(r"\d{4}-?\d{3}[\dX]", value, re.I)})
year = metadata.get("publicationYear")
years = [int(year)] if str(year).isdigit() else []
return {**result, "status": "resolved", "type": "journal-article" if journal_article else "non-journal-material",
"issns": identifiers, "journals": [container["title"]] if isinstance(container.get("title"), str) else [], "years": years}
except (requests.RequestException, ValueError, KeyError, TypeError, AttributeError):
if attempt < 2:
time.sleep(2 ** attempt)
return result
def resolve_doi(doi, cache, timeout=20):
path = cache / (hashlib.sha256(doi.encode()).hexdigest() + ".json")
if path.exists():
result = json.loads(path.read_text(encoding="utf8"))
if result.get("doi") == doi and (result.get("status") == "resolved"
or (result.get("status") == "doi_not_found" and result.get("datacite_checked"))):
return result
url = "https://api.crossref.org/works/" + quote(doi, safe="")
result = {"doi": doi, "status": "doi_lookup_failed", "source_url": url}
for attempt in range(3):
try:
response = requests.get(url, timeout=timeout, allow_redirects=False,
headers={"User-Agent": "MIEM-workers/0.18 publication-quartiles",
"Accept": "application/json"})
if response.status_code == 404:
result = resolve_datacite(doi, timeout)
break
if response.status_code == 429 or response.status_code >= 500:
time.sleep(min(10, 2 ** attempt))
continue
response.raise_for_status()
message = response.json()["message"]
if _doi(message.get("DOI", "")) != doi:
result["status"] = "doi_identity_conflict"
break
years = set()
for field in ("published", "published-print", "published-online", "issued"):
for parts in message.get(field, {}).get("date-parts", []):
if parts and type(parts[0]) is int:
years.add(parts[0])
result.update(status="resolved", type=message.get("type"),
journals=[v for v in message.get("container-title", []) if isinstance(v, str)],
issns=[v for v in message.get("ISSN", []) if isinstance(v, str)
and re.fullmatch(r"\d{4}-\d{3}[\dX]", v, re.I)], years=sorted(years))
break
except (requests.RequestException, ValueError, KeyError, TypeError, AttributeError):
if attempt < 2:
time.sleep(2 ** attempt)
if result["status"] in {"resolved", "doi_not_found"}:
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(result, ensure_ascii=False), encoding="utf8")
temporary.replace(path)
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apply", action="store_true", help="Commit changes; default is read-only")
parser.add_argument("--lookup-doi", action="store_true")
parser.add_argument("--all-systems", action="store_true", help="Collect additional systems/categories even for publications with known quartiles")
parser.add_argument("--check-vak", action="store_true", help="Check the supplied K1/K2 snapshots")
parser.add_argument("--allow-latest", action="store_true", help="Use latest available metric if the publication year is unavailable")
parser.add_argument("--sjr-history", action="store_true", help="Check UNEx/SCImago historical quartiles by DOI ISSNs")
parser.add_argument("--scopus-title-history", type=Path, help="Official Scopus August 2026 source-title XLSX for explicit ISSN succession")
parser.add_argument("--cache-dir", type=Path, required=True)
parser.add_argument("--report", type=Path, required=True)
parser.add_argument("--workers", type=int, choices=range(1, 9), default=4)
args = parser.parse_args()
pending = args.report.with_suffix(args.report.suffix + ".pending")
if args.scopus_title_history and args.scopus_title_history.resolve() in {args.report.resolve(), pending.resolve()}:
raise ValueError("Report must not overwrite the source title list")
args.cache_dir.mkdir(parents=True, exist_ok=True)
args.report.parent.mkdir(parents=True, exist_ok=True)
# Verify report destination before changing any data.
pending.write_text("", encoding="utf8")
engine = create_engine(os.environ["DATABASE_URL"])
table = Table("employee_publications", MetaData(), autoload_with=engine)
if "quartiles" not in table.c:
raise ValueError("Apply migration 010 before enrichment")
fields = ["id", "year", "doi_url", "raw_data", "quartiles", "description"]
if args.check_vak:
if "vak_categories" not in table.c:
raise ValueError("Apply migration 011 before VAK enrichment")
fields.extend(["publication_type", "vak_categories"])
columns = [table.c[field] for field in fields]
with engine.connect() as connection:
rows = [dict(row) for row in connection.execute(select(*columns)).mappings()]
def needs_lookup(row):
raw = row["raw_data"] if isinstance(row["raw_data"], dict) else {}
names = _journal_names(raw)
description = row["description"] if isinstance(row["description"], dict) else raw.get("description", {})
if isinstance(description, dict):
names.extend(_description_journals(description))
ids = [raw[key] for key in ("issn", "eissn", "ISSN", "EISSN") if isinstance(raw.get(key), str)]
payload = merge(row["quartiles"], hse_entries(row["raw_data"], row["year"]))
return (args.all_systems or not entries(payload) or (payload.get("lookup") or {}).get("status") == "found_latest"
or bool(identity_issues(payload, row["doi_url"], names, ids)))
identifiers = sorted({_doi(row["doi_url"] or "") for row in rows
if needs_lookup(row)} - {""})
resolved = {}
if args.lookup_doi:
with ThreadPoolExecutor(max_workers=args.workers) as pool:
tasks = {pool.submit(resolve_doi, doi, args.cache_dir): doi for doi in identifiers}
for task in as_completed(tasks):
resolved[tasks[task]] = task.result()
if len(resolved) % 100 == 0 or len(resolved) == len(identifiers):
print(f"DOI checked: {len(resolved)}/{len(identifiers)}", flush=True)
rankings, ranking_failures = {}, []
if args.scopus_title_history:
by_issn, by_title = load_title_history(args.scopus_title_history)
resolved = {doi: with_title_history(metadata, by_issn, by_title, SOURCE_TITLES_URL)
for doi, metadata in resolved.items()}
if args.lookup_doi and args.sjr_history:
issns = sorted({value for metadata in resolved.values() if metadata.get("type") == "journal-article"
for value in metadata.get("ranking_issns", metadata.get("issns", []))})
years = {row["year"] for row in rows if needs_lookup(row) and row["year"] is not None}
if args.allow_latest:
years.add(2025)
rankings, ranking_failures = load_sjr(issns, years, args.cache_dir)
counts = Counter()
report = []
for row in rows:
doi = _doi(row["doi_url"] or "")
previous_lookup = (row["quartiles"].get("lookup") or {}) if row["quartiles"] else {}
metadata = resolved.get(doi, previous_lookup if doi and _doi(previous_lookup.get("doi") or "") == doi else {})
additions = hse_entries(row["raw_data"], row["year"])
payload = merge(row["quartiles"], additions)
if additions:
counts["hse_quartiles"] += 1
if args.lookup_doi and needs_lookup(row):
doi = _doi(row["doi_url"] or "")
metadata = resolved.get(doi, {"status": "invalid_doi" if row["doi_url"] else "missing_doi"})
status = metadata["status"]
if status == "resolved":
found, status = catalog_entries(metadata, row["year"], args.allow_latest)
if rankings:
sjr, sjr_status = sjr_entries(metadata, row["year"], rankings, args.allow_latest)
found.extend(sjr)
if not found and status == "historical_quartile_unavailable":
status = sjr_status
if found:
status = "found_latest" if any(item["metric_year"] != row["year"] for item in found) else "found"
payload = merge(payload, found)
payload["lookup"] = {**metadata, "status": status, "requested_year": row["year"],
"checked_on": date.today().isoformat()}
counts[status] += 1
counts["with_quartile" if entries(payload) else "without_quartile"] += 1
vak_payload = row.get("vak_categories")
if args.check_vak:
Review

[P2] Передайте в проверку ВАК ISSN исходной записи, а не только ответа DOI. Имена берутся из raw_data/csv_enrichment, но identifiers передаются исключительно из metadata. При запуске --check-vak без DOI-поиска или при отсутствии DOI исходные raw_data.issn/eissn игнорируются. Воспроизведение на реальном цикле обработки с изолированным индексом: raw_data={'journal':'First journal','issn':'1111-1111'}, а для First journal в справочнике известен ISSN 0006-3835. CLI сохраняет status='found' по названию; тот же assess_vak с исходным ISSN правильно возвращает journal_identity_conflict. Даже предварительное совпадение не должно обходить явно известное противоречие. Соберите доступные исходные ISSN/eISSN вместе с идентификаторами DOI и передайте их в проверку; добавьте тест CLI с конфликтующим исходным ISSN и сценарий идентификации только по raw_data.issn без DOI.

[P2] Передайте в проверку ВАК ISSN исходной записи, а не только ответа DOI. Имена берутся из raw_data/csv_enrichment, но identifiers передаются исключительно из metadata. При запуске --check-vak без DOI-поиска или при отсутствии DOI исходные raw_data.issn/eissn игнорируются. Воспроизведение на реальном цикле обработки с изолированным индексом: raw_data={'journal':'First journal','issn':'1111-1111'}, а для First journal в справочнике известен ISSN 0006-3835. CLI сохраняет status='found' по названию; тот же assess_vak с исходным ISSN правильно возвращает journal_identity_conflict. Даже предварительное совпадение не должно обходить явно известное противоречие. Соберите доступные исходные ISSN/eISSN вместе с идентификаторами DOI и передайте их в проверку; добавьте тест CLI с конфликтующим исходным ISSN и сценарий идентификации только по raw_data.issn без DOI.
raw = row["raw_data"] if isinstance(row["raw_data"], dict) else {}
imported = raw.get("csv_enrichment", {})
names = [*_journal_names(raw), *(_journal_names(imported) if isinstance(imported, dict) else []),
*(metadata.get("journals", []) if metadata.get("type") in {"journal-article", "journal"} else [])]
if row["publication_type"] == "ARTICLE" and metadata.get("type") in {None, "journal-article", "journal"}:
description = row["description"] if isinstance(row["description"], dict) else raw.get("description", {})
if isinstance(description, dict):
names.extend(_description_journals(description))
identifiers = list(metadata.get("issns", []))
for source in (raw, imported):
if isinstance(source, dict):
identifiers.extend(source[key] for key in ("issn", "eissn", "ISSN", "EISSN") if isinstance(source.get(key), str))
vak_payload = assess_vak(list(dict.fromkeys(names)), list(dict.fromkeys(identifiers)))
if row.get("vak_categories") is not None and row["vak_categories"] != vak_payload:
counts["vak_existing_value_kept"] += 1
vak_payload = row["vak_categories"]
counts["vak_" + vak_payload["status"]] += 1
if payload == row["quartiles"] and vak_payload == row.get("vak_categories"):
continue
if args.apply:
# Only this field is changed; parser-owned data and existing evidence survive.
with engine.begin() as connection:
current = dict(connection.execute(select(*columns).where(table.c.id == row["id"])
.with_for_update()).mappings().one())
if any(current[field] != row[field] for field in fields if field != "id"):
counts["concurrent_change_skipped"] += 1
continue
values = {"quartiles": payload, "updated_at": datetime.now(timezone.utc)}
if args.check_vak:
values["vak_categories"] = vak_payload
connection.execute(update(table).where(table.c.id == row["id"]).values(**values))
counts["changed"] += 1
report.append({"id": row["id"], "quartiles": payload, **({"vak_categories": vak_payload} if args.check_vak else {})})
summary = {"mode": "apply" if args.apply else "dry-run", "publications": len(rows),
"unique_doi_checked": len(resolved), "counts": dict(counts), "ranking_failures": ranking_failures}
pending.write_text(json.dumps({"summary": summary, "records": report}, ensure_ascii=False, indent=2), encoding="utf8")
pending.replace(args.report)
print(json.dumps(summary, ensure_ascii=False), flush=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
# Connection errors can contain credentials; do not print their text.
print(f"Enrichment failed ({type(error).__name__}); completed records are retained; retry is safe.", flush=True)
raise SystemExit(1)
+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.16.5"
assert response.json()["backend_version"] == "0.18.1"
def test_api_employees_and_stats_require_admin_session():
+3 -2
View File
@@ -67,7 +67,8 @@ def test_council_union_counts_provenance_and_types(db_session, export):
publication_id="100", url="https://publications.hse.ru/view/100")
publication(db_session, person, title=" shared WORK ", kind="ARTICLE")
publication(db_session, person, title="Book only", kind="BOOK", year=2020)
publication(db_session, person, title="Imported", raw_data={"csv_enrichment": {"Квартиль wos": "Q2"}})
publication(db_session, person, title="Imported", doi="https://doi.org/10.1234/imported",
raw_data={"csv_enrichment": {"Квартиль wos": "Q2", "DOI": "10.1234/imported"}})
db_session.commit()
result = council.council_candidates(db_session, date(2026, 9, 30))
assert result["total"] == 1
@@ -218,7 +219,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.16.5 · Frontend 0.16.5" in response.text
assert "Backend 0.18.1 · Frontend 0.18.1" in response.text
assert "Вход в админку" in response.text
assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403)
assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text
+299
View File
@@ -0,0 +1,299 @@
from copy import deepcopy
from datetime import date
import pytest
from sqlalchemy import create_engine, inspect, text, select
from sqlalchemy.orm import Session
from app.db import _ensure_runtime_schema, Base
from app.models import EmployeePublication, Employee
from app.services import publication_quartiles as quartiles
from app.services.crawler import _apply_publication
from scripts.enrich_publication_quartiles import resolve_doi, load_sjr, main
@pytest.mark.parametrize("scopus,wos", [("Q1", "Q2"), ("q3", "Q4"), ("Q4", "")])
def test_hse_backfill_keeps_systems_q1_to_q4_and_unknown_metric_year(scopus, wos):
raw = {"other": "preserve", "csv_enrichment": {"Квартиль scopus": scopus, "Квартиль wos": wos, "Год": "2024"}}
before = deepcopy(raw)
result = quartiles.hse_entries(raw, 2024)
assert raw == before
assert [e["quartile"] for e in result] == [v.upper() for v in (scopus, wos) if v]
assert all(e["metric_year"] is None and e["publication_year"] == 2024 for e in result)
assert [e["system"] for e in result] == (["Scopus", "Web of Science"] if wos else ["Scopus"])
@pytest.mark.parametrize("value", ["Q5", "1", "", None])
def test_invalid_hse_value_does_not_invent_a_quartile(value):
assert quartiles.hse_entries({"csv_enrichment": {"Квартиль scopus": value}}, 2024) == []
def test_merge_is_idempotent_and_preserves_conflicting_evidence():
first = quartiles.hse_entries({"csv_enrichment": {"Квартиль scopus": "Q1", "DOI": "10.1234/work"}}, 2024)[0]
payload = quartiles.merge(None, [first])
assert quartiles.merge(payload, [{**first, "checked_on": "2099-01-01"}]) == payload
assert len(quartiles.merge(payload, [{**first, "quartile": "Q2"}])["entries"]) == 2
assert quartiles.labels(payload, 2024, doi="10.1234/work") == ["Scopus Q1"]
assert quartiles.labels(payload, 2023) == []
def test_catalog_metric_requires_exact_year_unless_latest_explicitly_allowed(monkeypatch):
monkeypatch.setattr(quartiles, "publication_catalog", lambda ids, names: ([{
"name": "Journal", "metrics": [{"year": 2025, "system": "Scopus", "quartile": "Q1", "metric": "CiteScore",
"category": "Mathematics", "source": "Snapshot", "source_url": "https://example.org/metrics"}]}], []))
metadata = {"type": "journal-article", "years": [2024], "issns": ["0006-3835"]}
assert quartiles.catalog_entries(metadata, 2024)[0] == []
found, status = quartiles.catalog_entries(metadata, 2024, True)
assert status == "found_latest" and found[0]["metric_year"] == 2025 and found[0]["publication_year"] == 2024
assert quartiles.labels(quartiles.merge(None, found), 2024) == []
assert quartiles.catalog_entries(metadata, 2023, True)[1] == "publication_year_conflict"
assert quartiles.catalog_entries({**metadata, "type": "book-chapter"}, 2024, True)[1] == "not_journal_article"
def sjr_record(year, categories="Analysis (Q1); Applied Mathematics (Q2)"):
return {"journal": "Journal", "issns": ["0006-3835"], "quartile": "Q1", "categories": categories,
"source_url": f"https://opendata.unex.es/recurso/ciencia-tecnologia/investigacion/revistas/Revista/{year}-1"}
def test_sjr_keeps_historical_year_categories_and_system_distinct():
metadata = {"type": "journal-article", "years": [2020], "issns": ["0006-3835"]}
found, status = quartiles.sjr_entries(metadata, 2020, quartiles.index_sjr({2020: [sjr_record(2020)], 2025: [sjr_record(2025)]}), True)
assert status == "found" and [e["quartile"] for e in found] == ["Q1", "Q2"]
assert all(e["system"] == "SCImago" and e["metric_year"] == 2020 for e in found)
assert quartiles.labels(quartiles.merge(None, found), 2020) == []
latest, status = quartiles.sjr_entries(metadata, 2020, quartiles.index_sjr({2025: [sjr_record(2025)]}), True)
assert status == "found_latest" and latest[0]["metric_year"] == 2025
assert quartiles.sjr_entries(metadata, 2020, quartiles.index_sjr({2025: [sjr_record(2025)]}))[0] == []
def test_sjr_identity_and_category_conflicts_do_not_resolve_by_order():
metadata = {"type": "journal-article", "years": [2020], "issns": ["0006-3835"]}
first = sjr_record(2020)
second = {**first, "source_url": first["source_url"] + "2"}
assert quartiles.sjr_entries(metadata, 2020, quartiles.index_sjr({2020: [first, second]}))[1] == "journal_identity_conflict"
assert quartiles.sjr_entries(metadata, 2020, quartiles.index_sjr({2020: [sjr_record(2020, "Analysis (Q3)")]} ))[1] == "ranking_category_conflict"
def test_schema_addition_is_repeatable_and_preserves_existing_data(monkeypatch):
engine = create_engine("sqlite://")
with engine.begin() as connection:
connection.execute(text("CREATE TABLE employee_publications (id INTEGER PRIMARY KEY, title TEXT NOT NULL)"))
connection.execute(text("INSERT INTO employee_publications (id,title) VALUES (1,'Keep')"))
monkeypatch.setattr("app.db.engine", engine)
_ensure_runtime_schema()
_ensure_runtime_schema()
assert "quartiles" in {c["name"] for c in inspect(engine).get_columns("employee_publications")}
with engine.connect() as connection:
assert connection.execute(text("SELECT title,quartiles FROM employee_publications")).one() == ("Keep", None)
def test_crawler_preserves_quartiles_while_replacing_source_owned_raw_data():
payload = quartiles.merge(None, quartiles.hse_entries({"csv_enrichment": {"Квартиль scopus": "Q4"}}, 2024))
publication = EmployeePublication(title="Old", source_hash="old", quartiles=deepcopy(payload))
publication.vak_categories = {"schema_version": 1, "entries": [{"category": "К1"}], "status": "found"}
_apply_publication(publication, {"id": "42", "title": "Updated", "year": 2024, "raw_data": {"title": "Updated"}}, "new")
assert publication.quartiles == payload
assert publication.vak_categories["entries"] == [{"category": "К1"}]
assert publication.title == "Updated" and publication.source_hash == "new"
def test_council_reads_new_field_without_raw_enrichment_and_keeps_historical_filter():
from app.services.council import _database_article
values = quartiles.hse_entries({"csv_enrichment": {"Квартиль scopus": "Q2", "DOI": "10.1234/work"}}, 2024)
publication = EmployeePublication(title="Work", year=2024, publication_type="ARTICLE", source_hash="hash",
quartiles=quartiles.merge(None, values), raw_data={}, doi_url="https://doi.org/10.1234/work")
assert _database_article(publication)["quartiles"] == ["Scopus Q2"]
publication.quartiles = quartiles.merge(None, [{**values[0], "metric_year": 2025}])
assert _database_article(publication)["quartiles"] == []
def test_doi_cache_uses_verified_identity_and_does_not_need_network_on_repeat(monkeypatch, tmp_path):
calls = []
class Response:
status_code = 200
def raise_for_status(self):
pass
def json(self):
return {"message": {"DOI": "10.1234/work", "type": "journal-article", "container-title": ["Journal"],
"ISSN": ["0006-3835"], "published": {"date-parts": [[2024, 2, 1]]}}}
def get(url, **kwargs):
calls.append(url)
assert kwargs["allow_redirects"] is False
return Response()
monkeypatch.setattr("scripts.enrich_publication_quartiles.requests.get", get)
result = resolve_doi("10.1234/work", tmp_path)
assert result["status"] == "resolved" and result["years"] == [2024]
assert resolve_doi("10.1234/work", tmp_path) == result and len(calls) == 1
assert calls[0] == "https://api.crossref.org/works/10.1234%2Fwork"
def test_sjr_source_combines_multiple_categories_of_the_same_resource(monkeypatch, tmp_path):
url = "https://opendata.unex.es/recurso/ciencia-tecnologia/investigacion/revistas/Revista/2020-1"
def binding(category, quartile="Q1"):
return {key: {"value": value} for key, value in {
"uri": url, "title": "Journal", "issn": "0006-3835", "quartile": quartile, "categories": category}.items()}
class Response:
def raise_for_status(self):
pass
def json(self):
return {"results": {"bindings": [binding("Analysis (Q1)"), binding("Applied Mathematics (Q2)")]}}
monkeypatch.setattr("scripts.enrich_publication_quartiles.requests.get", lambda *args, **kwargs: Response())
rankings, errors = load_sjr(["0006-3835"], [2020], tmp_path)
assert not errors
metadata = {"type": "journal-article", "years": [2020], "issns": ["0006-3835"]}
found, status = quartiles.sjr_entries(metadata, 2020, rankings)
assert status == "found" and {e["quartile"] for e in found} == {"Q1", "Q2"}
def test_doi_identity_conflict_does_not_supply_journal_metrics(monkeypatch, tmp_path):
class Response:
status_code = 200
def raise_for_status(self):
pass
def json(self):
return {"message": {"DOI": "10.1234/another", "type": "journal-article", "ISSN": ["0006-3835"]}}
monkeypatch.setattr("scripts.enrich_publication_quartiles.requests.get", lambda *args, **kwargs: Response())
result = resolve_doi("10.1234/work", tmp_path)
assert result["status"] == "doi_identity_conflict" and "issns" not in result
assert not list(tmp_path.glob("*.json"))
def test_enrichment_cli_dry_run_apply_and_repeat_preserve_original_data(monkeypatch, tmp_path):
url = "sqlite:///" + str(tmp_path / "publications.db")
engine = create_engine(url)
Base.metadata.create_all(engine)
raw = {"original": "keep", "csv_enrichment": {"Квартиль scopus": "Q3", "Год": "2024"}}
with Session(engine) as session:
employee = Employee(profile_key="test", canonical_url="https://example.org/person")
session.add(employee)
session.flush()
session.add(EmployeePublication(employee_id=employee.id, title="Work", year=2024, source_hash="hash", raw_data=raw))
session.commit()
monkeypatch.setenv("DATABASE_URL", url)
arguments = ["enrich", "--cache-dir", str(tmp_path / "cache"), "--report", str(tmp_path / "report.json")]
monkeypatch.setattr("sys.argv", arguments)
main()
with Session(engine) as session:
assert session.scalar(select(EmployeePublication)).quartiles is None
monkeypatch.setattr("sys.argv", arguments + ["--apply"])
main()
with Session(engine) as session:
publication = session.scalar(select(EmployeePublication))
payload = deepcopy(publication.quartiles)
assert publication.raw_data == raw and payload["entries"][0]["quartile"] == "Q3"
main()
with Session(engine) as session:
publication = session.scalar(select(EmployeePublication))
assert publication.quartiles == payload and publication.raw_data == raw
def test_issn_succession_requires_explicit_relation_and_unique_doi_journal_title():
old = {"history": "Continued as", "related": ["Physical Review B"]}
new = {"issns": {"2469-9950", "2469-9969"}}
metadata = {"issns": ["1098-0121", "1550-235X"], "journals": ["Physical Review B"]}
result = quartiles.with_title_history(metadata, {"10980121": [old]}, {"physical review b": [new]}, "https://example.org/source-titles")
assert result["issns"] == metadata["issns"] and "2469-9950" in result["ranking_issns"]
assert metadata == {"issns": ["1098-0121", "1550-235X"], "journals": ["Physical Review B"]}
assert quartiles.with_title_history(metadata, {"10980121": [old]}, {"physical review b": [new, new]}, "url") == metadata
assert quartiles.with_title_history({**metadata, "journals": ["Other journal"]}, {"10980121": [old]}, {"physical review b": [new]}, "url") == {**metadata, "journals": ["Other journal"]}
def test_crossref_not_found_falls_back_to_datacite_journal_metadata(monkeypatch, tmp_path):
class Response:
def __init__(self, status):
self.status_code = status
def raise_for_status(self):
pass
def json(self):
return {"data": {"attributes": {"doi": "10.1234/work", "publicationYear": 2024,
"types": {"resourceTypeGeneral": "JournalArticle"},
"container": {"title": "Journal", "identifierType": "ISSN", "identifier": "0006-3835"}}}}
calls = []
def get(url, **kwargs):
calls.append(url)
return Response(404 if "crossref.org" in url else 200)
monkeypatch.setattr("scripts.enrich_publication_quartiles.requests.get", get)
result = resolve_doi("10.1234/work", tmp_path)
assert result["status"] == "resolved" and result["type"] == "journal-article"
assert result["issns"] == ["0006-3835"] and result["years"] == [2024]
assert result["datacite_checked"] and len(calls) == 2
@pytest.mark.parametrize("new_doi,new_journal", [("10.1234/new", "Different journal"), ("10.1234/old", "Different journal")])
def test_crawler_doi_or_journal_change_excludes_preserved_quartile(new_doi, new_journal):
from app.services.council import _database_article
evidence = {"system": "Scopus", "quartile": "Q1", "publication_year": 2024, "metric_year": 2024,
"journal": "Old journal", "issns": [], "source": "Checked"}
payload = {"schema_version": 1, "entries": [evidence], "lookup": {"doi": "10.1234/old"}}
publication = EmployeePublication(title="Work", year=2024, source_hash="old", publication_type="ARTICLE",
doi_url="https://doi.org/10.1234/old", quartiles=deepcopy(payload), raw_data={"journal": "Old journal"})
assert _database_article(publication)["quartiles"] == ["Scopus Q1"]
_apply_publication(publication, {"id": "42", "title": "Work", "year": 2024, "type": "ARTICLE",
"doi_url": "https://doi.org/" + new_doi, "raw_data": {"journal": new_journal}}, "new")
article = _database_article(publication)
assert publication.quartiles == payload
assert article["quartiles"] == [] and article["quartile_issues"]
assert all("перепроверка" in issue for issue in article["quartile_issues"])
def test_updating_lookup_freezes_legacy_binding_instead_of_rebinding_old_entries():
old = {"system": "Scopus", "quartile": "Q1", "publication_year": 2024, "metric_year": 2024,
"journal": "Old journal", "issns": [], "source": "Checked"}
payload = {"schema_version": 1, "entries": [old], "lookup": {"doi": "10.1234/old"}}
updated = quartiles.merge(payload, [], lookup={"doi": "10.1234/new"})
assert updated["entries"][0]["doi"] == "10.1234/old"
assert quartiles.labels(updated, 2024, "10.1234/new", ["Old journal"]) == []
fresh = {**old, "doi": "10.1234/new", "quartile": "Q2"}
refreshed = quartiles.merge(updated, [fresh], lookup={"doi": "10.1234/new"})
assert quartiles.labels(refreshed, 2024, "10.1234/new", ["Old journal"]) == ["Scopus Q2"]
assert len(refreshed["entries"]) == 2
def test_unbound_hse_fallback_does_not_bypass_quartile_identity_checks():
from app.services.council import _database_article
publication = EmployeePublication(title="Work", year=2024, source_hash="hash", publication_type="ARTICLE",
doi_url="10.1234/new", raw_data={"journal": "New journal", "csv_enrichment": {
"DOI": "10.1234/old", "Название журнала": "Old journal", "Квартиль scopus": "Q1"}})
article = _database_article(publication)
assert article["quartiles"] == [] and article["quartile_issues"]
assert "Old journal" not in article["journals"]
def test_journal_issn_allows_explicit_rename_but_conflicting_issn_is_rejected():
value = {"system": "Scopus", "quartile": "Q1", "publication_year": 2024, "metric_year": 2024,
"doi": "10.1234/work", "journal": "Old title", "issns": ["0006-3835"]}
payload = quartiles.merge(None, [value])
assert quartiles.labels(payload, 2024, "10.1234/work", ["Renamed title"], ["00063835"]) == ["Scopus Q1"]
assert quartiles.labels(payload, 2024, "10.1234/work", ["Old title"], ["11111111"]) == []
def test_default_enrichment_rechecks_filled_evidence_after_doi_change(monkeypatch, tmp_path):
url = "sqlite:///" + str(tmp_path / "stale.db")
engine = create_engine(url)
Base.metadata.create_all(engine)
old = {"system": "Scopus", "quartile": "Q1", "publication_year": 2024, "metric_year": 2024,
"journal": "Old journal", "issns": [], "source": "Checked"}
payload = {"schema_version": 1, "entries": [old], "lookup": {"doi": "10.1234/old", "status": "found"}}
with Session(engine) as session:
person = Employee(profile_key="test", canonical_url="https://example.org/person")
session.add(person)
session.flush()
session.add(EmployeePublication(employee_id=person.id, title="Work", year=2024, source_hash="hash",
doi_url="10.1234/new", raw_data={"journal": "New journal"}, quartiles=payload))
session.commit()
calls = []
def resolve(doi, cache):
calls.append(doi)
return {"status": "resolved", "doi": doi, "type": "journal-article", "journals": ["New journal"]}
monkeypatch.setattr("scripts.enrich_publication_quartiles.resolve_doi", resolve)
monkeypatch.setattr("scripts.enrich_publication_quartiles.catalog_entries", lambda metadata, year, latest: ([
{**old, "doi": metadata["doi"], "journal": "New journal", "quartile": "Q2"}], "found"))
monkeypatch.setenv("DATABASE_URL", url)
monkeypatch.setattr("sys.argv", ["enrich", "--apply", "--lookup-doi", "--cache-dir", str(tmp_path / "cache"),
"--report", str(tmp_path / "report.json")])
main()
with Session(engine) as session:
stored = session.scalar(select(EmployeePublication)).quartiles
assert calls == ["10.1234/new"] and len(stored["entries"]) == 2
assert stored["entries"][0]["doi"] == "10.1234/old"
assert quartiles.labels(stored, 2024, "10.1234/new", ["New journal"]) == ["Scopus Q2"]
+112
View File
@@ -0,0 +1,112 @@
import pytest
from app.services import publication_vak as vak
from app.services.publication_quartiles import catalog_entries, sjr_entries, index_sjr
@pytest.fixture()
def index(monkeypatch):
k1 = {"category": "К1", "journal": "Первый журнал", "source_row": 1, "issns": ["00063835"]}
k2 = {"category": "К2", "journal": "Второй журнал", "source_row": 2, "issns": []}
monkeypatch.setattr(vak, "category_index", lambda: ({"первый журнал": [k1], "второй журнал": [k2]}, {"00063835": [k1]}))
return k1, k2
def test_vak_identifies_k1_by_issn_without_guessing_translated_names(index):
result = vak.assess_vak(["English title"], ["0006-3835"])
assert result["status"] == "found" and result["entries"][0]["category"] == "К1"
assert result["entries"][0]["identity_confirmed"] and not result["historical_category_confirmed"]
def test_k2_name_match_is_explicitly_preliminary(index):
result = vak.assess_vak([" Второй журнал. "], [])
assert result["entries"][0]["category"] == "К2"
assert not result["entries"][0]["identity_confirmed"]
assert result["entries"][0]["match_method"] == "normalized_name"
def test_vak_conflicting_identifiers_and_multiple_journals_are_not_unioned(index):
assert vak.assess_vak(["Первый журнал"], ["1111-1111"])["status"] == "journal_identity_conflict"
assert vak.assess_vak(["Первый журнал", "Второй журнал"], [])["status"] == "ambiguous_journal"
assert vak.assess_vak([], [])["status"] == "unknown_journal"
assert vak.assess_vak(["Другой журнал"], [])["status"] == "not_found_in_k1_k2"
def test_committed_category_snapshots_match_known_journal_and_record_sources():
vak.category_index.cache_clear()
result = vak.assess_vak(["Бизнес-информатика"], [])
assert result["status"] == "found" and result["entries"][0]["category"] == "К1"
assert result["entries"][0]["source_sha256"] and result["entries"][0]["categorized_on"]
def test_all_catalog_systems_keep_their_own_metric_year_and_multiple_categories(monkeypatch):
metrics = [{"year": year, "system": system, "quartile": quarter, "metric": metric,
"category": category, "source": "Snapshot", "source_url": "https://example.org/source"}
for year, system, quarter, metric, category in [(2024, "Scopus", "Q1", "CiteScore", "Analysis"),
(2024, "Scopus", "Q2", "CiteScore", "Applied Mathematics"), (2025, "Web of Science", "Q3", "JCR", "Mathematics")]]
monkeypatch.setattr("app.services.publication_quartiles.publication_catalog", lambda ids, names: ([{"name": "Journal", "metrics": metrics}], []))
metadata = {"type": "journal-article", "years": [2024], "issns": ["0006-3835"]}
result, status = catalog_entries(metadata, 2024, True)
assert status == "found_latest" and len(result) == 3
assert {(r["system"], r["quartile"], r["metric_year"]) for r in result} == {
("Scopus", "Q1", 2024), ("Scopus", "Q2", 2024), ("Web of Science", "Q3", 2025)}
def test_book_container_title_is_not_treated_as_a_vak_journal(monkeypatch, tmp_path):
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from app.db import Base
from app.models import Employee, EmployeePublication
from scripts.enrich_publication_quartiles import main
url = "sqlite:///" + str(tmp_path / "books.db")
engine = create_engine(url)
Base.metadata.create_all(engine)
with Session(engine) as session:
employee = Employee(profile_key="test", canonical_url="https://example.org/person")
session.add(employee)
session.flush()
session.add(EmployeePublication(employee_id=employee.id, title="Chapter", year=2024, publication_type="CHAPTER",
source_hash="hash", doi_url="10.1234/chapter", raw_data={}))
session.commit()
monkeypatch.setenv("DATABASE_URL", url)
monkeypatch.setattr("scripts.enrich_publication_quartiles.resolve_doi", lambda *args: {
"status": "resolved", "type": "book-chapter", "journals": ["Бизнес-информатика"], "issns": [], "years": [2024]})
monkeypatch.setattr("sys.argv", ["enrich", "--apply", "--lookup-doi", "--check-vak", "--all-systems",
"--cache-dir", str(tmp_path / "cache"), "--report", str(tmp_path / "report.json")])
main()
with Session(engine) as session:
publication = session.scalar(select(EmployeePublication))
assert publication.vak_categories["status"] == "unknown_journal"
assert publication.vak_categories["entries"] == [] and publication.quartiles["entries"] == []
@pytest.mark.parametrize("raw,expected", [
({"journal": "Первый журнал", "issn": "1111-1111"}, "journal_identity_conflict"),
({"issn": "0006-3835"}, "found"),
({"eissn": "0006-3835"}, "found"),
({"csv_enrichment": {"ISSN": "0006-3835"}}, "found"),
])
def test_cli_checks_original_issn_even_without_doi(index, monkeypatch, tmp_path, raw, expected):
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from app.db import Base
from app.models import Employee, EmployeePublication
from scripts.enrich_publication_quartiles import main
url = "sqlite:///" + str(tmp_path / "vak.db")
engine = create_engine(url)
Base.metadata.create_all(engine)
with Session(engine) as session:
person = Employee(profile_key="test", canonical_url="https://example.org/person")
session.add(person)
session.flush()
session.add(EmployeePublication(employee_id=person.id, title="Work", year=2024, source_hash="hash", raw_data=raw))
session.commit()
monkeypatch.setenv("DATABASE_URL", url)
monkeypatch.setattr("sys.argv", ["enrich", "--apply", "--check-vak", "--cache-dir", str(tmp_path / "cache"),
"--report", str(tmp_path / "report.json")])
main()
with Session(engine) as session:
publication = session.scalar(select(EmployeePublication))
assert publication.vak_categories["status"] == expected
if expected == "found":
assert publication.vak_categories["entries"][0]["identity_confirmed"]