fix: simplify council assessment for technical faculty
CI / test (pull_request) Successful in 9m26s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped

This commit is contained in:
codex committed 2026-10-08 19:03:11 +03:00
1 parent a3f4a5b321
commit ec9a03fcf2
14 files changed
+65 -73

No files matched your search

+4
View File
@@ -1,5 +1,9 @@
# Changelog
## 0.16.2
- Страница совета и XLSX настроены на технические науки и пятилетний период относительно текущей даты. Убраны дата ходатайства, сведения о монографиях и связанные причины проверки.
## 0.16.1
- Ручные основания больше не возвращают книги и другие недопущенные виды в предварительные счётчики совета. Дополнительные виды учитываются только при подтверждённой разрешающей политике и согласованном виде материала.
+2 -2
View File
@@ -90,7 +90,7 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
Страница `/admin/council` доступна без авторизации. Показывает докторов, кандидатов наук и PhD, включая сотрудников без доступных публикаций; уволенные отмечены отдельно. Высшая степень выбирается по всей истории: доктор выше кандидата/PhD, кандидат и PhD имеют одинаковый уровень. Неподтверждённого доктора нельзя заменить подтверждённым кандидатом. Звание профессора или доцента не заменяет степень.
Форма принимает дату ходатайства и отрасль предполагаемого включения; отрасль не выводится автоматически из степени или специальности. Пункт 5: для доктора порог по отрасли — 7, 8, 9 либо 11 работ, для части отраслей нужна одна монография. Пункт 6: кандидат/PhD — 20 работ; самостоятельного К2 недостаточно. Период — пять лет перед указанной датой. Без даты показывается явно предварительный период относительно текущей даты Москвы. Некорректные дату, отрасль и специальность отклоняет HTTP 422.
Страница и XLSX настроены на технические науки: пункт 5 — 9 публикаций для доктора; пункт 6 — 20 для кандидата/PhD, самостоятельного К2 недостаточно. Период — пять лет относительно текущей даты Москвы. Дата ходатайства и сведения о монографиях не отображаются и не препятствуют итогу по техническим наукам. Некорректную специальность отклоняет HTTP 422.
Отдельно показаны предварительные Q1/Q2/К1/К2/RSCI, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет.
@@ -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.1`. Админка всегда показывает версии backend и frontend в footer.
Версия сервиса: `0.16.2`. Админка всегда показывает версии backend и frontend в footer.
## Разовый импорт публикаций
+2 -4
View File
@@ -136,11 +136,10 @@ 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 = "",
petition_date: str = "", branch: str = "",
db: Session = Depends(get_db)):
try:
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty,
petition_date=petition_date, 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):
@@ -151,11 +150,10 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_c
@router.get("/council/export.xlsx")
def export_council(request: Request, q: str = "", sort: str = "total_count", direction: str = "desc", specialty: str = "",
petition_date: str = "", branch: str = "",
db: Session = Depends(get_db)):
try:
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty,
petition_date=petition_date, 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)
+5 -12
View File
@@ -20,7 +20,7 @@ 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_catalog import catalog, publication_catalog
from app.services.council_assessment import (
BRANCHES, assess_candidate, degree_level, evidence, iso_date, publication_proofs,
BRANCHES, assess_candidate, degree_level, evidence, publication_proofs,
publication_type_allowed, sourced,
)
@@ -226,18 +226,11 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0,
sort: str = "total_count", direction: str = "desc", paginate: bool = True, specialty: str = "",
petition_date: date | str | None = None, branch: str = "") -> dict:
if isinstance(petition_date, str):
try:
petition_date = iso_date(petition_date) if petition_date else None
except ValueError as exc:
raise CouncilInputError("Некорректная дата ходатайства; ожидается ГГГГ-ММ-ДД") from exc
if petition_date and petition_date.year <= 5:
raise CouncilInputError("Дата ходатайства не позволяет построить пятилетний период")
branch: str = "технические") -> dict:
if branch and branch not in BRANCHES:
raise CouncilInputError("Неизвестная отрасль науки")
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
start, end = period(petition_date or today)
start, end = period(today)
specialties = _k1_specialties()["specialties"]
if specialty and specialty not in {s["code"] for s in specialties}:
raise CouncilInputError("Неизвестная специальность ВАК")
@@ -287,7 +280,7 @@ 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, petition_date, branch, specialty, start, end)
assessment = assess_candidate(degrees, articles, employee_evidence, branch, specialty, start, end)
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"])
@@ -315,7 +308,7 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
return {"candidates": candidates[offset:offset + limit] if paginate else candidates,
"start": start, "end": end, "total": total,
"specialties": specialties, "specialty": specialty, "specialty_name": specialty_name,
"petition_date": petition_date, "branch": branch, "branches": BRANCHES,
"branch": branch,
"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)
+1 -3
View File
@@ -230,7 +230,7 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy
article["thematic_status"] = "Результаты рассмотрения статьи расходятся — требуется проверка"
def assess_candidate(degrees, articles, employee_evidence, petition_date, branch, specialty, start, end):
def assess_candidate(degrees, articles, employee_evidence, branch, specialty, start, end):
proofs = [r for r in employee_evidence.get("degrees", []) if isinstance(r.get("degree"), str) and sourced(r)]
history = list(dict.fromkeys([*degrees, *(r["degree"] for r in proofs)]))
level = max((degree_level(d) for d in history), default=0)
@@ -251,8 +251,6 @@ def assess_candidate(degrees, articles, employee_evidence, petition_date, branch
reasons.append("Учёная степень для пунктов 5–6 не установлена")
elif not degree_confirmed:
reasons.append("Высшая текущая степень документально не подтверждена")
if not petition_date:
reasons.append("Дата ходатайства не задана: показан предварительный период")
if not branch:
reasons.append("Отрасль предполагаемого включения не задана")
if not articles:
+9 -11
View File
@@ -6,7 +6,6 @@ from openpyxl import Workbook
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
from app.services.council_assessment import BRANCHES
def _append(sheet, values):
@@ -29,8 +28,8 @@ def council_xlsx(result: dict) -> bytes:
_append(employees, ["№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "Страница ВШЭ", "Все публикации",
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности",
"Текущая степень", "Степень подтверждена", "Пункт", "Отрасль оценки", "Дата ходатайства", "Минимум",
"Подтверждено", "К2 предварительно", "RSCI предварительно", "Монографии подтверждены", "Минимум монографий", "Итог", "Причины проверки"])
"Текущая степень", "Степень подтверждена", "Пункт", "Отрасль оценки", "Минимум",
"Подтверждено", "К2 предварительно", "RSCI предварительно", "Итог", "Причины проверки"])
_append(publications, ["№ сотрудника", "ФИО", "Название публикации", "Год", "Тип материала", "Источники",
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "Страница ВШЭ", "DOI", "Ссылка на сайте", "Строки CSV", "Специальности ВАК", "По выбранной специальности",
"К2", "RSCI", "Подтверждённый зачёт", "Дата публикации", "Причины проверки", "Подтверждённые основания",
@@ -44,8 +43,8 @@ def council_xlsx(result: dict) -> bytes:
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"], result["petition_date"], candidate["minimum"], candidate["confirmed_count"],
candidate["k2_count"], candidate["rsci_count"], candidate["monograph_count"], candidate["monograph_minimum"],
result["branch"], candidate["minimum"], candidate["confirmed_count"],
candidate["k2_count"], candidate["rsci_count"],
candidate["assessment_status"], "; ".join(candidate["assessment_reasons"])])
for article in candidate["articles"]:
_append(publications, [number, candidate["name"], article["title"], article["year"], article["type_label"],
@@ -61,11 +60,11 @@ def council_xlsx(result: dict) -> bytes:
"; ".join(t for r in article["catalog_records"] for t in r["thematic"]), article["thematic_status"]])
_append(parameters, ["Параметр", "Значение"])
for values in [
["Дата ходатайства", result["petition_date"] or "Не задана — предварительный период"],
["Отрасль предполагаемого включения", result["branch"] or "Не выбрана"],
["Отрасль оценки", result["branch"]],
["Период оценки", "Пять лет относительно текущей даты Москвы"],
["Статус счётчиков", "Q1/Q2/К1/К2/RSCI и ВСЕГО предварительные; подтверждённый зачёт показан отдельно. К2 самостоятельно допустим только для пункта 5."],
["Предел заключения", "Только публикационные критерии; не автоматическое включение в совет. Отсутствие доказательств не означает отсутствие публикаций."],
["Границы и виды", "Спорные граничные даты, допустимость иных видов и период монографий требуют подтверждения по источнику. Без него соответствующие работы не подтверждают минимум."],
["Границы и виды", "Спорные граничные даты и допустимость иных видов требуют подтверждения по источнику. Без него соответствующие работы не подтверждают минимум."],
["Специальность ВАК", f"{result['specialty']}. — {result['specialty_name']}" if result["specialty"] else "Все специальности"],
["Подбор по специальности", "Хотя бы одна публикация за пять лет в релевантном журнале К1 по Перечню ВАК на 15.09.2026; содержание статьи и исторические специальности не проверяются."],
["Начало периода", result["start"]], ["Конец периода", result["end"]], ["Поиск по ФИО", result["q"]],
@@ -86,8 +85,7 @@ def council_xlsx(result: dict) -> bytes:
"Страница ВШЭ учитывает сохранённые публикации, включая импортированные из CSV; источник указан на листе Публикации."],
]:
_append(parameters, values)
for branch, (minimum, monographs) in BRANCHES.items():
_append(parameters, [f"Пункт 5 · {branch}", f"Публикаций: {minimum}; обязательных монографий: {monographs}"])
_append(parameters, ["Пункт 5 · технические", "9 публикаций; К1/К2/RSCI/Q1/Q2"])
_append(parameters, ["Пункт 6 · кандидат/PhD", "20 публикаций; К1/RSCI/Q1/Q2; одного К2 недостаточно"])
_append(parameters, ["Квартильные каталоги", "Ограниченные подборки, метрики 2025. Текущая индексация не проверена; это не означает отсутствие индексации. Тематика журнала не подтверждает статью."])
for meta in result["catalog_metadata"]:
@@ -96,7 +94,7 @@ 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, 18, 12, 16, 16, 16, 20, 20, 50, 70]),
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]),
(parameters, [32, 95])]:
sheet.freeze_panes = "A2"
+7 -16
View File
@@ -5,7 +5,7 @@
<header class="council__hero">
<div class="council__hero-copy">
<h2 class="council__title" id="council-title">Диссертационный совет</h2>
<p class="council__intro">Проверка публикационных критериев пунктов 5–6 для докторов, кандидатов наук и PhD. Показаны обнаруженные степени, предварительные сведения и отдельно подтверждённые работы. Соответствие этим критериям не означает автоматического включения в совет.</p>
<p class="council__intro">Проверка публикационных критериев пунктов 5–6 по техническим наукам для докторов, кандидатов наук и PhD. Показаны обнаруженные степени, предварительные сведения и отдельно подтверждённые работы. Соответствие этим критериям не означает автоматического включения в совет.</p>
</div>
</header>
@@ -21,22 +21,13 @@
<details class="council__notice">
<summary><strong>Как читать счётчики и критерии</strong></summary>
<p>Предварительные Q1/Q2 включают отметки CSV, применимые снимки каталогов и отдельные подтверждения. Предварительные К1/К2 включают совпадения журнала со справочником ВАК. ВСЕГО — объединение Q1/Q2/К1/RSCI и К2 для доктора наук. Работа учитывается один раз. Подтверждённое число требует самостоятельных доказательств применимости основания к этой работе.</p>
<p>Период {{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }}{% if not petition_date %} предварительный, относительно текущей даты{% endif %}. Подтверждённый итог требует документального подтверждения высшей степени, даты ходатайства, отрасли и работ с проверенными типом, периодом и допустимым основанием. Пограничные даты и годы, неизвестные типы и исторические категории требуют проверки. Предварительные числа сами по себе не подтверждают соответствие порогу. К2 допустим самостоятельно только для доктора наук; РИНЦ не заменяет RSCI.</p>
<p>Период {{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }} относительно текущей даты. Подтверждённый итог требует документального подтверждения высшей степени и работ с проверенными типом, периодом и допустимым основанием. Пограничные даты и годы, неизвестные типы и исторические категории требуют проверки. Предварительные числа сами по себе не подтверждают соответствие порогу. К2 допустим самостоятельно только для доктора наук; РИНЦ не заменяет RSCI.</p>
<p>Q1/Q2-каталоги — ограниченные тематические подборки со снимком метрик 2025 года. Они не подтверждают другие годы и текущую индексацию. Данные основаны на открытых копиях таблиц Scopus/JCR. Аналитическая связь журнала со специальностью не подтверждает содержание конкретной статьи. Порог и тематическое рассмотрение показываются отдельно.</p>
<p>«В БД» и «Все публикации» показывают количество работ за все годы, независимо от категорий. Общий перечень объединяет CSV и БД без дублей. Наличие публикаций не подтверждает соответствие всем требованиям совета.</p>
<p>При выборе специальности показаны сотрудники с хотя бы одной учитываемой за пять лет публикацией в журнале К1, которому эта специальность присвоена в «ВАК Перечень.pdf» на 15.09.2026. Это соответствие по журналу; содержание статьи и исторический перечень специальностей на дату публикации не проверяются.</p>
</details>
<form class="council__search" method="get" action="/admin/council" role="search">
<label class="directory__field" for="council-date">Дата ходатайства
<input class="directory__input" id="council-date" name="petition_date" type="date" value="{{ petition_date.isoformat() if petition_date else '' }}">
</label>
<label class="directory__field" for="council-branch">Отрасль предполагаемого включения
<select class="directory__input" id="council-branch" name="branch">
<option value="">Не выбрана — требуется проверка</option>
{% for value in branches %}<option value="{{ value }}"{% if branch == value %} selected{% endif %}>{{ value|capitalize }}</option>{% endfor %}
</select>
</label>
<label class="directory__field" for="council-specialty">Специальность ВАК
<select class="directory__input" id="council-specialty" name="specialty">
<option value="">Все специальности</option>
@@ -63,7 +54,7 @@
</label>
<button class="button" type="submit">Применить</button>
<button class="button button--secondary" type="submit" formaction="/admin/council/export.xlsx">Скачать XLSX</button>
{% if q %}<a class="admin__link" href="?petition_date={{ petition_date.isoformat() if petition_date else '' }}&amp;branch={{ branch|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
</form>
<div class="council__list-heading">
<h3>Сотрудники</h3>
@@ -89,7 +80,7 @@
</summary>
<div class="council__candidate-body">
<p><strong>{{ candidate.assessment_status }}</strong>. Текущая степень: {{ candidate.selected_degree or 'не установлена' }}; документальное подтверждение: {{ 'есть' if candidate.degree_confirmed else 'нет' }}.</p>
<p>Пункт: {{ candidate.clause or 'не определён' }} · Минимум: {{ candidate.minimum if candidate.minimum is not none else 'нужна отрасль' }} · Подтверждено: {{ candidate.confirmed_count }} · Предварительно: {{ candidate.total_count }} · К2: {{ candidate.k2_count }} · RSCI: {{ candidate.rsci_count }} · Монографии: {{ candidate.monograph_count }}{% if candidate.monograph_minimum %} / {{ candidate.monograph_minimum }} обязательно{% else %} (справочно){% endif %}</p>
<p>Пункт: {{ candidate.clause or 'не определён' }} · Минимум: {{ candidate.minimum if candidate.minimum is not none else 'нужна отрасль' }} · Подтверждено: {{ candidate.confirmed_count }} · Предварительно: {{ candidate.total_count }} · К2: {{ candidate.k2_count }} · RSCI: {{ candidate.rsci_count }}</p>
{% if candidate.assessment_reasons %}<ul class="council__evidence" aria-label="Причины проверки">{% for reason in candidate.assessment_reasons %}<li>{{ reason }}</li>{% endfor %}</ul>{% endif %}
{% if not candidate.articles %}<p class="council__hint">Доступных публикаций нет. Это не доказывает отсутствие работ сотрудника.</p>{% endif %}
{% if specialty %}<p class="council__hint">Публикаций по выбранной специальности: {{ candidate.specialty_count }}</p>{% endif %}
@@ -132,16 +123,16 @@
</div>
{% if matched > limit %}
<nav class="directory__pagination" aria-label="Страницы сотрудников">
{% if offset > 0 %}<a class="admin__link" href="?petition_date={{ petition_date.isoformat() if petition_date else '' }}&amp;branch={{ branch|urlencode }}&amp;q={{ q|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset - limit }}">Назад</a>{% endif %}
{% if offset > 0 %}<a class="admin__link" href="?q={{ q|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset - limit }}">Назад</a>{% endif %}
<span class="directory__page">{{ offset // limit + 1 }} / {{ (matched + limit - 1) // limit }}</span>
{% if offset + limit < matched %}<a class="admin__link" href="?petition_date={{ petition_date.isoformat() if petition_date else '' }}&amp;branch={{ branch|urlencode }}&amp;q={{ q|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset + limit }}">Далее</a>{% endif %}
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset + limit }}">Далее</a>{% endif %}
</nav>
{% endif %}
{% else %}
<div class="council__message">
<h4>{% if q or specialty %}Сотрудники не найдены{% else %}Сотрудники с учёной степенью пока не найдены{% endif %}</h4>
<p>{% if q or specialty %}Попробуйте другую фамилию или специальность. Вспомогательный подбор по журналам К1 может скрывать сотрудников с другими основаниями и не является условием соответствия пунктам 5–6.{% else %}Для списка требуется обнаруженная либо документально подтверждённая степень доктора, кандидата наук или PhD.{% endif %}</p>
{% if q %}<a class="admin__link" href="?petition_date={{ petition_date.isoformat() if petition_date else '' }}&amp;branch={{ branch|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
</div>
{% endif %}
{% endif %}
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "0.16.1"
FRONTEND_VERSION = "0.16.1"
BACKEND_VERSION = "0.16.1"
APP_VERSION = "0.16.2"
FRONTEND_VERSION = "0.16.2"
BACKEND_VERSION = "0.16.2"
+2 -2
View File
@@ -1,6 +1,6 @@
# Подтверждения публикационных критериев совета
Страница и XLSX читают один результат сервиса. Правила и ограничения исходного материала описаны в `app/data/COUNCIL_CRITERIA.md`, каталогов — в `app/data/Q1_Q2_CATALOG_GUIDE.md`. Порог зависит от **отрасли предполагаемого включения**, а не от отрасли степени или автоматически выбранной специальности.
Страница и XLSX читают один результат сервиса. Правила и ограничения исходного материала описаны в `app/data/COUNCIL_CRITERIA.md`, каталогов — в `app/data/Q1_Q2_CATALOG_GUIDE.md`. Публичная страница и XLSX настроены на **технические науки**: для доктора — 9 публикаций, для кандидата/PhD — 20. Пятилетний период рассчитывается относительно текущей даты Москвы. Дата ходатайства, монографии и связанные причины проверки не отображаются; подтверждение степени, вида, периода и основания работ по-прежнему требуется. Внутренний сервис сохраняет правила других отраслей для проверки исходной инструкции.
По умолчанию `app/data/council_evidence.json` содержит пустой набор подтверждений. Это намеренно: профиль, CSV и текущие списки журналов не заменяют документы. Без достаточных подтверждений вывод — «Требует проверки». Источники считаются неполными; отрицательное заключение из малого количества найденных работ не формируется.
@@ -67,7 +67,7 @@
Прямая и условная связь журнала — аналитическая подсказка, не подтверждение любой статьи. Текст условий не вычисляется по ключевым словам и не трактуется как AND/OR. Отсутствие журнала в подборке означает «не оценён», а `not_established` относится к конкретной оценённой паре и не запрещает междисциплинарную статью.
К записи публикации можно добавить `thematic_review` с полями `specialty_code`, `branch` (значение отрасли из формы), `passport_id`, `passport_sha256`, `point_numbers` (числовой массив), `materials` (ссылки/описание проверенных материалов), `checked_conditions` (массив результатов проверки условий, допускается пустой массив), `result` (`confirmed` или `requires_check`), `source`, `checked_on`. Сервис проверяет выбранную специальность, отрасль, версию паспорта и номера направлений. Для 1.3.8 технический и физико-математический паспорта имеют отдельные ID и нумерацию; подмена отрасли не допускается. Оговорка 1.2.2 о названии диссертации не применяется автоматически к статье. Результат рассмотрения показывается отдельно и не вводит дополнительный числовой критерий.
К записи публикации можно добавить `thematic_review` с полями `specialty_code`, `branch` (для публичной страницы — `технические`), `passport_id`, `passport_sha256`, `point_numbers` (числовой массив), `materials` (ссылки/описание проверенных материалов), `checked_conditions` (массив результатов проверки условий, допускается пустой массив), `result` (`confirmed` или `requires_check`), `source`, `checked_on`. Сервис проверяет выбранную специальность, отрасль, версию паспорта и номера направлений. Для 1.3.8 технический и физико-математический паспорта имеют отдельные ID и нумерацию; подмена отрасли не допускается. Оговорка 1.2.2 о названии диссертации не применяется автоматически к статье. Результат рассмотрения показывается отдельно и не вводит дополнительный числовой критерий.
## Каталоги и обновление
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "0.16.1"
version = "0.16.2"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+2 -4
View File
@@ -83,13 +83,11 @@ def main():
page.goto("http://miem.test/admin/council")
expect(page.get_by_role("status")).to_have_text("Показаны 1–25 из 28")
assert "Без степени" not in page.content() and "Только звание" not in page.content()
page.get_by_label("Дата ходатайства").fill("2026-10-08")
page.get_by_label("Отрасль предполагаемого включения").select_option("технические")
expect(page.locator("#council-date")).to_have_count(0)
assert "Монографии" not in page.content()
page.get_by_role("button", name="Применить", exact=True).click()
expect(page.get_by_role("link", name="Вход в админку")).to_be_visible()
page.get_by_role("link", name="Далее").click()
expect(page.get_by_label("Дата ходатайства")).to_have_value("2026-10-08")
expect(page.get_by_label("Отрасль предполагаемого включения")).to_have_value("технические")
expect(page.get_by_role("status")).to_have_text("Показаны 26–28 из 28")
page.get_by_role("link", name="Назад").click()
summary = page.locator(".council__candidate-summary").first
+1 -1
View File
@@ -18,7 +18,7 @@ def test_health_returns_versions():
response = TestClient(app).get("/api/health")
assert response.status_code == 200
assert response.json()["backend_version"] == "0.16.1"
assert response.json()["backend_version"] == "0.16.2"
def test_api_employees_and_stats_require_admin_session():
+3 -3
View File
@@ -218,7 +218,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
assert "&lt;script&gt;" in response.text and "<script>" not in response.text
assert "javascript:" not in response.text and "private@example.org" not in response.text
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
assert "Backend 0.16.1 · Frontend 0.16.1" in response.text
assert "Backend 0.16.2 · Frontend 0.16.2" in response.text
assert "Вход в админку" in response.text
assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403)
assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text
@@ -267,7 +267,7 @@ def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db
]
assert people["J2"].value == "https://www.hse.ru/staff/test"
assert people["K2"].value is None
assert people.max_column == 26
assert people.max_column == 23
assert works["L1"].value == "Страница ВШЭ"
assert people.max_row == 29 and works.max_row == 29
assert people["B2"].value == "Петров 27" and people["B29"].value == "Петров 00"
@@ -570,7 +570,7 @@ def test_xlsx_specialties_are_explicit_with_and_without_filter(db_session, expor
if not specialty:
unmatched = next(dict(zip(staff[0], values)) for values in staff[1:] if values[1] == other.full_name)
assert unmatched['Специальности ВАК'] == 'Не подтверждены'
assert book['Сотрудники'].auto_filter.ref.endswith(f'Z{len(staff)}')
assert book['Сотрудники'].auto_filter.ref.endswith(f'W{len(staff)}')
assert book['Публикации'].auto_filter.ref.endswith(f'AA{len(works)}')
+23 -11
View File
@@ -1,6 +1,6 @@
import itertools
import json
from datetime import date
from datetime import date, datetime
from io import BytesIO
import pytest
@@ -50,7 +50,7 @@ def work(db, employee, number=0, year=2024, kind="ARTICLE", basis="К1", **proof
def result(db, branch="технические", **kwargs):
db.commit()
return council.council_candidates(db, petition_date=date(2026, 10, 8), branch=branch, **kwargs)["candidates"][0]
return council.council_candidates(db, today=date(2026, 10, 8), branch=branch, **kwargs)["candidates"][0]
@pytest.mark.parametrize("branch,rule", assessment.BRANCHES.items())
@@ -145,7 +145,12 @@ def test_catalogs_union_percentiles_and_article_not_approved():
assert publication_catalog(["0006-3835"], ["Wrong name"])[1]
def test_http_validation_context_and_export_parity(db_session, proofs):
def test_http_validation_context_and_export_parity(db_session, proofs, monkeypatch):
class Clock:
@staticmethod
def now(zone):
return datetime(2026, 10, 8, tzinfo=zone)
monkeypatch.setattr(council, "datetime", Clock)
degree = "Доктор технических наук"
employee = person(db_session, [degree])
proofs([{**SOURCE, "degree": degree}], [work(db_session, employee, n) for n in range(9)])
@@ -155,10 +160,10 @@ def test_http_validation_context_and_export_parity(db_session, proofs):
app.dependency_overrides[get_db] = database
try:
client = TestClient(app)
for query in ["petition_date=oops", "petition_date=0001-01-01", "branch=oops", "specialty=oops"]:
for query in ["specialty=oops"]:
assert client.get("/admin/council?" + query).status_code == 422
assert client.get("/admin/council/export.xlsx?" + query).status_code == 422
query = "?petition_date=2026-10-08&branch=технические&sort=confirmed_count"
query = "?sort=confirmed_count"
page = client.get("/admin/council" + query)
assert page.status_code == 200 and "Соответствует публикационным критериям пункта 5" in page.text
book = load_workbook(BytesIO(client.get("/admin/council/export.xlsx" + query).content))
@@ -167,6 +172,12 @@ def test_http_validation_context_and_export_parity(db_session, proofs):
assert row["Подтверждено"] == 9 and row["Минимум"] == 9
assert row["Итог"] in page.text
assert row["Отрасль оценки"] == "технические"
assert not any("ходатайств" in str(c.value).lower() or "монограф" in str(c.value).lower() for sheet in book for cells in sheet for c in cells)
assert "council-date" not in page.text and "Монографии" not in page.text
assert "Дата ходатайства" not in page.text
legacy = client.get("/admin/council/export.xlsx?petition_date=2000-01-01&branch=экономические")
legacy_book = load_workbook(BytesIO(legacy.content))
assert legacy_book["Сотрудники"]["Q2"].value == "технические"
finally:
app.dependency_overrides.clear()
@@ -263,14 +274,15 @@ def test_degree_document_can_include_employee_without_profile_degree(db_session,
assert value["clause"] == 6 and value["degree_confirmed"] and value["count"] == 0
def test_confirmed_degree_needs_petition_and_branch_even_if_minimum_met(db_session, proofs):
def test_technical_assessment_uses_current_period_without_petition(db_session, proofs):
degree = "Доктор технических наук"
employee = person(db_session, [degree])
proofs([{**SOURCE, "degree": degree}], [work(db_session, employee, n) for n in range(9)])
db_session.commit()
value = council.council_candidates(db_session, today=date(2026, 10, 8))["candidates"][0]
assert value["confirmed_count"] == 9 and value["assessment_status"] == "Требует проверки"
assert any("Дата ходатайства" in r for r in value["assessment_reasons"])
assert value["confirmed_count"] == value["minimum"] == 9
assert value["assessment_status"].startswith("Соответствует")
assert value["assessment_reasons"] == []
def test_boundary_dates_require_sourced_policy(db_session, proofs):
@@ -303,7 +315,7 @@ def test_manual_monograph_bases_respect_preliminary_type_policy(db_session, proo
record["kind"] = "monograph"
policy = {"publication_types": {**SOURCE, "values": ["monograph"]}} if admitted else {}
proofs(publications=[record], policy=policy)
full_result = council.council_candidates(db_session, petition_date=date(2026, 10, 8), branch="технические")
full_result = council.council_candidates(db_session, today=date(2026, 10, 8), branch="технические")
candidate = full_result["candidates"][0]
assert candidate[f"{flag}_count"] == candidate["total_count"] == int(admitted)
# The policy permits a type; it does not itself confirm a quartile.
@@ -344,7 +356,7 @@ def test_valid_snapshot_keeps_preliminary_metrics_when_other_is_broken(db_sessio
db_session.add(EmployeePublication(employee_id=employee.id, title="Snapshot work", year=2025,
publication_type="ARTICLE", source_hash="snapshot", raw_data={"journal": journal["name"], "issn": journal["issn"]}))
db_session.commit()
full_result = council.council_candidates(db_session, petition_date=date(2026, 10, 8), branch="технические")
full_result = council.council_candidates(db_session, today=date(2026, 10, 8), branch="технические")
candidate = full_result["candidates"][0]
article = candidate["articles"][0]
flag = "q2" if broken_index == 0 else "q1"
@@ -357,7 +369,7 @@ def test_valid_snapshot_keeps_preliminary_metrics_when_other_is_broken(db_sessio
assert "целостность каталога" in " ".join(str(cell.value) for row in book["Параметры"] for cell in row)
app.dependency_overrides[get_db] = lambda: db_session
try:
response = TestClient(app).get("/admin/council?petition_date=2026-10-08&branch=технические")
response = TestClient(app).get("/admin/council")
assert response.status_code == 200 and "целостность каталога" in response.text
assert journal["name"] in response.text
finally: