diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index e97eba3..4cda449 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -28,6 +28,7 @@ jobs:
- run: python -m pip install -r requirements.txt
- run: python -m pytest -q
- run: node --check app/static/admin.js
+ - run: node --check app/static/council.js
- run: sh -n infra/postgres-backup.sh
- run: sh -n infra/install-postgres-backup-cron.sh
diff --git a/README.md b/README.md
index c40a8a7..ffcf194 100644
--- a/README.md
+++ b/README.md
@@ -90,17 +90,29 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
Страница `/admin/council` доступна без авторизации. Показывает докторов, кандидатов наук и PhD, включая сотрудников без доступных публикаций; уволенные отмечены отдельно. Высшая степень выбирается по всей истории: доктор выше кандидата/PhD, кандидат и PhD имеют одинаковый уровень. Неподтверждённого доктора нельзя заменить подтверждённым кандидатом. Звание профессора или доцента не заменяет степень.
-Страница и XLSX настроены на технические науки: пункт 5 — 9 публикаций для доктора; пункт 6 — 20 для кандидата/PhD, самостоятельного К2 недостаточно. Период — пять лет относительно текущей даты Москвы. Дата ходатайства и сведения о монографиях не отображаются и не препятствуют итогу по техническим наукам. Некорректную специальность отклоняет HTTP 422.
+На странице и в XLSX выбирается отрасль оценки; по умолчанию технические науки. Для доктора порог зависит от отрасли, для кандидата/PhD — 20; самостоятельного К2 недостаточно. Период — пять календарных лет, включая текущий год Москвы. Некорректную специальность или отрасль отклоняет HTTP 422.
Отдельно показаны предварительные Q1/Q2/К1/К2/RSCI, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет.
+Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Нажатие на карточку статьи (или Enter/пробел с клавиатуры) показывает иконку редактирования; она открывает форму названия статьи, журнала и квартиля. Та же форма доступна в карточках основного списка. Для сохранения требуется вход администратора.
+
+Ручные изменения хранятся в отдельной таблице `council_corrections`, создаваемой при запуске приложения (`migrations/012_council_corrections.sql` для ручного обновления схемы). Исходные публикации и файлы выгрузки не перезаписываются. Квартиль применяется к выбранному журналу за год статьи, в том числе к другим статьям и сотрудникам; при неизвестном или конфликтующем годе можно исправить только название и журнал. Правки учитываются в фильтрах, предварительных счётчиках и XLSX. Ручные изменения журнала или квартиля требуют подтверждения источниками; изменение тематической связи само по себе не отменяет проверенный квартиль. Одновременное сохранение из устаревшей вкладки отклоняется: нужно обновить страницу. Привязка статьи использует исходный DOI или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново.
+
+Расчёт использует пять календарных лет, включая текущий год Москвы: в 2026 году — 2022–2026. Точные день и месяц для периода не нужны; конфликт годов остаётся причиной проверки. В форме фильтров выбирается отрасль оценки, которая задаёт порог для доктора (9 для технических, 11 для физико-математических и т. д.); для кандидата/PhD минимум 20. Выбранная отрасль сохраняется при переходах страниц и выгрузке XLSX, а все пороги вынесены в пояснения над списком.
+
+Статья с известным видом материала, журналом и квартилем Q1/Q2 из источника, применимым к году публикации, автоматически входит в «Подтверждено», если год входит в период и нет конфликтов идентичности, вида и годов. Неизвестный вид, отсутствующий журнал, квартиль другого года или ручная замена квартиля без источника не дают автоматического зачёта. Подтверждение степени и рассмотрение содержания статьи по специальности оцениваются отдельно.
+
+Редактор позволяет заменить связь журнала с одной или несколькими из доступных специальностей. Ручная связь применяется ко всем статьям журнала независимо от года; в фильтр попадают допустимые публикации за календарный период. Пустой ручной список исключает связь, переключатель «Использовать связь из справочников» восстанавливает справочные данные. Связь тематики журнала не заменяет рассмотрение содержания статьи. Ручные специальности сохраняются в той же таблице, в разделе `specialties`; дополнительная миграция не нужна.
+
Сервис читает подготовленный `app/data/council_articles.json` и таблицу `employee_publications`. Справочник преобразуется из CSV скриптом `scripts/prepare_council_articles.py`, проверяет схему и индекс авторов; импорт/парсинг CSV не объявляется доказательством выполнения критериев. Сопоставление данных ВШЭ по полному ФИО разрешено только при единственном совпадении. Дедупликация использует DOI либо нормализованные название и год при отсутствии неоднозначных DOI. Источники с разными годами не переносят категории между периодами.
К1/К2 сопоставляются по точным нормализованным названиям журнала и документированным алиасам; название статьи, переводы и нечёткое совпадение не используются. Справочник К1 содержит 773 журнала, К2 — 1539; экстрактор проверяет все 3134 строки, даты и SHA-256 источника. Это текущие категории, а не историческое доказательство. Q1/Q2 читаются также из двух тематических JSON-каталогов (схемы 1.0.0/1.1.0, 229 уникальных журналов): ISSN/eISSN, категории CiteScore/JCR, источники и версии паспортов. Снимок метрик 2025 не распространяется автоматически на другие годы; текущая индексация не проверена. Каталоги не являются белым списком. Аналитические прямые/условные связи журнала показываются отдельно от рассмотрения статьи по паспорту.
-Подтверждения хранятся отдельно от перезаписываемых парсером данных, в `app/data/council_evidence.json`; по умолчанию файл пустой. Формат, привязка к сотруднику/работе, правила конфликта, монографии и нормативные уточнения: [docs/council-evidence.md](docs/council-evidence.md). Непубличные документы и контакты на страницу не выводятся. РИНЦ или поле `hse_list` не подтверждают RSCI. Неизвестные виды, пограничные даты и неподтверждённые основания не создают положительного заключения.
+Подтверждения хранятся отдельно от перезаписываемых парсером данных, в `app/data/council_evidence.json`; по умолчанию файл пустой. Формат, привязка к сотруднику/работе, правила конфликта, монографии и нормативные уточнения: [docs/council-evidence.md](docs/council-evidence.md). Непубличные документы и контакты на страницу не выводятся. РИНЦ или поле `hse_list` не подтверждают RSCI. Неизвестные виды, конфликтующие годы и неподтверждённые основания не создают положительного заключения.
-Доступны поиск, сортировка, страницы по 25 сотрудников и XLSX всех найденных сотрудников. Дата, отрасль и остальные параметры сохраняются при переходах и экспорте. Листы «Сотрудники», «Публикации» и «Параметры» используют тот же расчёт, включают причины проверки и источники; внешние ссылки проверяются, строки не выполняются как формулы Excel. Фильтр специальности по журналам К1 остаётся отдельным вспомогательным подбором с ограниченным охватом, а не условием соответствия пунктам 5–6.
+Доступны поиск, сортировка, страницы по 25 сотрудников и XLSX всех найденных сотрудников. Отрасль и остальные параметры сохраняются при переходах и экспорте. XLSX содержит компактные листы «Сотрудники» (12 столбцов) и «Публикации» (14 столбцов), а правила и выбранные фильтры вынесены в «Параметры»; внешние ссылки проверяются, строки не выполняются как формулы Excel. Фильтр специальности по журналам К1 и ручным тематическим связям остаётся отдельным вспомогательным подбором с ограниченным охватом, а не условием соответствия пунктам 5–6.
+
+В XLSX «Научная степень» — текущая выбранная степень, а «Всего» — уникальное объединение Q1 ИЛИ Q2 ИЛИ К1; отдельные К2/RSCI не увеличивают этот столбец. Поэтому экспортный итог может отличаться от общего счётчика сайта. «Всего из ЛК» и «Общее количество всех публикаций без дублей» учитывают все годы. Отсутствующий тип материала обозначается «не указан», положительное сообщение о периоде объединяется в «В пределах пяти лет». Версия 1.0.0 меняет набор и порядок столбцов несовместимо с прежними шаблонами обработки XLSX.
После изменения JSON/PDF пересоберите образ и пересоздайте API: данные включены в образ и кешируются в процессе.
@@ -174,8 +186,7 @@ node --check app/static/admin.js
сохраняют прежний смысл; число работ по выбранной специальности указано внутри
карточки сотрудника. Фильтр сохраняется при поиске, смене страницы и экспорте XLSX.
На листах «Сотрудники» и «Публикации» XLSX отдельная колонка содержит коды и названия
-подтверждённых специальностей, включая выгрузку без фильтра. При выбранном фильтре
-добавлены число подходящих работ сотрудника и отметка «Да/Нет» для каждой публикации.
+подтверждённых специальностей, включая выгрузку без фильтра. Число подходящих работ сотрудника и отметка «Да/Нет» для каждой публикации в компактный XLSX не включаются.
`app/data/k1_specialties.json` содержит все 773 журнала из `k1_journals.json`, коды
действующих специальностей, ISSN, страницы и строки исходного перечня, историю
diff --git a/app/admin.py b/app/admin.py
index 66ce9df..1b9857c 100644
--- a/app/admin.py
+++ b/app/admin.py
@@ -1,14 +1,16 @@
-from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
+from copy import deepcopy
+from pydantic import BaseModel, Field, ConfigDict
+from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
-from sqlalchemy import desc, func, select
-from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy import desc, func, select, update
+from sqlalchemy.exc import SQLAlchemyError, IntegrityError
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.db import SessionLocal, get_db
-from app.models import CrawlError, CrawlRun, Employee
-from app.security import SESSION_COOKIE, require_admin, sign_session, verify_admin
+from app.models import CrawlError, CrawlRun, Employee, CouncilCorrections
+from app.security import SESSION_COOKIE, require_admin, sign_session, verify_admin, read_session
from app.services.admin_data import (
employee_detail_payload,
format_admin_datetime,
@@ -19,7 +21,7 @@ from app.services.admin_data import (
)
from app.services.crawl_control import get_running_run, run_crawl_if_idle
from app.services.crawler import refresh_dismissed_status, refresh_employee
-from app.services.council import CouncilInputError, council_candidates
+from app.services.council import CouncilInputError, council_candidates, _journal_key
from app.services.council_export import council_xlsx
from app.version import BACKEND_VERSION, FRONTEND_VERSION
@@ -135,25 +137,113 @@ def directory(
@router.get("/council", response_class=HTMLResponse)
-def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", specialty: str = "",
- db: Session = Depends(get_db)):
+def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", specialty: str = "", branch: str = "технические",
+ db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
try:
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty,
- branch="технические")
+ branch=branch)
except CouncilInputError as exc:
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503)
- return _render(request, "council.html", {"error": False, "public_page": True, **result})
+ return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": read_session(request.cookies.get(SESSION_COOKIE), settings) == settings.admin_username, **result})
+
+
+class CouncilArticleEdit(BaseModel):
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid", strict=True)
+ employee_id: int = Field(gt=0)
+ edit_key: str = Field(pattern=r"^[a-f0-9]{64}$")
+ revision: int = Field(ge=0)
+ title: str = Field(min_length=1, max_length=2000)
+ journal: str = Field(min_length=1, max_length=500)
+ quartile: str = Field(pattern=r"^(Q[1-4])?$")
+ specialty_codes: list[str] | None = Field(default=None, max_length=12)
+
+
+@router.get("/council/publications", response_class=HTMLResponse)
+def council_publications(request: Request, employee: str = "", title: str = "", journal: str = "", offset: int = 0,
+ db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
+ context = {"public_page": True, "employee": employee, "title": title, "journal": journal,
+ "can_edit": read_session(request.cookies.get(SESSION_COOKIE), settings) == settings.admin_username}
+ try:
+ result = council_candidates(db, paginate=False, sort="full_name", direction="asc")
+ articles = [{**article, "employee_id": candidate["employee_id"], "employee_name": candidate["name"]}
+ for candidate in result["candidates"] for article in candidate["articles"]
+ if employee.casefold().strip() in candidate["name"].casefold()
+ and title.casefold().strip() in article["title"].casefold()
+ and journal.casefold().strip() in " ".join(article["journals"]).casefold()]
+ total, limit = len(articles), 50
+ offset = min(max(0, offset), ((total - 1) // limit) * limit) if total else 0
+ return _render(request, "council_publications.html", {**context, "error": False,
+ "articles": articles[offset:offset + limit], "matched": total, "offset": offset, "limit": limit,
+ "revision": result["revision"], "journal_names": result["journal_names"], "specialties": result["specialties"]})
+ except (SQLAlchemyError, OSError, ValueError, KeyError):
+ db.rollback()
+ return _render(request, "council_publications.html", {**context, "error": True}, status_code=503)
+
+
+@router.post("/council/publications")
+def edit_council_publication(request: Request, change: CouncilArticleEdit,
+ db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
+ username = read_session(request.cookies.get(SESSION_COOKIE), settings)
+ if username != settings.admin_username:
+ raise HTTPException(401, "Для сохранения войдите в админку")
+ # A custom header plus JSON prevents cross-site form submissions; CORS is disabled.
+ if request.headers.get("X-Council-Edit") != "1":
+ raise HTTPException(403, "Недопустимый запрос на изменение")
+ if not _journal_key(change.journal):
+ raise HTTPException(422, "Укажите название журнала")
+ try:
+ result = council_candidates(db, paginate=False)
+ article = next((a for c in result["candidates"] if c["employee_id"] == change.employee_id
+ for a in c["articles"] if a["edit_key"] == change.edit_key), None)
+ if not article:
+ raise HTTPException(404, "Статья не найдена. Обновите страницу")
+ if article["year"] is None and change.quartile:
+ raise HTTPException(422, "Для квартиля необходим однозначный год публикации")
+ if change.specialty_codes is not None and not set(change.specialty_codes) <= {item["code"] for item in result["specialties"]}:
+ raise HTTPException(422, "Неизвестная специальность ВАК")
+ record = db.get(CouncilCorrections, 1)
+ revision = record.revision if record else 0
+ if revision != change.revision:
+ raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
+ data = deepcopy(record.data) if record else {"articles": {}, "journals": {}}
+ if change.title != article["title"] or change.journal != article["edit_journal"]:
+ data["articles"][f"{change.employee_id}:{change.edit_key}"] = {"title": change.title, "journal": change.journal}
+ specialty_data = data.setdefault("specialties", {})
+ if "specialty_codes" in change.model_fields_set:
+ if change.specialty_codes is None:
+ specialty_data.pop(_journal_key(change.journal), None)
+ else:
+ specialty_data[_journal_key(change.journal)] = sorted(set(change.specialty_codes))
+ if article["year"] is not None and (change.quartile != article["edit_quartile"] or change.journal != article["edit_journal"]):
+ data["journals"][f"{_journal_key(change.journal)}:{article['year']}"] = change.quartile
+ if record:
+ count = db.execute(update(CouncilCorrections).where(CouncilCorrections.id == 1,
+ CouncilCorrections.revision == change.revision).values(data=data, revision=revision + 1,
+ updated_by=username)).rowcount
+ if not count:
+ db.rollback()
+ raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
+ else:
+ db.add(CouncilCorrections(id=1, data=data, revision=1, updated_by=username))
+ db.commit()
+ except IntegrityError:
+ db.rollback()
+ raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
+ except (SQLAlchemyError, OSError, ValueError, KeyError):
+ db.rollback()
+ raise HTTPException(503, "Не удалось сохранить изменения. Попробуйте ещё раз")
+ return {"saved": True}
@router.get("/council/export.xlsx")
-def export_council(request: Request, q: str = "", sort: str = "total_count", direction: str = "desc", specialty: str = "",
+def export_council(request: Request, q: str = "", sort: str = "total_count", direction: str = "desc", specialty: str = "", branch: str = "технические",
db: Session = Depends(get_db)):
try:
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty,
- branch="технические")
+ branch=branch)
content = council_xlsx(result)
except CouncilInputError as exc:
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
diff --git a/app/models.py b/app/models.py
index 0ac7b1c..572f9f1 100644
--- a/app/models.py
+++ b/app/models.py
@@ -243,3 +243,14 @@ class ParseResourceCache(Base):
body_snapshot: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
parser_version: Mapped[str | None] = mapped_column(String(32))
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
+
+
+class CouncilCorrections(Base):
+ """Manual council data, outside crawler-owned publications."""
+ __tablename__ = "council_corrections"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ revision: Mapped[int] = mapped_column(Integer, nullable=False)
+ data: Mapped[dict] = mapped_column(json_type, nullable=False)
+ updated_by: Mapped[str] = mapped_column(String(255), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)
diff --git a/app/services/council.py b/app/services/council.py
index ad1f3cf..27fda30 100644
--- a/app/services/council.py
+++ b/app/services/council.py
@@ -1,6 +1,5 @@
"""Public publication evidence from the council export and employee database."""
-import calendar
import hashlib
import json
import re
@@ -14,11 +13,11 @@ from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
-from app.models import Employee
+from app.models import Employee, CouncilCorrections
from app.services.academic_degrees import academic_degrees
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.publication_quartiles import qualifying_entries, 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,
@@ -50,8 +49,7 @@ def _safe_url(value: str | None) -> str | None:
def period(today: date) -> tuple[date, date]:
- year = today.year - 5
- return date(year, today.month, min(today.day, calendar.monthrange(year, today.month)[1])), today
+ return date(today.year - 4, 1, 1), date(today.year, 12, 31)
def _journal_key(value: str) -> str:
@@ -122,10 +120,12 @@ def _database_article(publication) -> dict:
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]))
+ quartile_entries = [*qualifying_entries(publication.quartiles, publication.year, doi, current_journals, identifiers),
+ *qualifying_entries(imported_quartiles, publication.year, doi, current_journals, identifiers)]
return {"title": publication.title, "year": publication.year, "doi": _doi(publication.doi_url or ""),
"types": [TYPES.get(kind.upper(), f"Другой тип: {kind}")] if kind else [],
- "quartiles": list(dict.fromkeys([*quartile_labels(publication.quartiles, publication.year, doi, current_journals, identifiers),
- *quartile_labels(imported_quartiles, publication.year, doi, current_journals, identifiers)])),
+ "quartiles": list(dict.fromkeys(f"{item['system']} {item['quartile']}" for item in quartile_entries)),
+ "quartile_entries": quartile_entries,
"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],
@@ -133,7 +133,7 @@ def _database_article(publication) -> dict:
"url": _safe_url(publication.url)}
-def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]:
+def _unique_articles(articles: list[dict], start: date, end: date, corrections=None, employee_id=None) -> list[dict]:
k1_journals = _k1_journals()
specialties = _k1_specialties()["by_journal"]
title_years = defaultdict(set)
@@ -171,12 +171,41 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
merged["in_csv"] |= article["in_csv"]
merged["in_db"] |= article["in_db"]
merged["url"] = merged["url"] or article["url"]
- for article in unique.values():
+ for identity, article in unique.items():
evidence = article.pop("evidence")
+ article["edit_key"] = hashlib.sha256(json.dumps(
+ identity,
+ ensure_ascii=False).encode()).hexdigest()
+ correction = (corrections or {}).get("articles", {}).get(f"{employee_id}:{article['edit_key']}")
+ article["manually_corrected"] = bool(correction)
+ article["journal_corrected"] = bool(correction and article["journals"] != [correction["journal"]])
+ if correction:
+ journal_changed = article["journal_corrected"]
+ article["title"] = correction["title"]
+ article["journals"] = [correction["journal"]]
+ if journal_changed:
+ article["issns"] = []
+ article["quartiles"] = []
+ article["quartile_issues"] = []
+ evidence = [{**item, "journals": article["journals"],
+ "quartiles": [] if journal_changed else item["quartiles"]} for item in 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["source_years"] = years
+ article["automatic_quartile_bases"] = []
+ if len(article["journals"]) == 1 and not article["quartile_issues"] and not article["journal_corrected"]:
+ for item in evidence:
+ if item["year"] != year:
+ continue
+ details = item.get("quartile_entries")
+ if details is None:
+ # Prepared CSV evidence has no category or metric metadata.
+ details = [{"quartile": label[-2:], "system": label[:-3],
+ "source": " / ".join(item["origins"]), "source_url": item.get("url")}
+ for label in item["quartiles"] if label.endswith((" Q1", " Q2"))]
+ article["automatic_quartile_bases"].extend(
+ {**entry, "basis": entry["quartile"], "applicable_year": year} for entry in details)
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))
@@ -209,10 +238,9 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
elif year is None:
article["criteria"].append("Год не указан — период не подтверждён")
elif start.year <= year <= end.year:
- article["criteria"].append("В пределах пяти лет по году; точную дату нужно проверить" if year in {start.year, end.year}
- else "В пределах пяти лет")
+ article["criteria"].append(f"В пределах пятилетнего периода {start.year}–{end.year}")
else:
- article["criteria"].append("Вне пятилетнего периода")
+ article["criteria"].append(f"Вне пятилетнего периода {start.year}–{end.year}")
if not article["quartiles"] and not matches:
article["criteria"].append("Q1/Q2 и К1 не подтверждены доступными данными")
article["doi_url"] = f"https://doi.org/{quote(article['doi'], safe='/')}" if article["doi"] else None
@@ -237,6 +265,9 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
name_counts = Counter(_name(employee.full_name or "") for employee in employees)
by_author = _articles_by_author()
verified = evidence()
+ corrections_record = db.get(CouncilCorrections, 1)
+ corrections = corrections_record.data if corrections_record else {}
+ revision = corrections_record.revision if corrections_record else 0
candidates = []
for employee in employees:
author = _name(employee.full_name or "")
@@ -250,9 +281,11 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
employee_evidence = verified["employees"].get(employee.profile_key, {})
if not any(degree_level(degree) for degree in degrees) and not employee_evidence.get("degrees"):
continue
- articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end)
+ articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end, corrections, employee.id)
for article in articles:
- proofs = publication_proofs(employee_evidence, article)
+ article["manual_review"] = article["journal_corrected"] or (len(article["journals"]) == 1 and
+ f"{_journal_key(article['journals'][0])}:{article['year']}" in corrections.get("journals", {}))
+ proofs = [] if article["manual_review"] else publication_proofs(employee_evidence, article)
kinds = {proof["kind"] for proof in proofs if proof.get("kind") and sourced(proof.get("kind_evidence"))}
kind = next(iter(kinds)) if len(kinds) == 1 else None
preliminary_type_allowed = len(kinds) <= 1 and publication_type_allowed(
@@ -260,7 +293,7 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
if not preliminary_type_allowed:
for flag in ("q1", "q2", "k1", "k2", "rsci"):
article[flag] = False
- identifiers = [*article["issns"], *(value for proof in proofs for value in proof.get("issns", []))]
+ identifiers = [] if article["journal_corrected"] else [*article["issns"], *(value for proof in proofs for value in proof.get("issns", []))]
for name in article["journals"]:
canonical = _k1_journals().get(_journal_key(name))
identifiers.extend(_k1_specialties()["journal_issns"].get(canonical, []))
@@ -278,13 +311,57 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
key = {"К1": "k1", "К2": "k2", "Q1": "q1", "Q2": "q2", "RSCI": "rsci"}.get(basis.get("basis"))
if key:
article[key] = True
- assessment = assess_candidate(degrees, articles, employee_evidence, branch, specialty, start, end)
+ if not article["catalog_issues"] and not article["journal_corrected"]:
+ article["automatic_quartile_bases"].extend({"basis": metric["quartile"], "system": metric["system"],
+ "metric": metric["metric"], "category": metric["category"], "metric_year": metric["year"],
+ "applicable_year": article["year"], "source": metric["source"], "source_url": metric["source_url"]}
+ for record in article["catalog_records"] for metric in record["metrics"]
+ if article["year"] == metric["year"] and metric["quartile"] in {"Q1", "Q2"})
+ journal_key = _journal_key(article["journals"][0]) if len(article["journals"]) == 1 else ""
+ manual_quartile = corrections.get("journals", {}).get(f"{journal_key}:{article['year']}")
+ if manual_quartile is not None:
+ article["quartiles"] = [f"Ручная корректировка {manual_quartile}"] if manual_quartile else []
+ article["q1"] = bool(manual_quartile == "Q1" and preliminary_type_allowed and article["year"] is not None and start.year <= article["year"] <= end.year)
+ article["q2"] = bool(manual_quartile == "Q2" and preliminary_type_allowed and article["year"] is not None and start.year <= article["year"] <= end.year)
+ article["criteria"] = [c for c in article["criteria"] if not c.endswith((" Q1", " Q2", " Q3", " Q4")) and c != "Q1/Q2 и К1 не подтверждены доступными данными"]
+ article["criteria"].append(f"Квартиль журнала за {article['year']}: {manual_quartile or 'не указан'} · ручная корректировка, требует подтверждения")
+ if article["manual_review"]:
+ article["automatic_quartile_bases"] = []
+ manual_specialties = corrections.get("specialties", {}).get(journal_key) if journal_key else None
+ article["manual_specialties"] = manual_specialties is not None
+ default_specialties = sorted(set().union(
+ *(_k1_specialties()["by_journal"].get(_k1_journals().get(_journal_key(name)), []) for name in article["journals"]),
+ *(record["specialty_codes"] for record in article["catalog_records"])))
+ article["edit_specialty_codes"] = manual_specialties if manual_specialties is not None else default_specialties
+ if manual_specialties is not None:
+ eligible = preliminary_type_allowed and article["year"] is not None and start.year <= article["year"] <= end.year
+ article["specialty_codes"] = manual_specialties if eligible else []
+ for record in article["catalog_records"]:
+ record["thematic"] = (["Связь журнала со специальностью: ручная корректировка"] if specialty in manual_specialties
+ else ["Связь журнала с выбранной специальностью исключена вручную"] if specialty else [])
+ article["qualifies"] = article["q1"] or article["q2"] or article["k1"]
+ labels = {q[-2:] for q in article["quartiles"] if q.endswith((" Q1", " Q2", " Q3", " Q4"))}
+ if manual_quartile is None:
+ labels.update(metric["quartile"] for record in article["catalog_records"] for metric in record["metrics"]
+ if metric["year"] == article["year"] and metric["quartile"] in {"Q1", "Q2", "Q3", "Q4"})
+ labels.update(basis["basis"] for proof in proofs for basis in proof.get("bases", [])
+ if isinstance(basis, dict) and basis.get("applicable_year") == article["year"] and basis.get("basis") in {"Q1", "Q2", "Q3", "Q4"})
+ article["edit_quartile"] = manual_quartile if manual_quartile is not None else next(iter(labels)) if len(labels) == 1 else ""
+ article["edit_journal"] = article["journals"][0] if len(article["journals"]) == 1 else ""
+ changed = [a for a in articles if a["manual_review"]]
+ adjusted_evidence = {**employee_evidence, "publications": [p for p in employee_evidence.get("publications", [])
+ if not any((a["doi"] and p.get("doi") == a["doi"]) or p.get("source_hash") in a["source_hashes"] for a in changed)]}
+ assessment = assess_candidate(degrees, articles, adjusted_evidence, branch, specialty, start, end)
+ for article in changed:
+ article["confirmation_reasons"].append("Ручная корректировка требует повторного подтверждения источниками")
specialty_count = sum(specialty in a["specialty_codes"] for a in articles) if specialty else 0
for article in articles:
article["matches_specialty"] = bool(specialty and specialty in article["specialty_codes"])
if article["matches_specialty"]:
- article["criteria"].append(f"По специальности {specialty}: {specialty_name} (журнал К1, Перечень ВАК)")
- candidates.append({**assessment, "specialty_count": specialty_count,"name": employee.full_name or "ФИО не указано",
+ article["criteria"].append(f"По специальности {specialty}: {specialty_name} ({'ручная связь журнала' if article['manual_specialties'] else 'журнал К1, Перечень ВАК'})")
+ for article in articles:
+ article["messages"] = list(dict.fromkeys([*article["criteria"], *article["confirmation_reasons"]]))
+ candidates.append({**assessment, "specialty_count": specialty_count,"name": employee.full_name or "ФИО не указано", "employee_id": employee.id,
"count": len(articles),
**{f"{key}_count": sum(a[key] for a in articles) for key in ("q1", "q2", "k1", "k2", "rsci")},
"total_count": sum(a["qualifies"] for a in articles),
@@ -303,10 +380,14 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
matched = len(candidates)
limit = 25
offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0
- return {"candidates": candidates[offset:offset + limit] if paginate else candidates,
+ criteria_groups = {}
+ for name, (minimum, monographs) in BRANCHES.items():
+ criteria_groups.setdefault((minimum, monographs), []).append(name)
+ return {"criteria_groups": [{"names": ", ".join(names), "minimum": minimum, "monographs": monographs}
+ for (minimum, monographs), names in criteria_groups.items()], "journal_names": sorted({name for c in candidates for a in c["articles"] for name in a["journals"]}), "revision": revision, "candidates": candidates[offset:offset + limit] if paginate else candidates,
"start": start, "end": end, "total": total,
"specialties": specialties, "specialty": specialty, "specialty_name": specialty_name,
- "branch": branch,
+ "branch": branch, "branches": list(BRANCHES),
"catalog_metadata": catalog()["metadata"], "catalog_issues": catalog()["issues"],
"category_metadata": [{"file": path.name, **{key: data[key] for key in ("source", "source_sha256", "source_rows")}}
for path in (K1_JOURNALS, K2_JOURNALS)
diff --git a/app/services/council_assessment.py b/app/services/council_assessment.py
index b6acd05..61c5078 100644
--- a/app/services/council_assessment.py
+++ b/app/services/council_assessment.py
@@ -129,34 +129,29 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy
reasons = [*article.get("catalog_issues", []), *article.get("quartile_issues", [])]
years = article["source_years"]
dates = set()
- date_conflict = False
for proof in proofs:
- if proof.get("publication_date"):
+ if proof.get("publication_date") and sourced(proof.get("date_evidence")):
try:
- value = iso_date(proof["publication_date"])
- if sourced(proof.get("date_evidence")):
- dates.add(value)
- else:
- reasons.append("Источник точной даты не подтверждён")
+ dates.add(iso_date(proof["publication_date"]))
except ValueError:
- reasons.append("Некорректная точная дата")
- date_conflict = True
- if len(years) > 1 or len(dates) > 1 or (dates and years and next(iter(dates)).year not in years):
- reasons.append("Даты публикации в источниках расходятся")
- date_conflict = True
+ pass # Exact dates are optional; the calendar-year window uses the publication year.
+ date_years = {value.year for value in dates}
+ date_conflict = len(years) > 1 or len(date_years) > 1 or bool(date_years and years and not date_years <= set(years))
+ if date_conflict:
+ reasons.append("Годы публикации в источниках расходятся")
exact = next(iter(dates)) if len(dates) == 1 else None
- in_period = start < exact < end if exact else len(years) == 1 and start.year < years[0] < end.year
- if exact and exact in {start, end}:
- boundary = policy.get("boundary_dates", {})
- in_period = sourced(boundary) and boundary.get("value") == "inclusive"
- if not in_period:
- reasons.append("Включение граничной даты не подтверждено")
+ publication_year = article["year"] if article["year"] is not None else next(iter(date_years)) if len(date_years) == 1 else None
+ in_period = publication_year is not None and start.year <= publication_year <= end.year
if not in_period:
- reasons.append("Пятилетний период не подтверждён")
+ reasons.append(f"Вне пятилетнего периода {start.year}–{end.year}" if publication_year is not None
+ else "Год не указан — период не подтверждён")
types = {proof["kind"] for proof in proofs if proof.get("kind") and sourced(proof.get("kind_evidence"))}
- if len(types) != 1:
+ automatic_bases = [b for b in article.get("automatic_quartile_bases", [])
+ if b["applicable_year"] == (exact.year if exact else article["year"])
+ and not article.get("catalog_issues") and not article.get("quartile_issues")]
+ if len(types) != 1 and not (not types and article["types"] == ["Статья"] and automatic_bases):
reasons.append("Вид материала не подтверждён или расходится")
- kind = next(iter(types)) if len(types) == 1 else None
+ kind = next(iter(types)) if len(types) == 1 else "article" if not types and article["types"] == ["Статья"] and automatic_bases else None
type_conflict = len(types) > 1 or bool(article["types"] and kind and article["types"] != [TYPE_LABELS.get(kind)])
if type_conflict:
reasons.append("Виды материала в источниках расходятся")
@@ -180,13 +175,15 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy
bases.append(basis)
else:
reasons.append("Источник или год применимости основания не подтверждены")
+ if kind == "article":
+ bases.extend(automatic_bases)
# Different categories may legitimately have different quartiles; conflicting
# claims for the same system/category/year must not be resolved by ordering.
claims = {}
basis_conflict = False
for basis in bases:
key = ("ВАК", basis_year) if basis["basis"] in {"К1", "К2"} else (
- basis.get("system", basis["basis"]), basis.get("category"), basis.get("metric_year"), basis_year)
+ basis.get("system", basis["basis"]), basis.get("category"), basis.get("metric"), basis.get("metric_year"), basis_year)
if key in claims and claims[key] != basis["basis"]:
reasons.append("Подтверждённые основания источников расходятся")
basis_conflict = True
@@ -196,7 +193,7 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy
reasons.append("Нет подтверждённого допустимого основания выбранного пункта")
# Missing optional evidence is not a veto on another sufficient independent
# basis. Identity/date/type conflicts, however, invalidate the whole work.
- identity_conflict = any("расход" in reason or "идентичности" in reason or "разные журналы" in reason
+ identity_conflict = bool(article.get("quartile_issues")) or any("расход" in reason or "идентичности" in reason or "разные журналы" in reason
for reason in article.get("catalog_issues", []))
if article.get("proof_identity_conflict"):
identity_conflict = True
diff --git a/app/services/council_catalog.py b/app/services/council_catalog.py
index d859fa2..8fe9b98 100644
--- a/app/services/council_catalog.py
+++ b/app/services/council_catalog.py
@@ -166,6 +166,7 @@ def publication_catalog(identifiers, names, specialty=""):
output.append({"id": record["id"], "name": record["name"], "issn": record.get("issn"), "eissn": record.get("eissn"),
"catalog": record["catalog"], "metrics": record["metrics"], "thematic": thematic,
"match_level": link["match_level"] if link else None,
+ "specialty_codes": list(record["links"]),
"passport_urls": [safe_url(data["passports"][r["passport_id"]]["source_url"]) for r in link["passport_references"]] if link else []})
# A conflict is preserved; it never resolves by load order.
if len(records) > 1:
diff --git a/app/services/council_export.py b/app/services/council_export.py
index 7564c86..2af1815 100644
--- a/app/services/council_export.py
+++ b/app/services/council_export.py
@@ -1,4 +1,4 @@
-"""XLSX report containing the same public evidence as the council page."""
+"""Compact XLSX report from council data, with an explicit Q1/Q2/K1 total."""
from io import BytesIO
@@ -26,66 +26,56 @@ def council_xlsx(result: dict) -> bytes:
def specialties_text(codes):
return "\n".join(label for code, label in specialty_labels.items() if code in codes) or "Не подтверждены"
- _append(employees, ["№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "ЛК сотрудника ВШЭ", "Все публикации",
- "Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности",
- "Текущая степень", "Степень подтверждена", "Пункт", "Отрасль оценки", "Минимум",
- "Подтверждено", "К2 предварительно", "RSCI предварительно", "Итог", "Причины проверки"])
- _append(publications, ["№ сотрудника", "ФИО", "Название публикации", "Год", "Тип материала", "Источники",
- "Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "ЛК сотрудника ВШЭ", "DOI", "Ссылка на сайте", "Строки выгрузки ВШЭ", "Специальности ВАК", "По выбранной специальности",
- "К2", "RSCI", "Подтверждённый зачёт", "Дата публикации", "Причины проверки", "Подтверждённые основания",
- "Журнал и идентификаторы", "Квартильные категории и источники", "Аналитическая связь журнала", "Рассмотрение статьи"])
+ _append(employees, ["№", "ФИО", "Научная степень", "Q1", "Q2", "К1", "К2", "Всего",
+ "Всего из ЛК", "Общее количество всех публикаций без дублей", "Специальности ВАК", "Профиль ВШЭ"])
+ _append(publications, ["№", "ФИО", "Название", "Год", "Тип материала", "Критерии",
+ "Q1", "Q2", "К1", "К2", "RSCI", "Специальность ВАК", "DOI", "Ссылка на сайт"])
for number, candidate in enumerate(result["candidates"], 1):
- note = "Совпадение ФИО: данные ВШЭ не присвоены автоматически" if candidate["ambiguous"] else ""
specialty_codes = {code for article in candidate["articles"] for code in article["specialty_codes"]}
- _append(employees, [number, candidate["name"], candidate["degree"], candidate["q1_count"], candidate["q2_count"],
- candidate["k1_count"], candidate["total_count"],
- candidate["db_count"], candidate["count"],
- candidate["profile_url"], note, specialties_text(specialty_codes),
- candidate["specialty_count"] if result["specialty"] else None,
- candidate["selected_degree"], "Да" if candidate["degree_confirmed"] else "Нет", candidate["clause"],
- result["branch"], candidate["minimum"], candidate["confirmed_count"],
- candidate["k2_count"], candidate["rsci_count"],
- candidate["assessment_status"], "; ".join(candidate["assessment_reasons"])])
+ total = sum(article["q1"] or article["q2"] or article["k1"] for article in candidate["articles"])
+ _append(employees, [number, candidate["name"], candidate["selected_degree"],
+ *[candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "k2")], total,
+ candidate["db_count"], candidate["count"], specialties_text(specialty_codes), candidate["profile_url"]])
for article in candidate["articles"]:
- _append(publications, [number, candidate["name"], article["title"], article["year"], article["type_label"],
- "; ".join(article["origins"]), "; ".join(article["criteria"]),
- *["Да" if article[key] else "Нет" for key in ("q1", "q2", "k1", "qualifies", "in_db")],
- article["doi_url"], article["url"], ", ".join(map(str, article["rows"])), specialties_text(article["specialty_codes"]),
- ("Да" if article["matches_specialty"] else "Нет") if result["specialty"] else None,
- *["Да" if article[key] else "Нет" for key in ("k2", "rsci", "confirmed")], article["publication_date"],
- "; ".join(article["confirmation_reasons"]),
- "; ".join(f"{b['basis']} · {b.get('system', '')} · {b.get('category', '')} · {b.get('metric', '')} · год метрики {b.get('metric_year', '')} · применимо к {b['applicable_year']} · {b['source']} · {b.get('source_url') or ''}" for b in article["confirmed_bases"]),
- "; ".join(f"{r['name']} · {r['id']} · ISSN {r['issn']} / {r['eissn']} · {r['catalog']}" for r in article["catalog_records"]),
- "; ".join(f"{m['system']} · {m['metric']} · {m['category']} · {m['edition'] or ''} · {m['quartile']} · год метрики {m['year']} · выпуск {m['release_year'] or ''} · {m['source']} · {m['source_url'] or ''} · строка {m['source_row'] or ''} · страница {m['source_pdf_page'] or ''}" for r in article["catalog_records"] for m in r["metrics"]),
- "; ".join(t for r in article["catalog_records"] for t in r["thematic"]), article["thematic_status"]])
+ criteria = list(dict.fromkeys("В пределах пяти лет" if value.startswith(
+ ("В пределах пяти лет", "В пределах пятилетнего периода")) else value for value in article["criteria"]))
+ _append(publications, [number, candidate["name"], article["title"], article["year"],
+ article["type_label"] if article["types"] else "не указан", "; ".join(criteria),
+ *["Да" if article[key] else "Нет" for key in ("q1", "q2", "k1", "k2", "rsci")],
+ specialties_text(article["specialty_codes"]), article["doi_url"], article["url"]])
_append(parameters, ["Параметр", "Значение"])
for values in [
["Отрасль оценки", result["branch"]],
- ["Период оценки", "Пять лет относительно текущей даты Москвы"],
- ["Статус счётчиков", "Q1/Q2/К1/К2/RSCI и ВСЕГО предварительные; подтверждённый зачёт показан отдельно. К2 самостоятельно допустим только для пункта 5."],
+ ["Период оценки", "Пять календарных лет, включая текущий год Москвы; день и месяц не учитываются"],
+ ["Статус счётчиков", "Q1/Q2/К1/К2/RSCI показывают наличие соответствующего основания у уникальной работы в календарном периоде. Это не заключение о включении сотрудника в совет; подтверждённый зачёт и причины проверки доступны на сайте."],
["Предел заключения", "Только публикационные критерии; не автоматическое включение в совет. Отсутствие доказательств не означает отсутствие публикаций."],
- ["Границы и виды", "Спорные граничные даты и допустимость иных видов требуют подтверждения по источнику. Без него соответствующие работы не подтверждают минимум."],
+ ["Границы и виды", "Год публикации должен быть согласован между источниками; допустимость иных видов требует подтверждения. Без него соответствующие работы не подтверждают минимум."],
["Специальность ВАК", f"{result['specialty']}. — {result['specialty_name']}" if result["specialty"] else "Все специальности"],
- ["Подбор по специальности", "Хотя бы одна публикация за пять лет в релевантном журнале К1 по Перечню ВАК на 15.09.2026; содержание статьи и исторические специальности не проверяются."],
+ ["Подбор по специальности", "Хотя бы одна публикация за пять календарных лет в журнале К1 по Перечню ВАК на 15.09.2026 либо с ручной тематической связью; содержание статьи не проверяется."],
["Начало периода", result["start"]], ["Конец периода", result["end"]], ["Поиск по ФИО", result["q"]],
["Сортировать по", {"full_name": "ФИО", "q1_count": "Q1", "q2_count": "Q2", "k1_count": "К1", "k2_count": "К2", "rsci_count": "RSCI", "confirmed_count": "Подтверждено",
- "total_count": "ВСЕГО", "db_count": "ЛК сотрудника ВШЭ", "count": "Все публикации"}[result["sort"]]],
+ "total_count": "ВСЕГО на сайте", "db_count": "ЛК сотрудника ВШЭ", "count": "Все публикации"}[result["sort"]]],
["Направление", "По возрастанию" if result["direction"] == "asc" else "По убыванию"],
["Сотрудников в выгрузке", result["matched"]], ["Всего сотрудников в списке", result["total"]],
- ["Отбор по степени", "Высшая степень из истории: доктор выше кандидата/PhD; отсутствие подтверждения доктора не переводит оценку на пункт 6. Отсутствие публикаций не исключает сотрудника."],
+ ["Научная степень", "Текущая выбранная степень сотрудника: доктор выше кандидата/PhD. История степеней не перечисляется. Отсутствие публикаций не исключает сотрудника."],
["Счётчики Q1/Q2/К1", "Уникальные публикации в пятилетнем периоде по году. Q1/Q2 — отметки Scopus или Web of Science. "
- "Для пограничных лет нужно проверить дату, для неизвестного типа — подтвердить, что это статья. "
+ "День и месяц публикации не учитываются; для неизвестного типа нужно подтвердить, что это статья. "
"Материалы с известным другим типом исключены. К1 — совпадение названия журнала с итоговой категорией "
"в справочнике из ВАК категории.pdf; историческая категория не подтверждается. "
"Без названия журнала К1 не подтверждён."],
- ["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — специальности релевантного журнала К1, учитываемые за пять лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
- ["Работ / По выбранной специальности", "При выбранном фильтре показаны число соответствующих публикаций сотрудника и отметка Да/Нет для каждой работы. Без фильтра ячейки пустые; список специальностей остаётся заполненным."],
- ["ВСЕГО", "Предварительные уникальные работы Q1/Q2/К1/RSCI либо К2 для доктора; пересечения учитываются один раз."],
- ["ЛК сотрудника ВШЭ / Все публикации", "Все годы и категории. ЛК сотрудника ВШЭ — публикации из личного кабинета. "
- "Все публикации объединяет записи личного кабинета и выгрузку из базы ВШЭ без дублей; источники указаны на листе Публикации."],
+ ["К2 / RSCI", "К2 — применимое основание из текущего справочника ВАК либо проверенной записи. RSCI учитывается по отдельному источнику, отметка РИНЦ его не заменяет. Оба основания показаны в Публикациях; К2 также вынесен в Сотрудники."],
+ ["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — справочные специальности журнала К1 либо ручная тематическая связь за пять календарных лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
+ ["Всего", "Число уникальных работ с хотя бы одним основанием Q1 ИЛИ Q2 ИЛИ К1. Пересечения учитываются один раз. К2 и RSCI самостоятельно не увеличивают этот столбец; итог может отличаться от общего счётчика на сайте."],
+ ["Всего из ЛК", "Число уникальных публикаций из личного кабинета сотрудника ВШЭ за все годы, независимо от категорий."],
+ ["Общее количество всех публикаций без дублей", "Все годы и категории; объединение личного кабинета сотрудника и выгрузки из базы ВШЭ без дублей. Дедупликация — по DOI либо согласованным названию и году."],
+ ["Лист Публикации", "Все доступные уникальные работы найденных сотрудников за все годы. № соответствует номеру сотрудника на листе Сотрудники. Отсутствующий тип — не указан; сообщение В пределах пяти лет выводится один раз."],
+ ["Автоматический зачёт на сайте", "Известная статья с журналом и Q1/Q2 из источника за применимый год автоматически засчитывается при согласованном годе в периоде и отсутствии конфликтов. Подтверждение степени и тематическое рассмотрение проверяются отдельно."],
+ ["Ручные корректировки", "Учитываются правки статьи, журнала, квартиля и специальностей из редактора. Квартиль относится к году публикации; тематическая связь журнала действует независимо от года. Ручной квартиль без источника требует проверки."],
+ ["Совпадение ФИО", "При одинаковом ФИО нескольких сотрудников данные выгрузки ВШЭ не присваиваются автоматически; используются записи личного кабинета каждого сотрудника."],
]:
_append(parameters, values)
- _append(parameters, ["Пункт 5 · технические", "9 публикаций; К1/К2/RSCI/Q1/Q2"])
+ for group in result["criteria_groups"]:
+ _append(parameters, ["Доктор · " + group["names"], f"{group['minimum']} публикаций; К1/К2/RSCI/Q1/Q2; обязательных монографий: {group['monographs']}"])
_append(parameters, ["Пункт 6 · кандидат/PhD", "20 публикаций; К1/RSCI/Q1/Q2; одного К2 недостаточно"])
_append(parameters, ["Квартильные каталоги", "Ограниченные подборки, метрики 2025. Текущая индексация не проверена; это не означает отсутствие индексации. Тематика журнала не подтверждает статью."])
for meta in result["catalog_metadata"]:
@@ -94,8 +84,8 @@ def council_xlsx(result: dict) -> bytes:
_append(parameters, ["Проверка каталога", issue])
for meta in result["category_metadata"]:
_append(parameters, [meta["file"], f"Источник {meta['source']}; SHA-256 {meta['source_sha256']}; проверено строк {meta['source_rows']}"])
- for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 12, 20, 22, 45, 55, 80, 28, 40, 20, 10, 28, 12, 16, 16, 16, 50, 70]),
- (publications, [16, 34, 65, 12, 40, 45, 65, 10, 10, 10, 12, 20, 45, 45, 22, 80, 28, 10, 10, 20, 18, 70, 75, 60, 90, 75, 60]),
+ for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 10, 12, 18, 28, 70, 45]),
+ (publications, [7, 34, 65, 12, 24, 80, 10, 10, 10, 10, 10, 70, 45, 45]),
(parameters, [32, 95])]:
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = sheet.dimensions
diff --git a/app/services/publication_quartiles.py b/app/services/publication_quartiles.py
index c547d0e..f9bdbd4 100644
--- a/app/services/publication_quartiles.py
+++ b/app/services/publication_quartiles.py
@@ -129,13 +129,18 @@ def identity_issues(payload, doi="", journals=(), identifiers=()):
if (issue := binding_issue(item, payload, doi, journals, identifiers))))
+def qualifying_entries(payload, publication_year, doi="", journals=(), identifiers=()):
+ return [item 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 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)))
+ return list(dict.fromkeys(f"{item['system']} {item['quartile']}" for item in
+ qualifying_entries(payload, publication_year, doi, journals, identifiers)))
def index_sjr(rankings):
diff --git a/app/static/admin.css b/app/static/admin.css
index 4dcccae..80d279b 100644
--- a/app/static/admin.css
+++ b/app/static/admin.css
@@ -913,3 +913,28 @@
.council__title { font-size: clamp(23px, 7vw, 29px); overflow-wrap: anywhere; }
.council__article-heading { flex-direction: column; gap: 4px; }
}
+
+.council__article[data-council-article] { position: relative; padding-inline-end: 60px; cursor: pointer; }
+.council__article[data-council-article]:hover { background: #f3f7f5; }
+.council__article--active { background: #e7f0eb; }
+.council__article[data-council-article]:focus-visible { outline: 3px solid #0f766e; outline-offset: 2px; }
+.council__edit { position: absolute; inset-inline-end: 4px; top: 12px; min-width: 44px; min-height: 44px; visibility: hidden; display: inline-flex; align-items: center; justify-content: center; }
+.council__article--active > .council__edit,
+.council__article:focus-within > .council__edit { visibility: visible; }
+.council__articles--editor { padding: 20px 20px 20px 42px; background: #fff; border: 1px solid #d6dddc; border-radius: 12px; }
+.council__articles--editor .council__hint { margin-block: 8px; }
+.council-editor { display: grid; gap: 16px; margin-top: 20px; }
+.council-editor .directory__field { min-width: 0; }
+.council-editor textarea { resize: vertical; min-height: 90px; font: inherit; }
+.council-editor__actions { display: flex; flex-wrap: wrap; gap: 12px; }
+.council-editor__error { margin: 0; color: #991b1b; line-height: 1.5; }
+@media (max-width: 600px) {
+ .council__articles--editor { padding-inline: 28px 12px; }
+ .council-editor .directory__input { font-size: 16px; }
+}
+
+.council-editor__specialties { min-width: 0; }
+.council-editor__specialties summary { cursor: pointer; min-height: 44px; align-content: center; }
+.council-editor__specialties fieldset { min-width: 0; margin: 0; padding: 12px; border: 1px solid #d6dddc; border-radius: 8px; }
+.council-editor__specialties .columns-modal__grid { grid-template-columns: 1fr; margin-block: 12px; }
+.council__criteria-list { line-height: 1.55; }
diff --git a/app/static/council.js b/app/static/council.js
new file mode 100644
index 0000000..4159bfe
--- /dev/null
+++ b/app/static/council.js
@@ -0,0 +1,116 @@
+(() => {
+ const dialog = document.querySelector('[data-council-dialog]');
+ if (!dialog) return;
+ const form = dialog.querySelector('[data-edit-form]');
+ const autoSpecialties = form?.querySelector('[data-specialty-auto]');
+ const specialtyCheckboxes = form ? Array.from(form.querySelectorAll('[name="specialty_codes"]')) : [];
+ autoSpecialties?.addEventListener('change', () => {
+ specialtyCheckboxes.forEach(input => { input.disabled = autoSpecialties.checked; });
+ });
+ let selected;
+ let trigger;
+ let saving = false;
+ const openKey = 'miem.council.openCandidates';
+
+ try {
+ const opened = JSON.parse(sessionStorage.getItem(openKey) || '[]');
+ document.querySelectorAll('[data-employee-id]').forEach(node => {
+ if (opened.includes(node.dataset.employeeId)) node.open = true;
+ });
+ sessionStorage.removeItem(openKey);
+ } catch (_error) { /* Storage is optional. */ }
+
+ function activate(article) {
+ document.querySelectorAll('.council__article--active').forEach(node => {
+ node.classList.remove('council__article--active');
+ });
+ article.classList.add('council__article--active');
+ }
+
+ document.querySelectorAll('[data-council-article]').forEach(article => {
+ article.addEventListener('click', event => {
+ if (!event.target.closest('a, button')) activate(article);
+ });
+ article.addEventListener('keydown', event => {
+ if (event.target === article && ['Enter', ' '].includes(event.key)) {
+ event.preventDefault();
+ activate(article);
+ article.querySelector('[data-council-edit]').focus();
+ }
+ });
+ });
+
+ document.querySelectorAll('[data-council-edit]').forEach(button => {
+ button.addEventListener('click', () => {
+ trigger = button;
+ selected = JSON.parse(button.dataset.article);
+ if (form) {
+ form.elements.title.value = selected.title;
+ form.elements.journal.value = selected.journal;
+ form.elements.quartile.value = selected.quartile;
+ autoSpecialties.checked = !selected.manual_specialties;
+ form.querySelector("[data-edit-specialties]").open = selected.manual_specialties;
+ specialtyCheckboxes.forEach(input => {
+ input.checked = selected.specialty_codes.includes(input.value);
+ input.disabled = autoSpecialties.checked;
+ });
+ form.elements.quartile.disabled = selected.year === null;
+ form.querySelector('[data-edit-error]').hidden = true;
+ form.querySelector('[data-edit-note]').textContent = selected.year === null
+ ? 'Год публикации не указан или неоднозначен. Можно изменить название статьи и журнал; квартиль недоступен.'
+ : `Изменение квартиля применяется ко всем статьям выбранного журнала за ${selected.year} год. Проверьте квартиль при смене журнала.`;
+ }
+ dialog.showModal();
+ });
+ });
+
+ dialog.querySelectorAll('[data-edit-close]').forEach(button => {
+ button.addEventListener('click', () => { if (!saving) dialog.close(); });
+ });
+ dialog.addEventListener('cancel', event => { if (saving) event.preventDefault(); });
+ dialog.addEventListener('click', event => { if (event.target === dialog && !saving) dialog.close(); });
+ dialog.addEventListener('close', () => trigger?.focus());
+
+ form?.addEventListener('submit', async event => {
+ event.preventDefault();
+ if (saving) return;
+ const error = form.querySelector('[data-edit-error]');
+ const submit = form.querySelector('[type="submit"]');
+ const payload = {
+ employee_id: selected.employee_id, edit_key: selected.edit_key, revision: selected.revision,
+ title: form.elements.title.value.trim(), journal: form.elements.journal.value.trim(),
+ quartile: selected.year === null ? '' : form.elements.quartile.value,
+ specialty_codes: autoSpecialties.checked ? null : specialtyCheckboxes.filter(input => input.checked).map(input => input.value),
+ };
+ saving = true;
+ submit.disabled = true;
+ submit.textContent = 'Сохраняем…';
+ form.setAttribute('aria-busy', 'true');
+ error.hidden = true;
+ try {
+ const response = await fetch('/admin/council/publications', {
+ method: 'POST', credentials: 'same-origin',
+ headers: {'Content-Type': 'application/json', 'X-Council-Edit': '1'},
+ body: JSON.stringify(payload), signal: AbortSignal.timeout(15000),
+ });
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(typeof data.detail === 'string' ? data.detail : 'Проверьте название статьи, журнал и квартиль.');
+ }
+ try {
+ sessionStorage.setItem(openKey, JSON.stringify(Array.from(document.querySelectorAll('[data-employee-id][open]')).map(node => node.dataset.employeeId)));
+ } catch (_error) { /* Storage is optional. */ }
+ window.location.reload();
+ } catch (failure) {
+ error.textContent = failure.name === 'TimeoutError'
+ ? 'Не получен ответ сервера. Обновите страницу, чтобы проверить сохранение.'
+ : failure.message === 'Failed to fetch' ? 'Нет связи с сервером. Проверьте подключение и попробуйте ещё раз.' : failure.message;
+ error.hidden = false;
+ } finally {
+ saving = false;
+ submit.disabled = false;
+ submit.textContent = 'Сохранить';
+ form.removeAttribute('aria-busy');
+ }
+ });
+})();
diff --git a/app/templates/council.html b/app/templates/council.html
index 55e36d8..d14e58f 100644
--- a/app/templates/council.html
+++ b/app/templates/council.html
@@ -5,10 +5,12 @@
Подбор сотрудников в диссертационный совет по техническим наукам. Публикации за последние пять лет сопоставляются с данными о квартилях и категориях журналов. Минимум — 9 работ для доктора наук и 20 для кандидата наук или PhD. Предварительный подсчёт и подтверждённые работы показаны отдельно. Подбор сотрудников в диссертационный совет по техническим наукам. Публикации за пять календарных лет, включая текущий год, сопоставляются с данными о квартилях и категориях журналов. Для доктора наук минимум зависит от отрасли оценки; для кандидата наук или PhD — 20 работ. Предварительный подсчёт и подтверждённые работы показаны отдельно.Диссертационный совет
-