Квартили и категории ВАК публикаций: отдельные поля и обогащение #66
No files matched your search
@@ -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-выпуском: резервная копия, предварительная сверка, сохранение заполненных значений, отдельная обработка конфликтов и проверка полноты до обновления приложения.
|
||||
|
||||
@@ -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`: публикации представлены отдельными
|
||||
|
||||
@@ -37,6 +37,14 @@ def _ensure_runtime_schema() -> None:
|
||||
models.EmployeePublication.__table__.create(bind=engine, checkfirst=True)
|
||||
inspector = inspect(engine)
|
||||
table_names = set(inspector.get_table_names())
|
||||
if "employee_publications" in table_names:
|
||||
publication_columns = {column["name"] for column in inspector.get_columns("employee_publications")}
|
||||
for column in ("quartiles", "vak_categories"):
|
||||
if column not in publication_columns:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("ALTER TABLE employee_publications ADD COLUMN " +
|
||||
("IF NOT EXISTS " if engine.dialect.name == "postgresql" else "") +
|
||||
column + " " + ("JSONB" if engine.dialect.name == "postgresql" else "JSON")))
|
||||
if "employees" in table_names and "employee_news_links" not in table_names:
|
||||
models.EmployeeNewsLink.__table__.create(bind=engine, checkfirst=True)
|
||||
inspector = inspect(engine)
|
||||
|
||||
@@ -108,6 +108,8 @@ class EmployeePublication(Base):
|
||||
other_url: Mapped[str | None] = mapped_column(Text)
|
||||
document_url: Mapped[str | None] = mapped_column(Text)
|
||||
citation_text: Mapped[str | None] = mapped_column(Text)
|
||||
quartiles: Mapped[dict | None] = mapped_column(json_type)
|
||||
vak_categories: Mapped[dict | None] = mapped_column(json_type)
|
||||
annotation: Mapped[dict | None] = mapped_column(json_type)
|
||||
description: Mapped[dict | None] = mapped_column(json_type)
|
||||
authors: Mapped[list | None] = mapped_column(json_type)
|
||||
|
||||
@@ -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)
|
||||
|
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Publication quartiles with source, system and year kept separately."""
|
||||
|
||||
from datetime import date
|
||||
from collections import defaultdict
|
||||
import re
|
||||
|
||||
from app.services.council_catalog import publication_catalog, safe_url, issn
|
||||
from app.services.council_source import _name, _doi
|
||||
|
||||
|
||||
SYSTEM_FIELDS = {"Scopus": "Квартиль scopus", "Web of Science": "Квартиль wos"}
|
||||
QUARTILES = {"Q1", "Q2", "Q3", "Q4"}
|
||||
|
||||
|
||||
def entries(payload):
|
||||
if payload is None:
|
||||
return []
|
||||
if (not isinstance(payload, dict) or type(payload.get("schema_version")) is not int or payload["schema_version"] != 1
|
||||
or not isinstance(payload.get("entries"), list)
|
||||
or (payload.get("lookup") is not None and not isinstance(payload["lookup"], dict))
|
||||
or (isinstance(payload.get("lookup"), dict) and payload["lookup"].get("doi") is not None
|
||||
and not isinstance(payload["lookup"]["doi"], str))
|
||||
or not all(isinstance(item, dict) and item.get("quartile") in QUARTILES
|
||||
and item.get("system") in {*SYSTEM_FIELDS, "SCImago"}
|
||||
and (item.get("doi") is None or isinstance(item["doi"], str))
|
||||
and (item.get("journal") is None or isinstance(item["journal"], str))
|
||||
and isinstance(item.get("issns", []), list)
|
||||
and all(isinstance(value, str) for value in item.get("issns", []))
|
||||
for item in payload["entries"])):
|
||||
raise ValueError("Invalid publication quartiles")
|
||||
return payload["entries"]
|
||||
|
||||
|
||||
def hse_entries(raw, publication_year):
|
||||
metadata = raw.get("csv_enrichment", {}) if isinstance(raw, dict) else {}
|
||||
if not isinstance(metadata, dict):
|
||||
return []
|
||||
source_year = str(metadata.get("Год") or "").strip()
|
||||
applicable_year = int(source_year) if source_year.isdigit() else publication_year
|
||||
output = []
|
||||
for system, field in SYSTEM_FIELDS.items():
|
||||
quartile = str(metadata.get(field) or "").strip().upper()
|
||||
if quartile in QUARTILES:
|
||||
output.append({"system": system, "quartile": quartile, "metric": None,
|
||||
"doi": _doi(str(metadata.get("DOI") or "")) or None,
|
||||
"metric_year": None, "publication_year": applicable_year,
|
||||
"category": None, "journal": metadata.get("Название журнала") or None,
|
||||
"issns": [], "source": "Данные ВШЭ", "source_url": None,
|
||||
"checked_on": date.today().isoformat()})
|
||||
return output
|
||||
|
||||
|
||||
def catalog_entries(doi_metadata, publication_year, allow_latest=False):
|
||||
"""Only a journal identified by DOI/ISSN and the exact requested metric year."""
|
||||
if doi_metadata.get("type") != "journal-article":
|
||||
return [], "not_journal_article"
|
||||
if publication_year is None:
|
||||
return [], "missing_publication_year"
|
||||
if publication_year not in doi_metadata.get("years", []):
|
||||
return [], "publication_year_conflict"
|
||||
if not doi_metadata.get("issns"):
|
||||
return [], "missing_journal_issn"
|
||||
records, issues = publication_catalog(doi_metadata["issns"], [])
|
||||
if issues:
|
||||
return [], "journal_identity_conflict"
|
||||
metrics = [(record, metric) for record in records for metric in record["metrics"]]
|
||||
selected_years = {}
|
||||
for system in SYSTEM_FIELDS:
|
||||
years = {metric["year"] for _, metric in metrics if metric["system"] == system}
|
||||
selected_years[system] = publication_year if publication_year in years else max(years, default=None) if allow_latest else None
|
||||
output = []
|
||||
for record in records:
|
||||
for metric in record["metrics"]:
|
||||
if metric["year"] == selected_years.get(metric["system"]) and metric["system"] in SYSTEM_FIELDS:
|
||||
output.append({"system": metric["system"], "quartile": metric["quartile"],
|
||||
"doi": doi_metadata.get("doi"),
|
||||
"metric": metric["metric"], "metric_year": metric["year"],
|
||||
"publication_year": publication_year, "category": metric["category"],
|
||||
"journal": record["name"], "issns": doi_metadata["issns"],
|
||||
"source": metric["source"], "source_url": safe_url(metric["source_url"]),
|
||||
"checked_on": date.today().isoformat()})
|
||||
return output, ("found_latest" if any(item["metric_year"] != publication_year for item in output) else "found") if output else "historical_quartile_unavailable"
|
||||
|
||||
|
||||
def merge(existing, additions, lookup=None):
|
||||
current = entries(existing)
|
||||
previous_doi = _doi(((existing or {}).get("lookup") or {}).get("doi") or "") or None
|
||||
# Freeze the previous lookup's identity on legacy evidence before replacing it.
|
||||
output = [{**item, "doi": previous_doi} if "doi" not in item else dict(item) for item in current]
|
||||
# Repeating a lookup must not duplicate evidence merely because its date changed.
|
||||
def key(item):
|
||||
return tuple(item.get(field) for field in ("system", "quartile", "metric", "metric_year",
|
||||
"publication_year", "category", "journal", "source", "source_url", "doi"))
|
||||
seen = {key(item) for item in output}
|
||||
for item in additions:
|
||||
if key(item) not in seen:
|
||||
output.append(item)
|
||||
seen.add(key(item))
|
||||
result = {**(existing or {}), "schema_version": 1, "entries": output}
|
||||
if lookup is not None:
|
||||
result["lookup"] = lookup
|
||||
return result
|
||||
|
||||
|
||||
def binding_issue(item, payload, doi="", journals=(), identifiers=()):
|
||||
value = item.get("doi") if "doi" in item else ((payload or {}).get("lookup") or {}).get("doi")
|
||||
source_doi = _doi(value or "")
|
||||
if value and not source_doi:
|
||||
return "DOI сохранённого квартиля некорректен — требуется перепроверка"
|
||||
current_doi = _doi(doi or "")
|
||||
if source_doi and source_doi != current_doi:
|
||||
return "Сохранённый квартиль относится к другому DOI — требуется перепроверка"
|
||||
source_ids = {issn(value) for value in item.get("issns", [])} - {""}
|
||||
current_ids = {issn(value) for value in identifiers} - {""}
|
||||
ids_match = bool(source_ids & current_ids)
|
||||
if source_ids and current_ids and not ids_match:
|
||||
return "ISSN сохранённого квартиля и публикации расходятся — требуется перепроверка"
|
||||
source_name = _name(item.get("journal") or "")
|
||||
names_match = bool(source_name and source_name in {_name(name) for name in journals if name})
|
||||
if source_name and journals and not names_match and not ids_match:
|
||||
return "Журнал сохранённого квартиля и публикации расходятся — требуется перепроверка"
|
||||
if not (source_doi and source_doi == current_doi) and not ids_match and not names_match:
|
||||
return "Привязка сохранённого квартиля к публикации не подтверждена — требуется перепроверка"
|
||||
return None
|
||||
|
||||
|
||||
def identity_issues(payload, doi="", journals=(), identifiers=()):
|
||||
return list(dict.fromkeys(issue for item in entries(payload)
|
||||
if (issue := binding_issue(item, payload, doi, journals, identifiers))))
|
||||
|
||||
|
||||
def labels(payload, publication_year, doi="", journals=(), identifiers=()):
|
||||
return list(dict.fromkeys(f"{item['system']} {item['quartile']}" for item in entries(payload)
|
||||
if item["quartile"] in {"Q1", "Q2"}
|
||||
and item["system"] in SYSTEM_FIELDS
|
||||
and item.get("publication_year") == publication_year
|
||||
and item.get("metric_year") in {None, publication_year}
|
||||
and not binding_issue(item, payload, doi, journals, identifiers)))
|
||||
|
||||
|
||||
def index_sjr(rankings):
|
||||
from app.services.council_catalog import issn
|
||||
indexed = {}
|
||||
for year, records in rankings.items():
|
||||
by_issn = defaultdict(list)
|
||||
for record in records:
|
||||
for identifier in set(map(issn, record["issns"])) - {""}:
|
||||
by_issn[identifier].append(record)
|
||||
indexed[int(year)] = dict(by_issn)
|
||||
return indexed
|
||||
|
||||
|
||||
def with_title_history(metadata, by_issn, by_title, source_url):
|
||||
"""Use explicit Scopus title succession, never a fuzzy journal-name match."""
|
||||
from app.services.council_catalog import issn
|
||||
if not safe_url(source_url):
|
||||
return metadata
|
||||
extra = set()
|
||||
names = {_name(name) for name in metadata.get("journals", [])}
|
||||
for identifier in metadata.get("issns", []):
|
||||
for old in by_issn.get(issn(identifier), []):
|
||||
if old["history"] not in {"Continued as", "Continues"}:
|
||||
continue
|
||||
for related in old["related"]:
|
||||
candidates = by_title.get(_name(related), [])
|
||||
if _name(related) in names and len(candidates) == 1:
|
||||
extra.update(candidates[0]["issns"])
|
||||
if not extra:
|
||||
return metadata
|
||||
return {**metadata, "ranking_issns": sorted(set(metadata["issns"]) | extra),
|
||||
"journal_identity_source_url": source_url}
|
||||
|
||||
|
||||
def sjr_entries(metadata, publication_year, rankings, allow_latest=False):
|
||||
from app.services.council_catalog import issn
|
||||
if metadata.get("type") != "journal-article":
|
||||
return [], "not_journal_article"
|
||||
if publication_year is None or publication_year not in metadata.get("years", []):
|
||||
return [], "publication_year_conflict"
|
||||
matches = {}
|
||||
identifiers = set(map(issn, metadata.get("ranking_issns", metadata.get("issns", [])))) - {""}
|
||||
for year, by_issn in rankings.items():
|
||||
journals = {record["source_url"]: record for identifier in identifiers for record in by_issn.get(identifier, [])}
|
||||
if journals:
|
||||
matches[int(year)] = journals
|
||||
year = publication_year if publication_year in matches else max(matches, default=None) if allow_latest else None
|
||||
if year is None:
|
||||
return [], "historical_quartile_unavailable"
|
||||
if len(matches[year]) != 1:
|
||||
return [], "journal_identity_conflict"
|
||||
record = next(iter(matches[year].values()))
|
||||
if record.get("conflict"):
|
||||
return [], "ranking_category_conflict"
|
||||
categories = []
|
||||
for category in record.get("categories", "").split(";"):
|
||||
if not category.strip():
|
||||
continue
|
||||
match = re.fullmatch(r"\s*(.+?)\s*\((Q[1-4])\)\s*", category)
|
||||
if match:
|
||||
categories.append((match[1].strip(), match[2]))
|
||||
else:
|
||||
return [], "ranking_category_conflict"
|
||||
if categories and min(quartile for _, quartile in categories) != record["quartile"]:
|
||||
return [], "ranking_category_conflict"
|
||||
if not categories and record.get("quartile") in QUARTILES:
|
||||
categories = [(None, record["quartile"])]
|
||||
result = [{"system": "SCImago", "quartile": quartile, "metric": "SJR" if category else "SJR best quartile",
|
||||
"doi": metadata.get("doi"),
|
||||
"metric_year": year, "publication_year": publication_year, "category": category,
|
||||
"journal": record["journal"], "issns": record["issns"],
|
||||
"source": "SCImago Journal & Country Rank · Universidad de Extremadura",
|
||||
"source_url": record["source_url"], "checked_on": date.today().isoformat()}
|
||||
for category, quartile in categories]
|
||||
if metadata.get("journal_identity_source_url"):
|
||||
for item in result:
|
||||
item["journal_identity_source_url"] = metadata["journal_identity_source_url"]
|
||||
return result, "found" if year == publication_year else "found_latest"
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Match a publication's journal to the supplied K1/K2 category snapshots."""
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from functools import lru_cache
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from app.services.council_catalog import issn
|
||||
from app.services.council_source import _name
|
||||
|
||||
DATA = Path(__file__).resolve().parents[1] / "data"
|
||||
|
||||
|
||||
def journal_key(value):
|
||||
return " ".join(re.findall(r"\w+", _name(value)))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def category_index():
|
||||
files = {category: DATA / filename for category, filename in
|
||||
(("К1", "k1_journals.json"), ("К2", "k2_journals.json"))}
|
||||
documents = {category: json.loads(path.read_text(encoding="utf8")) for category, path in files.items()}
|
||||
if documents["К1"]["source_sha256"] != documents["К2"]["source_sha256"]:
|
||||
raise ValueError("VAK category source mismatch")
|
||||
identities = json.loads((DATA / "k1_specialties.json").read_text(encoding="utf8"))
|
||||
if identities["k1_source_sha256"] != hashlib.sha256(files["К1"].read_bytes().replace(b"\r\n", b"\n")).hexdigest():
|
||||
raise ValueError("VAK journal identity source mismatch")
|
||||
known = {item["name"]: sorted({issn(entry["issn"]) for entry in item["vak_entries"]})
|
||||
for item in identities["journals"] if item.get("status") == "matched"}
|
||||
by_name, by_issn = defaultdict(list), defaultdict(list)
|
||||
for category, document in documents.items():
|
||||
for journal in document["journals"]:
|
||||
record = {"category": category, "journal": journal["name"], "source": document["source"],
|
||||
"source_sha256": document["source_sha256"], "source_row": journal["source_row"],
|
||||
"categorized_on": journal["categorized_on"], "issns": known.get(journal["name"], []) if category == "К1" else []}
|
||||
if record["issns"]:
|
||||
record.update(identity_source=identities["source"], identity_source_sha256=identities["source_sha256"],
|
||||
identity_as_of=identities["as_of"])
|
||||
for name in [journal["name"], *journal.get("aliases", [])]:
|
||||
by_name[journal_key(name)].append(record)
|
||||
for identifier in record["issns"]:
|
||||
by_issn[identifier].append(record)
|
||||
return dict(by_name), dict(by_issn)
|
||||
|
||||
|
||||
def assess_vak(names, identifiers):
|
||||
by_name, by_issn = category_index()
|
||||
matches = {}
|
||||
normalized = {issn(value) for value in identifiers} - {""}
|
||||
for identifier in normalized:
|
||||
for record in by_issn.get(identifier, []):
|
||||
matches[(record["category"], record["source_row"])] = {**record, "match_method": "issn", "identity_confirmed": True}
|
||||
for name in names:
|
||||
for record in by_name.get(journal_key(name), []):
|
||||
if record["issns"] and normalized and not normalized.intersection(record["issns"]):
|
||||
return {"schema_version": 1, "entries": [], "status": "journal_identity_conflict",
|
||||
"scope": "supplied_category_snapshot", "historical_category_confirmed": False, "checked_on": date.today().isoformat()}
|
||||
key = record["category"], record["source_row"]
|
||||
matches.setdefault(key, {**record, "match_method": "normalized_name", "identity_confirmed": False})
|
||||
if len(matches) > 1:
|
||||
status, output = "ambiguous_journal", []
|
||||
elif matches:
|
||||
status, output = "found", list(matches.values())
|
||||
else:
|
||||
status, output = ("not_found_in_k1_k2" if names or identifiers else "unknown_journal"), []
|
||||
return {"schema_version": 1, "entries": output, "status": status,
|
||||
"scope": "supplied_category_snapshot", "historical_category_confirmed": False,
|
||||
"checked_on": date.today().isoformat()}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,133 @@
|
||||
# Запись публикации в БД
|
||||
|
||||
Объект `EmployeePublication`, таблица `employee_publications`. Одна запись относится к одному сотруднику; одна работа у нескольких соавторов может иметь несколько записей с одним DOI. Типы ниже указаны для PostgreSQL; в SQLite JSONB представлен JSON.
|
||||
|
||||
| Поле | Тип | Обязательное | Назначение |
|
||||
|---|---|:---:|---|
|
||||
| `id` | integer | Да | Первичный ключ |
|
||||
| `employee_id` | integer | Да | Внешний ключ `employees.id`; удаление сотрудника удаляет связанные записи |
|
||||
| `publication_id` | varchar(64) | Нет | Идентификатор публикации в источнике ВШЭ |
|
||||
| `title` | text | Да | Название |
|
||||
| `year` | integer | Нет | Год публикации |
|
||||
| `publication_type` | varchar(64) | Нет | Тип материала: ARTICLE, CHAPTER, BOOK, PREPRINT и другие |
|
||||
| `language` | varchar(16) | Нет | Язык |
|
||||
| `status` | integer | Нет | Код статуса из источника |
|
||||
| `url` | text | Нет | Основная ссылка |
|
||||
| `doi_url` | text | Нет | DOI или ссылка DOI |
|
||||
| `other_url` | text | Нет | Дополнительная ссылка |
|
||||
| `document_url` | text | Нет | Ссылка на документ |
|
||||
| `citation_text` | text | Нет | Библиографическое описание |
|
||||
| `quartiles` | jsonb | Нет | Квартили Q1–Q4 с системой, годом метрики, источником и результатом поиска |
|
||||
| `vak_categories` | jsonb | Нет | Результат проверки журнала по предоставленным спискам К1/К2, способ идентификации и источник |
|
||||
| `annotation` | jsonb | Нет | Аннотация |
|
||||
| `description` | jsonb | Нет | Структурированное описание |
|
||||
| `authors` | jsonb | Нет | Список авторов |
|
||||
| `raw_data` | jsonb | Нет | Исходные данные, включая обогащение из данных ВШЭ |
|
||||
| `source_hash` | varchar(64) | Да | Хеш исходной записи |
|
||||
| `created_at` | timestamptz | Да | Создание записи |
|
||||
| `updated_at` | timestamptz | Да | Обновление записи |
|
||||
|
||||
Уникальны пары `(employee_id, publication_id)` и `(employee_id, source_hash)`. Индексы: `employee_id`, `publication_id`, `doi_url`, `year`, `publication_type`. Атрибут ORM `employee` ссылается на объект сотрудника и отдельным столбцом не является.
|
||||
|
||||
## Поле квартилей
|
||||
|
||||
`NULL` означает, что обогащение ещё не выполнялось. После проверки поле содержит объект со схемой версии 1. Пустой `entries` означает отсутствие найденного квартиля; причина сохраняется в `lookup`. Пустой результат не равнозначен Q4 и не доказывает отсутствие индексации.
|
||||
|
||||
Пример структуры (иллюстративные данные):
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"entries": [
|
||||
{
|
||||
"system": "Scopus",
|
||||
"quartile": "Q2",
|
||||
"doi": "10.1234/example",
|
||||
"metric": "CiteScore",
|
||||
"metric_year": 2024,
|
||||
"publication_year": 2024,
|
||||
"category": "Applied Mathematics",
|
||||
"journal": "Название журнала",
|
||||
"issns": ["0006-3835"],
|
||||
"source": "Название проверенного источника",
|
||||
"source_url": "https://example.org/metrics",
|
||||
"checked_on": "2026-10-08"
|
||||
}
|
||||
],
|
||||
"lookup": {
|
||||
"doi": "10.1234/example",
|
||||
"status": "found",
|
||||
"requested_year": 2024,
|
||||
"checked_on": "2026-10-08"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `entries` сохраняет все найденные категории и системы отдельно, без выбора одного «лучшего» квартиля вместо остальных. Возможные системы: Scopus, Web of Science, SCImago. Метрики CiteScore, JCR и SJR не подменяют друг друга. При наличии только лучшего квартиля SJR используется метрика `SJR best quartile`, категория остаётся `null`.
|
||||
- Каждое основание хранит собственный `doi`. При дополнении старого payload основания без этого ключа получают DOI из прежнего `lookup` до его обновления; при отсутствии прежнего DOI сохраняется `null`. Новый общий `lookup` не перепривязывает прежние записи. DOI данных ВШЭ берётся из их собственных исходных метаданных.
|
||||
- Перед использованием в счётчиках основание сверяется с текущими DOI, журналом и ISSN публикации. Конфликт исключает это основание и выводится как причина перепроверки; доказательства сохраняются. Совпадающий ISSN позволяет подтвердить переименование журнала, но не снимает конфликт DOI. Основание без проверяемой привязки не засчитывается по одному совпадению года. Это также относится к обратному чтению `raw_data.csv_enrichment`.
|
||||
- Значения `Квартиль scopus` и `Квартиль wos` из `raw_data.csv_enrichment` переносятся в отдельные записи Q1–Q4. Их точная метрика и год метрики неизвестны: `metric` и `metric_year` остаются `null`. Год публикации не объявляется годом метрики. Исходное обогащение не удаляется.
|
||||
- При поиске новых значений журнал определяется по DOI через [Crossref](https://www.crossref.org/documentation/retrieve-metadata/rest-api/), а при отсутствии записи — через публичный [DataCite API](https://support.datacite.org/docs/rest-api). ISSN связывает журнал с рейтингом. Проверяются идентичность DOI, тип материала и соответствие года публикации данным DOI. Книге или главе не присваивается журнальный квартиль по одному похожему названию. Препринт DataCite не получает квартиль журнала связанной статьи автоматически.
|
||||
- Для старого ISSN допускается подтверждённая преемственность названия по официальному [списку Scopus за август 2026](https://downloads.ctfassets.net/o78em1y1w4i4/7xtaTxNiNcWRTeZkV86eNy/69cf2d506c905dc299531fdc93049dbb/ext_list_Aug_2026.xlsx). Нужны явная связь Continued as/Continues и единственное связанное название, совпадающее с журналом в ответе DOI. Похожесть названий не используется. Исходные ISSN Crossref сохраняются; дополнительные ISSN для рейтинга и ссылка на историю записываются отдельно в `lookup`, ссылка `journal_identity_source_url` также сохраняется в найденных квартилях.
|
||||
- Для каждой системы сначала используется метрика за год публикации. При явно разрешённом `--allow-latest` и отсутствии исторической метрики допускается последний доступный год: он остаётся в `metric_year`, а результат имеет статус `found_latest`. Все категории CiteScore/JCR и SJR сохраняются вместе; разные системы могут иметь разные годы метрики. Это последний год в доступных проверенных источниках, а не обещание глобально самого нового рейтинга.
|
||||
- Источники: проверяемые каталоги проекта (CiteScore/JCR) и исторические SJR-данные [Universidad de Extremadura](https://opendata.unex.es/dataset/revistas-cientificas-2025) за 2009–2025. Портал указывает SCImago как автора и поддерживающую организацию и публикует данные по CC BY 3.0; атрибуция и ссылка сохраняются в каждой записи. [SJR](https://www.scimagolab.com/products/sjr-scimago-journal-country-rank/) хранится с системой SCImago, даже несмотря на происхождение исходных данных из Scopus.
|
||||
|
||||
`lookup` также может содержать полученные по DOI тип материала, названия журнала, ISSN, годы и ссылку на регистратора DOI. Состояния поиска:
|
||||
|
||||
| Статус | Значение |
|
||||
|---|---|
|
||||
| `found` | Найдена метрика за год публикации |
|
||||
| `found_latest` | Использован другой, последний доступный год |
|
||||
| `missing_doi` / `invalid_doi` | DOI отсутствует или некорректен |
|
||||
| `doi_not_found` / `doi_lookup_failed` | DOI не найден в Crossref и DataCite / источник не ответил успешно; DOI может относиться к другому регистратору |
|
||||
| `doi_identity_conflict` | Ответ источника относится к другому DOI |
|
||||
| `not_journal_article` | DOI относится к материалу, не определённому как журнальная статья |
|
||||
| `missing_publication_year` / `publication_year_conflict` | Год неизвестен / отличается от годов источника DOI |
|
||||
| `missing_journal_issn` | Журнал по ISSN не установлен |
|
||||
| `journal_identity_conflict` | Идентификаторы связываются с несколькими журналами |
|
||||
| `ranking_category_conflict` | Сведения о категориях SJR противоречат друг другу |
|
||||
| `historical_quartile_unavailable` | Проверенные источники не дали допустимой метрики |
|
||||
|
||||
## Миграция и обогащение
|
||||
|
||||
Миграции `migrations/010_publication_quartiles.sql` и `migrations/011_publication_vak_categories.sql` добавляют nullable JSONB-поля без удаления или перезаписи данных. Инициализация приложения также добавляет поля на существующей схеме PostgreSQL/SQLite. Обогащение не запускается при старте API или обработке HTTP-запроса.
|
||||
|
||||
Перед изменением заполненной БД создать и проверить резервную копию. Подключение инструмента — только через `DATABASE_URL` из окружения; `.env` и файлы с credentials инструмент не читает. Не передавать секреты в аргументах и не коммитить отчёты, кэши и резервные копии.
|
||||
|
||||
Сначала выполнить предварительное сравнение без записи, затем перенос известных значений:
|
||||
|
||||
```sh
|
||||
python scripts/enrich_publication_quartiles.py --cache-dir .impeccable/quartiles/cache --report .impeccable/quartiles/hse-plan.json
|
||||
python scripts/enrich_publication_quartiles.py --apply --cache-dir .impeccable/quartiles/cache --report .impeccable/quartiles/hse-applied.json
|
||||
```
|
||||
|
||||
Затем проверить DOI у записей без квартиля. Для предварительного сравнения убрать `--apply`:
|
||||
|
||||
```sh
|
||||
python scripts/enrich_publication_quartiles.py --apply --lookup-doi --all-systems --check-vak --sjr-history --allow-latest --workers 4 --cache-dir .impeccable/quartiles/cache --report .impeccable/quartiles/doi-applied.json
|
||||
```
|
||||
|
||||
Для проверки смены ISSN добавить `--scopus-title-history /путь/ext_list_Aug_2026.xlsx`. Используется конкретный официальный снимок: SHA-256 `11e81f686401c89fbef28de31d1880388bb7122f35c89e09db6e1848237e9afb`; несоответствующий файл отклоняется. Это список названий и их истории, не источник квартилей.
|
||||
|
||||
`--all-systems` проверяет также публикации с уже известным квартилем: добавляются остальные доступные системы и предметные категории. Без этого флага проверяются записи без квартиля, ранее найденные последние метрики и заполненные основания с изменившейся или неподтверждённой привязкой. Новые доказательства дополняют список, не удаляя прежние. Несколько категорий с одинаковым Q1 сохраняются отдельно; один квартиль не заменяет остальные.
|
||||
|
||||
## Поле категорий ВАК
|
||||
|
||||
`vak_categories` имеет `schema_version: 1`, массив `entries`, `status`, `scope: "supplied_category_snapshot"`, `historical_category_confirmed: false` и дату `checked_on`. `NULL` — проверка ещё не проводилась. В каждом совпадении сохраняются `category` (К1/К2), каноническое название журнала, название и SHA-256 источника, строка источника, дата категоризации, ISSN при наличии, `match_method` и `identity_confirmed`.
|
||||
|
||||
Проверяются `k1_journals.json` и `k2_journals.json`, извлечённые из предоставленного документа «ВАК категории.pdf». Для К1 доступна дополнительная идентификация по ISSN из `k1_specialties.json`; её источник, SHA-256 и дата списка сохраняются отдельно. В проверку передаются ISSN/eISSN исходного `raw_data`, данных ВШЭ и актуального ответа DOI; исходные идентификаторы используются и без сетевого поиска или DOI. Ответ, сохранённый для другого DOI, не считается актуальным источником идентификаторов. Поиск К2 в текущих данных выполняется по нормализованному названию и явным административным переименованиям. Транслитерация, похожесть названий и предположение о соответствии русской и переводной версии не используются.
|
||||
|
||||
- `match_method: "issn"`, `identity_confirmed: true` — журнал идентифицирован по ISSN.
|
||||
- `match_method: "normalized_name"`, `identity_confirmed: false` — название совпало со списком; идентичность по ISSN не подтверждена. Это предварительное совпадение.
|
||||
- `status: "found"` — одно совпадение; `ambiguous_journal` или `journal_identity_conflict` — неоднозначность/конфликт, категория автоматически не выбирается.
|
||||
- `not_found_in_k1_k2` — совпадений в этих двух снимках нет; это не доказывает отсутствие журнала в ВАК или наличие К3. `unknown_journal` — данных для идентификации журнала нет.
|
||||
|
||||
Дата категоризации в документе не объявляется датой всего снимка, а категория по имеющемуся списку не подтверждает её наличие в год публикации. Книги и главы не становятся журнальными статьями из-за совпадения названия. Проверка К1/К2 не является подтверждением зачёта конкретной работы по критериям совета.
|
||||
|
||||
`--check-vak` сохраняет этот результат в отдельном поле. Уже заполненные отличающиеся значения не перезаписываются; число таких случаев отражается в `vak_existing_value_kept`. Их нужно отдельно сравнить с актуальными источниками. Проверить результаты и причины неоднозначности до использования данных в заключении.
|
||||
|
||||
Запросы DOI кэшируются по нормализованному DOI и переиспользуются для соавторов. Ошибки сети допускают повторную попытку; для полного обновления ранее найденных ответов использовать новый каталог кэша. Исторические рейтинги запрашиваются только для ISSN найденных журналов; ошибки источника отражаются в `ranking_failures` отчёта. Существующие квартильные записи не перезаписываются; повторный перенос не дублирует доказательства только из-за новой даты проверки. Перед записью инструмент проверяет, что публикация не изменилась параллельно; при изменении пропускает её и фиксирует `concurrent_change_skipped`. Коммит выполняется по публикациям: прерванную обработку можно возобновить. Проверить итоговый отчёт и полноту переноса после завершения.
|
||||
|
||||
Парсер сохраняет `quartiles`, обновляя остальные исходные поля публикации. Страница совета читает отдельное поле с обратной совместимостью для исходных данных ВШЭ. В предварительные Q1/Q2 совета попадают Scopus/Web of Science с применимым годом; последние метрики другого года и SJR автоматически не превращаются в исторический зачёт или документальное подтверждение критериев.
|
||||
|
||||
Миграция и обогащение локальной БД не обновляют production. Порядок переноса только недостающих данных в production и сохранения его заполненных значений описан в README.
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Source/year-aware quartiles; NULL means enrichment has not run yet.
|
||||
ALTER TABLE employee_publications ADD COLUMN IF NOT EXISTS quartiles JSONB;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Matches to the supplied category snapshot, with provenance and identity state.
|
||||
ALTER TABLE employee_publications ADD COLUMN IF NOT EXISTS vak_categories JSONB;
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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:
|
||||
|
agent
commented
[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)
|
||||
@@ -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():
|
||||
|
||||
@@ -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 "<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.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
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
from copy import deepcopy
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, inspect, text, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import _ensure_runtime_schema, Base
|
||||
from app.models import EmployeePublication, Employee
|
||||
from app.services import publication_quartiles as quartiles
|
||||
from app.services.crawler import _apply_publication
|
||||
from scripts.enrich_publication_quartiles import resolve_doi, load_sjr, main
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scopus,wos", [("Q1", "Q2"), ("q3", "Q4"), ("Q4", "")])
|
||||
def test_hse_backfill_keeps_systems_q1_to_q4_and_unknown_metric_year(scopus, wos):
|
||||
raw = {"other": "preserve", "csv_enrichment": {"Квартиль scopus": scopus, "Квартиль wos": wos, "Год": "2024"}}
|
||||
before = deepcopy(raw)
|
||||
result = quartiles.hse_entries(raw, 2024)
|
||||
assert raw == before
|
||||
assert [e["quartile"] for e in result] == [v.upper() for v in (scopus, wos) if v]
|
||||
assert all(e["metric_year"] is None and e["publication_year"] == 2024 for e in result)
|
||||
assert [e["system"] for e in result] == (["Scopus", "Web of Science"] if wos else ["Scopus"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["Q5", "1", "", None])
|
||||
def test_invalid_hse_value_does_not_invent_a_quartile(value):
|
||||
assert quartiles.hse_entries({"csv_enrichment": {"Квартиль scopus": value}}, 2024) == []
|
||||
|
||||
|
||||
def test_merge_is_idempotent_and_preserves_conflicting_evidence():
|
||||
first = quartiles.hse_entries({"csv_enrichment": {"Квартиль scopus": "Q1", "DOI": "10.1234/work"}}, 2024)[0]
|
||||
payload = quartiles.merge(None, [first])
|
||||
assert quartiles.merge(payload, [{**first, "checked_on": "2099-01-01"}]) == payload
|
||||
assert len(quartiles.merge(payload, [{**first, "quartile": "Q2"}])["entries"]) == 2
|
||||
assert quartiles.labels(payload, 2024, doi="10.1234/work") == ["Scopus Q1"]
|
||||
assert quartiles.labels(payload, 2023) == []
|
||||
|
||||
|
||||
def test_catalog_metric_requires_exact_year_unless_latest_explicitly_allowed(monkeypatch):
|
||||
monkeypatch.setattr(quartiles, "publication_catalog", lambda ids, names: ([{
|
||||
"name": "Journal", "metrics": [{"year": 2025, "system": "Scopus", "quartile": "Q1", "metric": "CiteScore",
|
||||
"category": "Mathematics", "source": "Snapshot", "source_url": "https://example.org/metrics"}]}], []))
|
||||
metadata = {"type": "journal-article", "years": [2024], "issns": ["0006-3835"]}
|
||||
assert quartiles.catalog_entries(metadata, 2024)[0] == []
|
||||
found, status = quartiles.catalog_entries(metadata, 2024, True)
|
||||
assert status == "found_latest" and found[0]["metric_year"] == 2025 and found[0]["publication_year"] == 2024
|
||||
assert quartiles.labels(quartiles.merge(None, found), 2024) == []
|
||||
assert quartiles.catalog_entries(metadata, 2023, True)[1] == "publication_year_conflict"
|
||||
assert quartiles.catalog_entries({**metadata, "type": "book-chapter"}, 2024, True)[1] == "not_journal_article"
|
||||
|
||||
|
||||
def sjr_record(year, categories="Analysis (Q1); Applied Mathematics (Q2)"):
|
||||
return {"journal": "Journal", "issns": ["0006-3835"], "quartile": "Q1", "categories": categories,
|
||||
"source_url": f"https://opendata.unex.es/recurso/ciencia-tecnologia/investigacion/revistas/Revista/{year}-1"}
|
||||
|
||||
|
||||
def test_sjr_keeps_historical_year_categories_and_system_distinct():
|
||||
metadata = {"type": "journal-article", "years": [2020], "issns": ["0006-3835"]}
|
||||
found, status = quartiles.sjr_entries(metadata, 2020, quartiles.index_sjr({2020: [sjr_record(2020)], 2025: [sjr_record(2025)]}), True)
|
||||
assert status == "found" and [e["quartile"] for e in found] == ["Q1", "Q2"]
|
||||
assert all(e["system"] == "SCImago" and e["metric_year"] == 2020 for e in found)
|
||||
assert quartiles.labels(quartiles.merge(None, found), 2020) == []
|
||||
latest, status = quartiles.sjr_entries(metadata, 2020, quartiles.index_sjr({2025: [sjr_record(2025)]}), True)
|
||||
assert status == "found_latest" and latest[0]["metric_year"] == 2025
|
||||
assert quartiles.sjr_entries(metadata, 2020, quartiles.index_sjr({2025: [sjr_record(2025)]}))[0] == []
|
||||
|
||||
|
||||
def test_sjr_identity_and_category_conflicts_do_not_resolve_by_order():
|
||||
metadata = {"type": "journal-article", "years": [2020], "issns": ["0006-3835"]}
|
||||
first = sjr_record(2020)
|
||||
second = {**first, "source_url": first["source_url"] + "2"}
|
||||
assert quartiles.sjr_entries(metadata, 2020, quartiles.index_sjr({2020: [first, second]}))[1] == "journal_identity_conflict"
|
||||
assert quartiles.sjr_entries(metadata, 2020, quartiles.index_sjr({2020: [sjr_record(2020, "Analysis (Q3)")]} ))[1] == "ranking_category_conflict"
|
||||
|
||||
|
||||
def test_schema_addition_is_repeatable_and_preserves_existing_data(monkeypatch):
|
||||
engine = create_engine("sqlite://")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE TABLE employee_publications (id INTEGER PRIMARY KEY, title TEXT NOT NULL)"))
|
||||
connection.execute(text("INSERT INTO employee_publications (id,title) VALUES (1,'Keep')"))
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
_ensure_runtime_schema()
|
||||
_ensure_runtime_schema()
|
||||
assert "quartiles" in {c["name"] for c in inspect(engine).get_columns("employee_publications")}
|
||||
with engine.connect() as connection:
|
||||
assert connection.execute(text("SELECT title,quartiles FROM employee_publications")).one() == ("Keep", None)
|
||||
|
||||
|
||||
def test_crawler_preserves_quartiles_while_replacing_source_owned_raw_data():
|
||||
payload = quartiles.merge(None, quartiles.hse_entries({"csv_enrichment": {"Квартиль scopus": "Q4"}}, 2024))
|
||||
publication = EmployeePublication(title="Old", source_hash="old", quartiles=deepcopy(payload))
|
||||
publication.vak_categories = {"schema_version": 1, "entries": [{"category": "К1"}], "status": "found"}
|
||||
_apply_publication(publication, {"id": "42", "title": "Updated", "year": 2024, "raw_data": {"title": "Updated"}}, "new")
|
||||
assert publication.quartiles == payload
|
||||
assert publication.vak_categories["entries"] == [{"category": "К1"}]
|
||||
assert publication.title == "Updated" and publication.source_hash == "new"
|
||||
|
||||
|
||||
def test_council_reads_new_field_without_raw_enrichment_and_keeps_historical_filter():
|
||||
from app.services.council import _database_article
|
||||
values = quartiles.hse_entries({"csv_enrichment": {"Квартиль scopus": "Q2", "DOI": "10.1234/work"}}, 2024)
|
||||
publication = EmployeePublication(title="Work", year=2024, publication_type="ARTICLE", source_hash="hash",
|
||||
quartiles=quartiles.merge(None, values), raw_data={}, doi_url="https://doi.org/10.1234/work")
|
||||
assert _database_article(publication)["quartiles"] == ["Scopus Q2"]
|
||||
publication.quartiles = quartiles.merge(None, [{**values[0], "metric_year": 2025}])
|
||||
assert _database_article(publication)["quartiles"] == []
|
||||
|
||||
|
||||
def test_doi_cache_uses_verified_identity_and_does_not_need_network_on_repeat(monkeypatch, tmp_path):
|
||||
calls = []
|
||||
class Response:
|
||||
status_code = 200
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
def json(self):
|
||||
return {"message": {"DOI": "10.1234/work", "type": "journal-article", "container-title": ["Journal"],
|
||||
"ISSN": ["0006-3835"], "published": {"date-parts": [[2024, 2, 1]]}}}
|
||||
def get(url, **kwargs):
|
||||
calls.append(url)
|
||||
assert kwargs["allow_redirects"] is False
|
||||
return Response()
|
||||
monkeypatch.setattr("scripts.enrich_publication_quartiles.requests.get", get)
|
||||
result = resolve_doi("10.1234/work", tmp_path)
|
||||
assert result["status"] == "resolved" and result["years"] == [2024]
|
||||
assert resolve_doi("10.1234/work", tmp_path) == result and len(calls) == 1
|
||||
assert calls[0] == "https://api.crossref.org/works/10.1234%2Fwork"
|
||||
|
||||
|
||||
def test_sjr_source_combines_multiple_categories_of_the_same_resource(monkeypatch, tmp_path):
|
||||
url = "https://opendata.unex.es/recurso/ciencia-tecnologia/investigacion/revistas/Revista/2020-1"
|
||||
def binding(category, quartile="Q1"):
|
||||
return {key: {"value": value} for key, value in {
|
||||
"uri": url, "title": "Journal", "issn": "0006-3835", "quartile": quartile, "categories": category}.items()}
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
def json(self):
|
||||
return {"results": {"bindings": [binding("Analysis (Q1)"), binding("Applied Mathematics (Q2)")]}}
|
||||
monkeypatch.setattr("scripts.enrich_publication_quartiles.requests.get", lambda *args, **kwargs: Response())
|
||||
rankings, errors = load_sjr(["0006-3835"], [2020], tmp_path)
|
||||
assert not errors
|
||||
metadata = {"type": "journal-article", "years": [2020], "issns": ["0006-3835"]}
|
||||
found, status = quartiles.sjr_entries(metadata, 2020, rankings)
|
||||
assert status == "found" and {e["quartile"] for e in found} == {"Q1", "Q2"}
|
||||
|
||||
|
||||
def test_doi_identity_conflict_does_not_supply_journal_metrics(monkeypatch, tmp_path):
|
||||
class Response:
|
||||
status_code = 200
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
def json(self):
|
||||
return {"message": {"DOI": "10.1234/another", "type": "journal-article", "ISSN": ["0006-3835"]}}
|
||||
monkeypatch.setattr("scripts.enrich_publication_quartiles.requests.get", lambda *args, **kwargs: Response())
|
||||
result = resolve_doi("10.1234/work", tmp_path)
|
||||
assert result["status"] == "doi_identity_conflict" and "issns" not in result
|
||||
assert not list(tmp_path.glob("*.json"))
|
||||
|
||||
|
||||
def test_enrichment_cli_dry_run_apply_and_repeat_preserve_original_data(monkeypatch, tmp_path):
|
||||
url = "sqlite:///" + str(tmp_path / "publications.db")
|
||||
engine = create_engine(url)
|
||||
Base.metadata.create_all(engine)
|
||||
raw = {"original": "keep", "csv_enrichment": {"Квартиль scopus": "Q3", "Год": "2024"}}
|
||||
with Session(engine) as session:
|
||||
employee = Employee(profile_key="test", canonical_url="https://example.org/person")
|
||||
session.add(employee)
|
||||
session.flush()
|
||||
session.add(EmployeePublication(employee_id=employee.id, title="Work", year=2024, source_hash="hash", raw_data=raw))
|
||||
session.commit()
|
||||
monkeypatch.setenv("DATABASE_URL", url)
|
||||
arguments = ["enrich", "--cache-dir", str(tmp_path / "cache"), "--report", str(tmp_path / "report.json")]
|
||||
monkeypatch.setattr("sys.argv", arguments)
|
||||
main()
|
||||
with Session(engine) as session:
|
||||
assert session.scalar(select(EmployeePublication)).quartiles is None
|
||||
monkeypatch.setattr("sys.argv", arguments + ["--apply"])
|
||||
main()
|
||||
with Session(engine) as session:
|
||||
publication = session.scalar(select(EmployeePublication))
|
||||
payload = deepcopy(publication.quartiles)
|
||||
assert publication.raw_data == raw and payload["entries"][0]["quartile"] == "Q3"
|
||||
main()
|
||||
with Session(engine) as session:
|
||||
publication = session.scalar(select(EmployeePublication))
|
||||
assert publication.quartiles == payload and publication.raw_data == raw
|
||||
|
||||
|
||||
def test_issn_succession_requires_explicit_relation_and_unique_doi_journal_title():
|
||||
old = {"history": "Continued as", "related": ["Physical Review B"]}
|
||||
new = {"issns": {"2469-9950", "2469-9969"}}
|
||||
metadata = {"issns": ["1098-0121", "1550-235X"], "journals": ["Physical Review B"]}
|
||||
result = quartiles.with_title_history(metadata, {"10980121": [old]}, {"physical review b": [new]}, "https://example.org/source-titles")
|
||||
assert result["issns"] == metadata["issns"] and "2469-9950" in result["ranking_issns"]
|
||||
assert metadata == {"issns": ["1098-0121", "1550-235X"], "journals": ["Physical Review B"]}
|
||||
assert quartiles.with_title_history(metadata, {"10980121": [old]}, {"physical review b": [new, new]}, "url") == metadata
|
||||
assert quartiles.with_title_history({**metadata, "journals": ["Other journal"]}, {"10980121": [old]}, {"physical review b": [new]}, "url") == {**metadata, "journals": ["Other journal"]}
|
||||
|
||||
|
||||
def test_crossref_not_found_falls_back_to_datacite_journal_metadata(monkeypatch, tmp_path):
|
||||
class Response:
|
||||
def __init__(self, status):
|
||||
self.status_code = status
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
def json(self):
|
||||
return {"data": {"attributes": {"doi": "10.1234/work", "publicationYear": 2024,
|
||||
"types": {"resourceTypeGeneral": "JournalArticle"},
|
||||
"container": {"title": "Journal", "identifierType": "ISSN", "identifier": "0006-3835"}}}}
|
||||
calls = []
|
||||
def get(url, **kwargs):
|
||||
calls.append(url)
|
||||
return Response(404 if "crossref.org" in url else 200)
|
||||
monkeypatch.setattr("scripts.enrich_publication_quartiles.requests.get", get)
|
||||
result = resolve_doi("10.1234/work", tmp_path)
|
||||
assert result["status"] == "resolved" and result["type"] == "journal-article"
|
||||
assert result["issns"] == ["0006-3835"] and result["years"] == [2024]
|
||||
assert result["datacite_checked"] and len(calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("new_doi,new_journal", [("10.1234/new", "Different journal"), ("10.1234/old", "Different journal")])
|
||||
def test_crawler_doi_or_journal_change_excludes_preserved_quartile(new_doi, new_journal):
|
||||
from app.services.council import _database_article
|
||||
evidence = {"system": "Scopus", "quartile": "Q1", "publication_year": 2024, "metric_year": 2024,
|
||||
"journal": "Old journal", "issns": [], "source": "Checked"}
|
||||
payload = {"schema_version": 1, "entries": [evidence], "lookup": {"doi": "10.1234/old"}}
|
||||
publication = EmployeePublication(title="Work", year=2024, source_hash="old", publication_type="ARTICLE",
|
||||
doi_url="https://doi.org/10.1234/old", quartiles=deepcopy(payload), raw_data={"journal": "Old journal"})
|
||||
assert _database_article(publication)["quartiles"] == ["Scopus Q1"]
|
||||
_apply_publication(publication, {"id": "42", "title": "Work", "year": 2024, "type": "ARTICLE",
|
||||
"doi_url": "https://doi.org/" + new_doi, "raw_data": {"journal": new_journal}}, "new")
|
||||
article = _database_article(publication)
|
||||
assert publication.quartiles == payload
|
||||
assert article["quartiles"] == [] and article["quartile_issues"]
|
||||
assert all("перепроверка" in issue for issue in article["quartile_issues"])
|
||||
|
||||
|
||||
def test_updating_lookup_freezes_legacy_binding_instead_of_rebinding_old_entries():
|
||||
old = {"system": "Scopus", "quartile": "Q1", "publication_year": 2024, "metric_year": 2024,
|
||||
"journal": "Old journal", "issns": [], "source": "Checked"}
|
||||
payload = {"schema_version": 1, "entries": [old], "lookup": {"doi": "10.1234/old"}}
|
||||
updated = quartiles.merge(payload, [], lookup={"doi": "10.1234/new"})
|
||||
assert updated["entries"][0]["doi"] == "10.1234/old"
|
||||
assert quartiles.labels(updated, 2024, "10.1234/new", ["Old journal"]) == []
|
||||
fresh = {**old, "doi": "10.1234/new", "quartile": "Q2"}
|
||||
refreshed = quartiles.merge(updated, [fresh], lookup={"doi": "10.1234/new"})
|
||||
assert quartiles.labels(refreshed, 2024, "10.1234/new", ["Old journal"]) == ["Scopus Q2"]
|
||||
assert len(refreshed["entries"]) == 2
|
||||
|
||||
|
||||
def test_unbound_hse_fallback_does_not_bypass_quartile_identity_checks():
|
||||
from app.services.council import _database_article
|
||||
publication = EmployeePublication(title="Work", year=2024, source_hash="hash", publication_type="ARTICLE",
|
||||
doi_url="10.1234/new", raw_data={"journal": "New journal", "csv_enrichment": {
|
||||
"DOI": "10.1234/old", "Название журнала": "Old journal", "Квартиль scopus": "Q1"}})
|
||||
article = _database_article(publication)
|
||||
assert article["quartiles"] == [] and article["quartile_issues"]
|
||||
assert "Old journal" not in article["journals"]
|
||||
|
||||
|
||||
def test_journal_issn_allows_explicit_rename_but_conflicting_issn_is_rejected():
|
||||
value = {"system": "Scopus", "quartile": "Q1", "publication_year": 2024, "metric_year": 2024,
|
||||
"doi": "10.1234/work", "journal": "Old title", "issns": ["0006-3835"]}
|
||||
payload = quartiles.merge(None, [value])
|
||||
assert quartiles.labels(payload, 2024, "10.1234/work", ["Renamed title"], ["00063835"]) == ["Scopus Q1"]
|
||||
assert quartiles.labels(payload, 2024, "10.1234/work", ["Old title"], ["11111111"]) == []
|
||||
|
||||
|
||||
def test_default_enrichment_rechecks_filled_evidence_after_doi_change(monkeypatch, tmp_path):
|
||||
url = "sqlite:///" + str(tmp_path / "stale.db")
|
||||
engine = create_engine(url)
|
||||
Base.metadata.create_all(engine)
|
||||
old = {"system": "Scopus", "quartile": "Q1", "publication_year": 2024, "metric_year": 2024,
|
||||
"journal": "Old journal", "issns": [], "source": "Checked"}
|
||||
payload = {"schema_version": 1, "entries": [old], "lookup": {"doi": "10.1234/old", "status": "found"}}
|
||||
with Session(engine) as session:
|
||||
person = Employee(profile_key="test", canonical_url="https://example.org/person")
|
||||
session.add(person)
|
||||
session.flush()
|
||||
session.add(EmployeePublication(employee_id=person.id, title="Work", year=2024, source_hash="hash",
|
||||
doi_url="10.1234/new", raw_data={"journal": "New journal"}, quartiles=payload))
|
||||
session.commit()
|
||||
calls = []
|
||||
def resolve(doi, cache):
|
||||
calls.append(doi)
|
||||
return {"status": "resolved", "doi": doi, "type": "journal-article", "journals": ["New journal"]}
|
||||
monkeypatch.setattr("scripts.enrich_publication_quartiles.resolve_doi", resolve)
|
||||
monkeypatch.setattr("scripts.enrich_publication_quartiles.catalog_entries", lambda metadata, year, latest: ([
|
||||
{**old, "doi": metadata["doi"], "journal": "New journal", "quartile": "Q2"}], "found"))
|
||||
monkeypatch.setenv("DATABASE_URL", url)
|
||||
monkeypatch.setattr("sys.argv", ["enrich", "--apply", "--lookup-doi", "--cache-dir", str(tmp_path / "cache"),
|
||||
"--report", str(tmp_path / "report.json")])
|
||||
main()
|
||||
with Session(engine) as session:
|
||||
stored = session.scalar(select(EmployeePublication)).quartiles
|
||||
assert calls == ["10.1234/new"] and len(stored["entries"]) == 2
|
||||
assert stored["entries"][0]["doi"] == "10.1234/old"
|
||||
assert quartiles.labels(stored, 2024, "10.1234/new", ["New journal"]) == ["Scopus Q2"]
|
||||
@@ -0,0 +1,112 @@
|
||||
import pytest
|
||||
|
||||
from app.services import publication_vak as vak
|
||||
from app.services.publication_quartiles import catalog_entries, sjr_entries, index_sjr
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def index(monkeypatch):
|
||||
k1 = {"category": "К1", "journal": "Первый журнал", "source_row": 1, "issns": ["00063835"]}
|
||||
k2 = {"category": "К2", "journal": "Второй журнал", "source_row": 2, "issns": []}
|
||||
monkeypatch.setattr(vak, "category_index", lambda: ({"первый журнал": [k1], "второй журнал": [k2]}, {"00063835": [k1]}))
|
||||
return k1, k2
|
||||
|
||||
|
||||
def test_vak_identifies_k1_by_issn_without_guessing_translated_names(index):
|
||||
result = vak.assess_vak(["English title"], ["0006-3835"])
|
||||
assert result["status"] == "found" and result["entries"][0]["category"] == "К1"
|
||||
assert result["entries"][0]["identity_confirmed"] and not result["historical_category_confirmed"]
|
||||
|
||||
|
||||
def test_k2_name_match_is_explicitly_preliminary(index):
|
||||
result = vak.assess_vak([" Второй журнал. "], [])
|
||||
assert result["entries"][0]["category"] == "К2"
|
||||
assert not result["entries"][0]["identity_confirmed"]
|
||||
assert result["entries"][0]["match_method"] == "normalized_name"
|
||||
|
||||
|
||||
def test_vak_conflicting_identifiers_and_multiple_journals_are_not_unioned(index):
|
||||
assert vak.assess_vak(["Первый журнал"], ["1111-1111"])["status"] == "journal_identity_conflict"
|
||||
assert vak.assess_vak(["Первый журнал", "Второй журнал"], [])["status"] == "ambiguous_journal"
|
||||
assert vak.assess_vak([], [])["status"] == "unknown_journal"
|
||||
assert vak.assess_vak(["Другой журнал"], [])["status"] == "not_found_in_k1_k2"
|
||||
|
||||
|
||||
def test_committed_category_snapshots_match_known_journal_and_record_sources():
|
||||
vak.category_index.cache_clear()
|
||||
result = vak.assess_vak(["Бизнес-информатика"], [])
|
||||
assert result["status"] == "found" and result["entries"][0]["category"] == "К1"
|
||||
assert result["entries"][0]["source_sha256"] and result["entries"][0]["categorized_on"]
|
||||
|
||||
|
||||
def test_all_catalog_systems_keep_their_own_metric_year_and_multiple_categories(monkeypatch):
|
||||
metrics = [{"year": year, "system": system, "quartile": quarter, "metric": metric,
|
||||
"category": category, "source": "Snapshot", "source_url": "https://example.org/source"}
|
||||
for year, system, quarter, metric, category in [(2024, "Scopus", "Q1", "CiteScore", "Analysis"),
|
||||
(2024, "Scopus", "Q2", "CiteScore", "Applied Mathematics"), (2025, "Web of Science", "Q3", "JCR", "Mathematics")]]
|
||||
monkeypatch.setattr("app.services.publication_quartiles.publication_catalog", lambda ids, names: ([{"name": "Journal", "metrics": metrics}], []))
|
||||
metadata = {"type": "journal-article", "years": [2024], "issns": ["0006-3835"]}
|
||||
result, status = catalog_entries(metadata, 2024, True)
|
||||
assert status == "found_latest" and len(result) == 3
|
||||
assert {(r["system"], r["quartile"], r["metric_year"]) for r in result} == {
|
||||
("Scopus", "Q1", 2024), ("Scopus", "Q2", 2024), ("Web of Science", "Q3", 2025)}
|
||||
|
||||
|
||||
def test_book_container_title_is_not_treated_as_a_vak_journal(monkeypatch, tmp_path):
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db import Base
|
||||
from app.models import Employee, EmployeePublication
|
||||
from scripts.enrich_publication_quartiles import main
|
||||
url = "sqlite:///" + str(tmp_path / "books.db")
|
||||
engine = create_engine(url)
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
employee = Employee(profile_key="test", canonical_url="https://example.org/person")
|
||||
session.add(employee)
|
||||
session.flush()
|
||||
session.add(EmployeePublication(employee_id=employee.id, title="Chapter", year=2024, publication_type="CHAPTER",
|
||||
source_hash="hash", doi_url="10.1234/chapter", raw_data={}))
|
||||
session.commit()
|
||||
monkeypatch.setenv("DATABASE_URL", url)
|
||||
monkeypatch.setattr("scripts.enrich_publication_quartiles.resolve_doi", lambda *args: {
|
||||
"status": "resolved", "type": "book-chapter", "journals": ["Бизнес-информатика"], "issns": [], "years": [2024]})
|
||||
monkeypatch.setattr("sys.argv", ["enrich", "--apply", "--lookup-doi", "--check-vak", "--all-systems",
|
||||
"--cache-dir", str(tmp_path / "cache"), "--report", str(tmp_path / "report.json")])
|
||||
main()
|
||||
with Session(engine) as session:
|
||||
publication = session.scalar(select(EmployeePublication))
|
||||
assert publication.vak_categories["status"] == "unknown_journal"
|
||||
assert publication.vak_categories["entries"] == [] and publication.quartiles["entries"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
({"journal": "Первый журнал", "issn": "1111-1111"}, "journal_identity_conflict"),
|
||||
({"issn": "0006-3835"}, "found"),
|
||||
({"eissn": "0006-3835"}, "found"),
|
||||
({"csv_enrichment": {"ISSN": "0006-3835"}}, "found"),
|
||||
])
|
||||
def test_cli_checks_original_issn_even_without_doi(index, monkeypatch, tmp_path, raw, expected):
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db import Base
|
||||
from app.models import Employee, EmployeePublication
|
||||
from scripts.enrich_publication_quartiles import main
|
||||
url = "sqlite:///" + str(tmp_path / "vak.db")
|
||||
engine = create_engine(url)
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
person = Employee(profile_key="test", canonical_url="https://example.org/person")
|
||||
session.add(person)
|
||||
session.flush()
|
||||
session.add(EmployeePublication(employee_id=person.id, title="Work", year=2024, source_hash="hash", raw_data=raw))
|
||||
session.commit()
|
||||
monkeypatch.setenv("DATABASE_URL", url)
|
||||
monkeypatch.setattr("sys.argv", ["enrich", "--apply", "--check-vak", "--cache-dir", str(tmp_path / "cache"),
|
||||
"--report", str(tmp_path / "report.json")])
|
||||
main()
|
||||
with Session(engine) as session:
|
||||
publication = session.scalar(select(EmployeePublication))
|
||||
assert publication.vak_categories["status"] == expected
|
||||
if expected == "found":
|
||||
assert publication.vak_categories["entries"][0]["identity_confirmed"]
|
||||
[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/журнала парсером при неизменном годе.