fix: keep council criteria tied to source publication years
CI / test (pull_request) Successful in 9m10s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped

This commit is contained in:
codex committed 2026-10-08 11:46:40 +03:00
1 parent 0af278247a
commit 30ea1c9257
5 files changed
+44 -15

No files matched your search

+20 -9
View File
@@ -163,27 +163,38 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
key = identifier or fallback
if key not in unique:
unique[key] = {**article, "doi": identifier,
**{field: list(article[field]) for field in ("types", "quartiles", "journals", "origins", "rows")}}
**{field: list(article[field]) for field in ("types", "quartiles", "journals", "origins", "rows")},
"evidence": []}
merged = unique[key]
merged["evidence"].append(article)
for field in ("types", "quartiles", "journals", "origins", "rows"):
merged[field] = list(dict.fromkeys([*merged[field], *article[field]]))
merged["in_csv"] |= article["in_csv"]
merged["in_db"] |= article["in_db"]
merged["url"] = merged["url"] or article["url"]
merged["year"] = merged["year"] or article["year"]
for article in unique.values():
year = article["year"]
evidence = article.pop("evidence")
years = sorted({item["year"] for item in evidence if item["year"] is not None})
# Never pick a conflicting source year merely because that row came first.
year = article["year"] = years[0] if len(years) == 1 else None
article["criteria"] = list(article["quartiles"])
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)
eligible = (year is not None and start.year <= year <= end.year
and (not article["types"] or article["types"] == ["Статья"]))
article["q1"] = eligible and any(q.endswith(" Q1") for q in article["quartiles"])
article["q2"] = eligible and any(q.endswith(" Q2") for q in article["quartiles"])
article["k1"] = eligible and bool(matches)
eligible_type = not article["types"] or article["types"] == ["Статья"]
article["q1"] = article["q2"] = article["k1"] = False
for item in evidence:
source_year = item["year"] if item["year"] is not None else year
if not eligible_type or source_year is None or not start.year <= source_year <= end.year:
continue
article["q1"] |= any(q.endswith(" Q1") for q in item["quartiles"])
article["q2"] |= any(q.endswith(" Q2") for q in item["quartiles"])
article["k1"] |= any(_journal_key(name) in k1_journals for name in item["journals"])
article["qualifies"] = article["q1"] or article["q2"] or article["k1"]
if year is None:
if len(years) > 1:
article["criteria"].append("Годы источников расходятся: " + ", ".join(map(str, years))
+ "; критерии учитываются по году каждой исходной записи")
elif year is None:
article["criteria"].append("Год не указан — период не подтверждён")
elif start.year <= year <= end.year:
article["criteria"].append("В пределах пяти лет по году; точную дату нужно проверить" if year in {start.year, end.year}
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "0.14.0"
FRONTEND_VERSION = "0.14.0"
BACKEND_VERSION = "0.14.0"
APP_VERSION = "0.14.1"
FRONTEND_VERSION = "0.14.1"
BACKEND_VERSION = "0.14.1"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "0.14.0"
version = "0.14.1"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+1 -1
View File
@@ -18,7 +18,7 @@ def test_health_returns_versions():
response = TestClient(app).get("/api/health")
assert response.status_code == 200
assert response.json()["backend_version"] == "0.14.0"
assert response.json()["backend_version"] == "0.14.1"
def test_api_employees_and_stats_require_admin_session():
+19 -1
View File
@@ -211,7 +211,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
assert "&lt;script&gt;" in response.text and "<script>" not in response.text
assert "javascript:" not in response.text and "private@example.org" not in response.text
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
assert "Backend 0.14.0 · Frontend 0.14.0" in response.text
assert "Backend 0.14.1 · Frontend 0.14.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
@@ -350,6 +350,24 @@ def test_k1_reference_integrity():
assert "бизнес информатика" in council._k1_journals()
@pytest.mark.parametrize("reverse", [False, True])
def test_conflicting_source_years_keep_categories_with_their_year(db_session, export, reverse):
rows = [row(year="2020", **{"Название журнала": "Бизнес-информатика"}),
row(year="2024", **{"Квартиль scopus": "Q2"})]
export(list(reversed(rows)) if reverse else rows)
person = employee(db_session)
# Evidence with no year must not inherit an arbitrary conflicting year.
publication(db_session, person, doi="https://doi.org/10.1234/shared", year=None,
raw_data={"csv_enrichment": {"Квартиль scopus": "Q1", "Журнал": "Бизнес-информатика"}})
db_session.commit()
candidate = council.council_candidates(db_session, date(2026, 10, 8))["candidates"][0]
assert [candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "total")] == [0, 1, 0, 1]
assert candidate["count"] == 1
article = candidate["articles"][0]
assert article["year"] is None
assert any("Годы источников расходятся: 2020, 2024" in criterion for criterion in article["criteria"])
@pytest.mark.parametrize("sort", ["q1_count", "q2_count", "k1_count", "total_count"])
@pytest.mark.parametrize("direction", ["asc", "desc"])
def test_sort_category_counters_and_xlsx(db_session, export, sort, direction):