chore: обновить паузу занятия от main

# Conflicts:
#	backend/app/scoring/dispatcher.py
#	backend/app/session/store.py
This commit is contained in:
kaifarikman 2026-09-27 23:30:18 +03:00
commit 0d58878cbe
51 changed files with 4545 additions and 87 deletions

View file

@ -51,6 +51,8 @@ _INSTANCE = hashlib.sha256(
).hexdigest()
_generations: dict[str, int] = {}
_active_sockets: dict[str, weakref.WeakKeyDictionary] = {}
# Logins already revoked because their users row disappeared.
_vanished: set[str] = set()
AUTH_GENERATION_SYNC_SECONDS = 1.0
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
_generations_synced_at: float | None = None
@ -121,10 +123,13 @@ async def sync_generations() -> None:
# The local-only dev-token principal is synthetic, never stored in users;
# a DB watcher must not revoke its in-memory generation during test/demo
# flows that deliberately exercise account invalidation.
# The revoked generation stays cached: dropping it would make the next
# lookup fall back to 0 and re-accept cookies issued before the revocation.
synthetic = {"dev"} if get_settings().dev_auth_bypass else set()
for login in _generations.keys() - current.keys() - synthetic:
_vanished.intersection_update(_generations.keys() - current.keys())
for login in _generations.keys() - current.keys() - synthetic - _vanished:
invalidate_login(login)
_generations.pop(login, None)
_vanished.add(login)
global _generations_synced_at
_generations_synced_at = time.monotonic()

View file

@ -48,3 +48,14 @@ async def signs(prefix: list[str] = Query(default=[]), group: int | None = None)
else None
),
}
@router.get("/services")
async def services() -> dict:
"""Каталог служб для ручного добавления в карточку — как окно «Добавьте службы».
Каталог — справочник боевого АРМ, а не содержимое вызова: отдавать его
курсанту так же безопасно, как группы происшествий.
"""
data = ekp.catalog()
return {"source": data.source, "services": [item.model_dump() for item in data.services]}

View file

@ -23,14 +23,26 @@ from app.db import repo
from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee
from app.domain.events import Exercise, SessionMode, SessionReport
from app.domain.taxonomy import (
ERRORS,
ErrorCode,
Finding,
FindingDecision,
FindingReview,
FindingSource,
)
from app.domain.roles import Role
from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current
from app.domain.timers import TimerCode, TimerState
from app.scoring.export import to_csv, to_pdf
from app.scoring.report import build as build_report
from app.scoring.review import has_finding
from app.scoring.taxonomy import METRIC_MAP
from app.session.access import can_access
from app.session.checkpoint import load_state
from app.session.finish import override_score
from app.session.finish import change_findings, override_score
from app.session.store import FindingAdded, FindingReviewed
from app.session.timers import now_utc
from app.session.score import scoring_scenario
from app.session.hub import hub
from app.session.state import SessionState
@ -656,6 +668,157 @@ async def override(
return SessionReport.model_validate(report["full_report"])
def _required_text(value: str) -> str:
cleaned = value.strip()
if not cleaned:
raise ValueError("поле обязательно")
return cleaned
class FindingReviewIn(BaseModel):
"""Решение по отметке: причина обязательна и остаётся в разборе."""
decision: FindingDecision
reason: str = Field(min_length=1, max_length=1000)
_reason = field_validator("reason")(_required_text)
class FindingIn(BaseModel):
"""Отметка преподавателя: код, факт и норма — то же обоснование, что у автоматической."""
code: ErrorCode
fact: str = Field(min_length=1, max_length=1000)
norm: str = Field(min_length=1, max_length=1000)
#: Номер карточки очереди ДДС (с 1); без него отметка относится к занятию.
card: int | None = Field(default=None, ge=1)
#: Ключ идемпотентности с клиента: повтор запроса не добавит отметку второй раз.
client_id: UUID | None = None
_fact = field_validator("fact")(_required_text)
_norm = field_validator("norm")(_required_text)
async def _change_findings(
session_id: UUID, who, db: AsyncSession | None, make,
) -> SessionReport:
"""Общий путь решений по отметкам — тот же, что у правки итога.
`make(who, score)` строит запись изменения по сохранённой оценке или
отвечает HTTP-ошибкой. Роль проверяет маршрут: администратору сюда нельзя,
как и к правке балла.
"""
state = hub.get(session_id)
if state is not None:
_require_access(who, state)
if state.score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
scenario = scoring_scenario(state)
if scenario is None:
raise HTTPException(status_code=409, detail="scenario_not_found")
change = make(who, state.score)
async with hub.operation(session_id):
if not _repeated(state.score, change):
change_findings(state, change)
return build_report(session_id, state, scenario)
if db is None:
raise HTTPException(status_code=404, detail="session_not_found")
session = await repo.get_session(db, session_id)
if session is None:
raise HTTPException(status_code=404, detail="session_not_found")
_require_access(who, session)
score = await db.scalar(select(Score).where(Score.session_id == session_id))
if score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
if (score.report or {}).get("full_report") is None:
raise HTTPException(status_code=409, detail="report_not_archived")
current = dict(score.report)
change = make(who, current)
await db.rollback() # чтение закончено; запись — одной транзакцией хранилища
if not _repeated(current, change):
await hub.store.commit_archived(session_id, [change])
# Ответ — по строке после commit: пересчёт шёл под блокировкой и мог учесть
# решение из соседней вкладки, которого не было в прочитанном выше отчёте.
score = await db.scalar(
select(Score).where(Score.session_id == session_id)
.execution_options(populate_existing=True)
)
report = SessionReport.model_validate(score.report["full_report"])
await db.rollback()
return report
def _repeated(report: dict, change: FindingReviewed | FindingAdded) -> bool:
return isinstance(change, FindingAdded) and has_finding(report, change.finding.client_id)
@router.post("/{session_id}/findings/{index}/review", response_model=SessionReport)
async def review_finding(
session_id: UUID,
index: int,
body: FindingReviewIn,
request: Request,
db: AsyncSession | None = Depends(optional_session),
) -> SessionReport:
"""Подтвердить или снять отметку; снятая не штрафует свою метрику."""
who = require(request, Role.INSTRUCTOR)
def make(who, score: dict) -> FindingReviewed:
if not 0 <= index < len(score.get("findings", [])):
raise HTTPException(status_code=404, detail="finding_not_found")
return FindingReviewed(index=index, role=who.role.value, review=FindingReview(
decision=body.decision, reason=body.reason, author=who.login, at=now_utc(),
))
return await _change_findings(session_id, who, db, make)
@router.post("/{session_id}/findings", response_model=SessionReport)
async def add_finding(
session_id: UUID,
body: FindingIn,
request: Request,
db: AsyncSession | None = Depends(optional_session),
) -> SessionReport:
"""Отметка преподавателя штрафует связанную метрику по той же методике."""
who = require(request, Role.INSTRUCTOR)
def make(who, score: dict) -> FindingAdded:
service = None
prefix = ""
exercise = (score.get("full_report") or {}).get("exercise")
# E1–E6 в связке 112 → ДДС — ошибки приёма вызова: их метрики у занятия,
# а не у карточки очереди. С номером карточки отметка не нашла бы свою
# метрику 112 и штрафовала бы отдельной метрикой преподавателя.
if (body.card is not None and body.code.value.startswith("E")
and exercise != Exercise.DDS.value):
raise HTTPException(status_code=422, detail="call_code_without_card")
if body.card is not None:
cards = score.get("card_results") or []
if body.card > len(cards):
raise HTTPException(status_code=422, detail="card_not_found")
service = cards[body.card - 1].get("managed_service")
prefix = f"Карточка {body.card}: " + (f"{service}: " if service else "")
competency = next((item for code, item in METRIC_MAP.values() if code is body.code), None)
return FindingAdded(role=who.role.value, finding=Finding(
code=body.code,
source=FindingSource.INSTRUCTOR,
summary=f"{prefix}{ERRORS[body.code].title}",
fact=body.fact,
norm=body.norm,
ref="отметка преподавателя на разборе",
competency=competency,
at=now_utc(),
card=body.card,
service=service,
author=who.login,
client_id=body.client_id,
))
return await _change_findings(session_id, who, db, make)
@router.get("", response_model=list[SessionOut])
async def listing(
request: Request,

View file

@ -112,6 +112,7 @@ def _build_state(session_id: UUID, event, who, scenario, scenarios,
dds_service=identity.service or event.dds_service,
attempt=identity.attempt,
criteria=event.criteria,
socket_last_seen_at=now_utc(),
)
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
state.timers.limits[TimerCode.CARD_FILL] = event.criteria.card_fill_time_limit_seconds * 1000

View file

@ -17,7 +17,7 @@ from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.api.ws.session import pump, run_command, session_socket
from app.api.ws.session import close_fenced, pump, run_command, session_socket
from app.domain.events import (
CallEndReason,
ErrorEvent,
@ -62,9 +62,14 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
entered = await session_socket(ws, session_id, (Role.INSTRUCTOR, Role.TRAINEE))
if entered is None:
return
_who, state = entered
who, state = entered
with hub.station(session_id) as queue:
async with hub.trainee_socket(
session_id, station=True, trainee=who.role is Role.TRAINEE,
) as queue:
if hub.is_lease_fenced(session_id):
await close_fenced(ws)
return
sender = asyncio.create_task(pump(ws, queue))
try:
# Карточка, переданная до подключения станции, не теряется:

View file

@ -120,6 +120,11 @@ class Settings(BaseSettings):
def limit_ms(self, code: TimerCode) -> int:
return self.timer_limits_ms.get(code, NORMATIVES[code].limit_ms)
# Пороги сигналов реестра живых занятий (пульт преподавателя).
signal_backlog_threshold: int = Field(default=4, ge=1)
signal_refusals_threshold: int = Field(default=2, ge=1)
signal_offline_window_seconds: int = Field(default=45, ge=1)
def validate_deployment_security(self) -> None:
"""Reject known development authentication defaults on a production app."""
if self.app_env != "production":

View file

@ -15,6 +15,9 @@ from pathlib import Path
from pydantic import BaseModel, Field
DATA = Path(__file__).parent / "ekp.json"
#: Каталог окна «Добавьте службы» из «СЛУЖБЫ 112.docx»: 209 служб, в том числе
#: районные ДДС. Источник и лицензия — в шапке файла и в docs/delivery/THIRD-PARTY.md.
CATALOG = Path(__file__).parent / "services_catalog.json"
#: Часть строк книги существует ради интеграции с чужими системами, а оператору
#: не показывается — книга помечает их прямо в первом признаке.
@ -89,6 +92,37 @@ def reference() -> Reference:
return Reference.model_validate_json(DATA.read_text(encoding="utf-8"))
class CatalogService(BaseModel):
"""Служба каталога боевого АРМ — то, что оператор добавляет в карточку вручную."""
id: str
name: str
organization: str | None = None
#: «ДДС», если это явно указано в названии в каталоге.
service_level: str | None = None
#: Скриншот и строка списка в «СЛУЖБЫ 112.docx».
source_reference: str
class Catalog(BaseModel):
source: str
transcription: str
license: str
services: list[CatalogService]
@lru_cache(maxsize=1)
def catalog() -> Catalog:
"""Каталог только расширяет выбор служб. Список оповещения по-прежнему
считается из признаков по классификатору, эталоны сценариев от него не зависят."""
return Catalog.model_validate_json(CATALOG.read_text(encoding="utf-8"))
@lru_cache(maxsize=1)
def catalog_names() -> frozenset[str]:
return frozenset(service.name for service in catalog().services)
@lru_cache(maxsize=1)
def _by_code() -> dict[str, Incident]:
return {incident.code: incident for incident in reference().incidents}

View file

@ -23,7 +23,7 @@ from app.domain.roles import Role
from app.domain.statuses import ServiceStatus, StationSnapshot
from app.domain.taxonomy import Finding
from app.domain.timers import TimerSnapshot
from app.scoring.taxonomy import METRIC_MAP
from app.scoring.taxonomy import INSTRUCTOR_METRIC, METRIC_MAP
class SessionMode(StrEnum):
@ -53,6 +53,8 @@ class LessonCriteria(BaseModel):
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
card_fill_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
dds_card_work_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
#: Сколько секунд после доклада бригады даётся на соответствующий статус.
dds_report_reaction_limit_seconds: int = Field(default=45, ge=5, le=600)
allowed_errors: int = Field(default=0, ge=0, le=50)
require_correct_grammar: bool = True
score_weights: dict[str, float] = Field(default_factory=dict)
@ -60,7 +62,7 @@ class LessonCriteria(BaseModel):
@field_validator("score_weights")
@classmethod
def validate_score_weights(cls, weights: dict[str, float]) -> dict[str, float]:
unknown = weights.keys() - METRIC_MAP.keys()
unknown = weights.keys() - METRIC_MAP.keys() - {INSTRUCTOR_METRIC}
if unknown:
raise ValueError(f"неизвестные метрики весов: {', '.join(sorted(unknown))}")
if any(not 0 <= weight <= 10 for weight in weights.values()):
@ -715,6 +717,12 @@ class Metric(BaseModel):
passed: bool
weight: float = 1.0
credit: float | None = Field(default=None, ge=0, le=1)
#: Номер карточки очереди ДДС (с 1) и служба — область метрики для разбора.
card: int | None = None
service: str | None = None
#: Автоматический вердикт, если его изменило решение преподавателя по отметке.
passed_auto: bool | None = None
credit_auto: float | None = Field(default=None, ge=0, le=1)
class CompetencyScore(BaseModel):
@ -764,6 +772,8 @@ class DdsCardReport(BaseModel):
card_id: UUID
scenario_id: str
score_auto: float
#: Балл карточки после решений преподавателя по отметкам; None — решений не было.
score_reviewed: float | None = None
reply_text: str
metrics: list[Metric]
findings: list[Finding]
@ -803,6 +813,8 @@ class SessionReport(BaseModel):
self_assessment_diff: SelfAssessmentDiff | None = None
notes: list[InstructorNoteShown] = []
score_auto: float
#: Балл после решений преподавателя по отметкам; None — решений не было.
score_reviewed: float | None = None
score_final: float
overridden_by: str | None = None
override_comment: str | None = None

View file

@ -118,6 +118,13 @@ class KIO(BaseModel):
# ДДС. `notify` считается из признаков и правится только системой:
# в боевом АРМ оператор может добавить службу вручную, но не удалить.
notify: list[str] = Field(default_factory=list, description="Список оповещения")
#: Службы, добавленные оператором вручную из каталога. Автоматический список
#: не заменяют и в оценку по эталону не входят. Каталог проверяется на входе
#: (apply_patch), а не здесь: иначе правка названия в каталоге сломала бы
#: загрузку сохранённых снимков занятий и сданных карточек.
notify_extra: list[str] = Field(
default_factory=list, max_length=20, description="Дополнительно оповестить"
)
dds: DDSCode | None = Field(
default=None, description="Устаревшее: одна служба. Заменено списком оповещения"
)
@ -147,7 +154,7 @@ EDITABLE_KIO_FIELDS: frozenset[str] = frozenset({
"okato", "address", "street", "building", "entrance", "floor",
"intercom_code", "description", "incident_group", "signs",
"incident_type", "victims_count", "is_emergency", "life_threat",
"evacuation_needed", "dds",
"evacuation_needed", "dds", "notify_extra",
"fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified",
"fire.people_inside", "fire.smoke_spread",
"police.offence_kind", "police.suspects", "police.suspect_fled",
@ -223,11 +230,40 @@ def derive_incident(card: KIO) -> KIO:
return KIO.model_validate(data)
class PatchRejected(ValueError):
"""Дельта `kio.patch` не принята целиком: карточка не изменилась."""
def _check_catalog(names: Any) -> None:
"""Только службы каталога: свободный текст в адресатах карточки
на АРМ ДДС выглядел бы как настоящая служба."""
from app.domain import ekp
if not isinstance(names, list):
return # тип и лимит проверит модель
known = ekp.catalog_names()
unknown = [name for name in names if name not in known]
if unknown:
raise PatchRejected(f"нет в каталоге служб: {', '.join(map(str, unknown[:3]))}")
def _without_auto_services(card: KIO) -> KIO:
"""Добавки без повторов и без служб, которые уже есть в списке оповещения:
иначе на АРМ ДДС одна служба окажется в двух строках."""
extra = [name for name in dict.fromkeys(card.notify_extra) if name not in card.notify]
if extra == card.notify_extra:
return card
return card.model_copy(update={"notify_extra": extra})
def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
"""Применить дельту `kio.patch`. Служебные поля игнорируются.
Вложенные поля приходят плоским путём: {"fire.floors": 5}.
Недопустимое значение — `PatchRejected` или `ValidationError`.
"""
if "notify_extra" in fields:
_check_catalog(fields["notify_extra"])
data = card.model_dump()
for path, value in fields.items():
if path in READ_ONLY_FIELDS:
@ -243,4 +279,4 @@ def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
# Пересчитываем не только на смену признаков: пострадавшие поднимают скорую,
# газификация — МОСГАЗ, и список оповещения обязан это отразить сразу.
touched = MODIFIER_FIELDS & set(fields)
return derive_incident(updated) if touched else updated
return _without_auto_services(derive_incident(updated) if touched else updated)

File diff suppressed because it is too large Load diff

View file

@ -7,8 +7,10 @@
from datetime import datetime
from enum import StrEnum
from typing import Any
from uuid import UUID
from pydantic import BaseModel
from pydantic import BaseModel, model_validator
class ErrorCode(StrEnum):
@ -152,6 +154,22 @@ COMPETENCY_LABELS: dict[Competency, str] = {
}
class FindingDecision(StrEnum):
"""Решение преподавателя по отметке на разборе."""
CONFIRMED = "confirmed"
DISMISSED = "dismissed"
class FindingReview(BaseModel):
"""Кто, когда и почему подтвердил или снял отметку. Сама отметка остаётся."""
decision: FindingDecision
reason: str
author: str
at: datetime
class Finding(BaseModel):
"""Отметка в разборе. `fact` и `norm` — то самое обоснование."""
@ -164,3 +182,32 @@ class Finding(BaseModel):
competency: Competency | None = None
transcript_ref: str | None = None
at: datetime | None = None
#: Метрика, чей провал объясняет отметка. Без неё связь с метриками идёт
#: по методике METRIC_MAP в пределах той же карточки и службы.
metric_key: str | None = None
#: Номер карточки очереди ДДС (с 1) и служба, к которым относится отметка.
card: int | None = None
service: str | None = None
#: Автор ручной отметки преподавателя (`source=instructor`).
author: str | None = None
#: Ключ идемпотентности ручной отметки: повтор запроса не добавляет её снова.
client_id: UUID | None = None
#: Все решения по отметке по порядку; действует последнее. Прежние не
#: затираются: «кто, когда, почему» нужно по каждой правке, а не по итогу.
reviews: list[FindingReview] = []
@model_validator(mode="before")
@classmethod
def _single_review(cls, data: Any) -> Any:
# Разборы, сохранённые до истории решений, хранили одно поле `review`.
if isinstance(data, dict) and "review" in data:
data = dict(data)
single = data.pop("review")
if single and not data.get("reviews"):
data["reviews"] = [single]
return data
@property
def review(self) -> FindingReview | None:
"""Действующее решение — последнее."""
return self.reviews[-1] if self.reviews else None

View file

@ -30,6 +30,7 @@ def evaluate_card(scenario: Scenario, kio: KIO) -> GostResult:
source=FindingSource.KIO if code.value == "E5" else FindingSource.GROUND_TRUTH,
summary=f"{title}: {fact}; требуется {norm}",
fact=fact, norm=norm, ref=ref, competency=competency,
metric_key=key,
))
if truth.incident_code:

View file

@ -11,11 +11,14 @@
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
"""
import math
from app.domain.statuses import (
COMMENT_REQUIRED,
PRIMARY,
REFUSAL_COMMENT_REQUIRED,
SERVICE_STATUS_LABELS,
PhoneReportRecord,
ServiceStatus,
StatusEntry,
current,
@ -23,19 +26,113 @@ from app.domain.statuses import (
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.events import Metric
from app.domain.timers import GOST_REF, TimerCode
from app.scoring.timing import TimeNorm, time_metric
MEMO_REF = "памятка «Работа на АРМ-112», раздел «Статусы реагирования»"
#: Статусы, означающие, что реагирование действительно шло.
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
#: Статус, который диспетчер отмечает по докладу бригады этой фазы.
REPORT_STATUS = {
"dispatched": ServiceStatus.RESPONDING,
"arrived": ServiceStatus.ARRIVED,
"working": ServiceStatus.WORKING,
"completed": ServiceStatus.COMPLETED,
}
REPORT_LABELS = {
"dispatched": "о выезде",
"arrived": "о прибытии",
"working": "о начале работ",
"completed": "о завершении работ",
}
#: Лимит реакции на доклад по умолчанию; занятие задаёт свой в критериях.
REPORT_REACTION_MS = 45_000
#: Реакция на доклад — норматив времени, как отработка карточки, но отметка
#: D6 из памятки: опоздавший статус — тот же неполный ход работ.
DDS_REPORT_REACTION = TimeNorm(
key="dds_report_reaction",
title="реакция на доклад бригады",
summary="статус по докладу бригады отмечен с нарушением норматива",
action="отметить статус по докладу",
unmeasured="не отмечен",
incomplete="не отмечен",
code=ErrorCode.D6,
source=FindingSource.DISPATCHER,
ref=MEMO_REF,
)
#: Терминальный отказ: работ не было, поэтому неотмеченный по докладу статус
#: не штрафуется ни отметкой, ни метрикой.
TERMINAL_REFUSAL = {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
def _finding(code: ErrorCode, summary: str, fact: str, norm: str | None = None) -> Finding:
def report_reactions(
marks: list[StatusEntry], reports: list[PhoneReportRecord],
) -> list[tuple[PhoneReportRecord, int | None]]:
"""Задержка статуса после каждого доклада бригады, мс; None — статус не отмечен.
Отсчёт — от серверного времени доклада. Статус, отмеченный раньше доклада,
задержки не имеет: диспетчер узнал о ходе работ по другому каналу.
"""
result: list[tuple[PhoneReportRecord, int | None]] = []
for report in reports:
status = REPORT_STATUS.get(report.phase)
if status is None:
continue
times = [mark.at for mark in marks if mark.status is status]
if any(at <= report.at for at in times):
result.append((report, 0))
continue
after = min(times, default=None)
result.append((
report,
int((after - report.at).total_seconds() * 1000) if after is not None else None,
))
return result
def report_reaction(
service: str, marks: list[StatusEntry], reports: list[PhoneReportRecord], limit_ms: int,
) -> tuple[Metric, Finding | None] | None:
"""Метрика и отметка D6 по самой поздней реакции службы на доклады бригады.
Одна функция для отметок и для балла: иначе метрика провалится там,
где отметки нет. None — реагировать было не на что.
"""
refused = current(marks, service) in TERMINAL_REFUSAL
reactions = [(report, delay) for report, delay in report_reactions(marks, reports)
if delay is not None or not refused]
if not reactions:
return None
report, delay = max(reactions, key=lambda item: math.inf if item[1] is None else item[1])
metric, finding = time_metric(DDS_REPORT_REACTION, delay, limit_ms, completed=True)
label = SERVICE_STATUS_LABELS[REPORT_STATUS[report.phase]]
detail = "отмечен не позже доклада" if delay == 0 else metric.fact
fact = (f"статус «{label}» по докладу бригады «{report.crew}» "
f"{REPORT_LABELS[report.phase]}: {detail}")
if len(reactions) > 1:
late = sum(item is None or item > limit_ms for _, item in reactions)
fact += f"; с нарушением {late} из {len(reactions)} докладов"
metric = metric.model_copy(update={
"title": f"{service}: {metric.title}", "fact": fact, "service": service,
})
if finding is not None:
finding = finding.model_copy(update={
"summary": f"{service}: {finding.summary}", "fact": fact, "service": service,
})
return metric, finding
def _finding(service: str, code: ErrorCode, summary: str, fact: str,
norm: str | None = None) -> Finding:
return Finding(
service=service,
code=code,
source=FindingSource.DISPATCHER,
summary=summary,
fact=fact,
norm=norm,
ref="памятка «Работа на АРМ-112», раздел «Статусы реагирования»",
ref=MEMO_REF,
competency=(
Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD
),
@ -91,6 +188,8 @@ def evaluate_dispatcher(
reply_text: str = "",
expected_decision: str = "accept",
expected_decision_reason: str | None = None,
phone_reports: list[PhoneReportRecord] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
) -> list[Finding]:
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
@ -117,7 +216,7 @@ def evaluate_dispatcher(
]
if missing_comment_statuses:
findings.append(_finding(
ErrorCode.D5,
service, ErrorCode.D5,
f"{service}: к статусу не добавлены основание и сведения",
fact="не заполнены комментарии: " + ", ".join(missing_comment_statuses),
norm="к каждой ручной отметке добавить основание и содержание полученных сведений",
@ -132,7 +231,7 @@ def evaluate_dispatcher(
]
if incomplete_comment_statuses:
findings.append(_finding(
ErrorCode.D5,
service, ErrorCode.D5,
f"{service}: комментарии к статусам не разделяют основание и сведения",
fact="неполные комментарии: " + ", ".join(incomplete_comment_statuses),
norm="в каждом комментарии заполнить отдельные поля «Основание» и «Сведения»",
@ -152,7 +251,7 @@ def evaluate_dispatcher(
]
if missing_recipients:
findings.append(_finding(
ErrorCode.D5,
service, ErrorCode.D5,
f"{service}: отдельный комментарий не указывает получателя сведений",
fact="; ".join(missing_recipients)[:500],
norm=(
@ -166,7 +265,7 @@ def evaluate_dispatcher(
if primary is None:
findings.append(
_finding(
ErrorCode.D1,
service, ErrorCode.D1,
f"{service}: статус реагирования не проставлен",
fact=(
f"прошло {elapsed_ms // 1000} с"
@ -181,7 +280,7 @@ def evaluate_dispatcher(
if elapsed_ms is None or elapsed_ms > deadline_ms:
findings.append(
_finding(
ErrorCode.D1,
service, ErrorCode.D1,
f"{service}: первичный статус проставлен с нарушением срока",
fact=(
f"прошло {elapsed_ms // 1000} с"
@ -197,7 +296,7 @@ def evaluate_dispatcher(
if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
findings.append(
_finding(
ErrorCode.D4,
service, ErrorCode.D4,
f"{service}: «{SERVICE_STATUS_LABELS[mark.status]}» без комментария",
fact="причина отказа не указана",
norm="комментарий к отказу обязателен",
@ -209,7 +308,7 @@ def evaluate_dispatcher(
if latest is ServiceStatus.DECLINED and expected_decision == "accept":
findings.append(
_finding(
ErrorCode.D3,
service, ErrorCode.D3,
f"{service}: отказ от происшествия, которое в её компетенции",
fact="служба есть в списке оповещения по ЕКП",
norm="принять согласно эталону сценария",
@ -218,7 +317,7 @@ def evaluate_dispatcher(
elif latest is ServiceStatus.ACCEPTED and expected_decision == "decline":
findings.append(
_finding(
ErrorCode.D2,
service, ErrorCode.D2,
f"{service}: принято вопреки эталону сценария",
fact="карточка принята службой",
norm=expected_decision_reason or "отказать с указанной причиной",
@ -232,7 +331,7 @@ def evaluate_dispatcher(
if service not in crew_assignments:
findings.append(
_finding(
ErrorCode.D2,
service, ErrorCode.D2,
f"{service}: «Принята» без назначения бригады и хода реагирования",
fact="бригада не выбрана, после приёма статусов не было",
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
@ -241,7 +340,7 @@ def evaluate_dispatcher(
else:
findings.append(
_finding(
ErrorCode.D2,
service, ErrorCode.D2,
f"{service}: «Принята», но о реагировании ничего не отмечено",
fact="после приёма статусов не было",
norm="статус должен соответствовать фактическому состоянию заявки",
@ -252,7 +351,7 @@ def evaluate_dispatcher(
) and service not in crew_assignments:
findings.append(
_finding(
ErrorCode.D2,
service, ErrorCode.D2,
f"{service}: ход реагирования без назначения бригады",
fact="статусы хода работ проставлены, бригада не выбрана",
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
@ -262,15 +361,18 @@ def evaluate_dispatcher(
# D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке
# это приводит к повторным звонкам и скрывает от других служб факт реакции.
# REFUSED — отдельный допустимый терминальный статус с обязательной причиной.
reports = [report for report in phone_reports or () if report.service == service]
missing_progress = [status for status in PROGRESS if status not in {m.status for m in marks}]
if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
and (missing_progress or latest is not ServiceStatus.COMPLETED)):
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
reported_done = any(report.phase == "completed" for report in reports)
findings.append(
_finding(
ErrorCode.D6,
service, ErrorCode.D6,
f"{service}: ход реагирования не доведён до конца",
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"),
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта")
+ ("; бригада доложила о завершении работ" if reported_done else ""),
norm=(
"отметить по факту начало реагирования, прибытие, проведение работ "
"и завершение; если работы не проводились — оформить отказ с причиной"
@ -278,6 +380,11 @@ def evaluate_dispatcher(
)
)
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
reaction = report_reaction(service, marks, reports, report_reaction_ms)
if reaction is not None and reaction[1] is not None:
findings.append(reaction[1])
return findings
@ -288,6 +395,7 @@ def dispatcher_metrics(
expected_decision_reason: str | None = None,
*,
services: list[str] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
@ -304,7 +412,7 @@ def dispatcher_metrics(
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
metrics.append(Metric(
key=key, title=f"{service}: {title}", fact=fact, norm=norm,
key=key, title=f"{service}: {title}", fact=fact, norm=norm, service=service,
ref=GOST_REF if key == "dds_ack" else "памятка «Работа на АРМ-112», статусы реагирования",
passed=passed, weight=weight,
))
@ -318,6 +426,12 @@ def dispatcher_metrics(
f"≤ {deadline_ms // 1000} с")
if primary is None:
continue
# Метрика есть только там, где бригада докладывала: без докладов
# реагировать не на что, и отсутствие звонков не штраф.
reports = [report for report in card.phone_reports if report.service == service]
reaction = report_reaction(service, marks, reports, report_reaction_ms)
if reaction is not None:
metrics.append(reaction[0])
expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"
else ServiceStatus.DECLINED)
expected_label = "Принята" if expected_decision == "accept" else "Не принята"

View file

@ -11,6 +11,7 @@ from html import escape
from pathlib import Path
from app.domain.events import SessionReport
from app.domain.taxonomy import Finding, FindingDecision, FindingSource
def _cell(value: object) -> str:
@ -18,12 +19,27 @@ def _cell(value: object) -> str:
if value is None:
return ""
result = str(value)
probe = result.lstrip(" \t\r\n\ufeff\u200b")
probe = result.lstrip(" \t\r\n\N{ZERO WIDTH NO-BREAK SPACE}\N{ZERO WIDTH SPACE}")
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
return "'" + result
return result
def _finding_origin(item: Finding) -> str:
if item.source is FindingSource.INSTRUCTOR:
return f"; добавлена преподавателем {item.author or ''}".rstrip()
return ""
def _finding_decision(item: Finding) -> str:
"""Решения по отметке рядом с ней, все по порядку: действует последнее."""
return "".join(
f"; {'снята' if review.decision is FindingDecision.DISMISSED else 'подтверждена'} "
f"преподавателем {review.author}: {review.reason}"
for review in item.reviews
)
def to_csv(report: SessionReport) -> bytes:
"""UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки."""
output = io.StringIO(newline="")
@ -38,12 +54,15 @@ def to_csv(report: SessionReport) -> bytes:
row("Занятие", "", "Режим", report.mode.value)
row("Занятие", "", "Попытка", report.attempt)
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
row("Критерии", "", "Лимит реакции на доклад бригады, с",
report.criteria.dds_report_reaction_limit_seconds)
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
row("Критерии", "", "Нарушенных метрик", report.failed_metrics)
row("Критерии", "", "Зачёт", "да" if report.passed else "нет")
row("Оценка", "", "Автоматическая", report.score_auto)
row("Оценка", "", "После разбора отметок", report.score_reviewed)
row("Оценка", "", "Итоговая", report.score_final)
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
row("Оценка", "", "Причина изменения", report.override_comment)
@ -54,7 +73,9 @@ def to_csv(report: SessionReport) -> bytes:
f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; "
f"вес: {item.weight:g}{credit}; источник: {item.ref or ''}")
for number, item in enumerate(report.findings, 1):
row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}")
row("Ошибки", number, item.code.value, item.summary,
f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}"
f"{_finding_origin(item)}{_finding_decision(item)}")
for number, item in enumerate(report.competencies, 1):
row("Компетенции", number, item.competency, item.value)
for number, item in enumerate(report.transcript, 1):
@ -77,7 +98,7 @@ def to_csv(report: SessionReport) -> bytes:
row("Сверка самооценки", "", label, ", ".join(values))
for number, item in enumerate(report.notes, 1):
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
return ("\ufeff" + output.getvalue()).encode("utf-8")
return ("\N{ZERO WIDTH NO-BREAK SPACE}" + output.getvalue()).encode("utf-8")
def _font_paths() -> tuple[Path, Path | None]:
@ -151,6 +172,7 @@ def to_pdf(report: SessionReport) -> bytes:
("Режим", report.mode.value),
("Попытка", report.attempt),
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
("Лимит реакции на доклад бригады", f"{report.criteria.dds_report_reaction_limit_seconds} с"),
("Допустимо ошибок", report.criteria.allowed_errors),
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
("Переопределения весов", report.criteria.score_weights or "стандартные"),
@ -160,6 +182,9 @@ def to_pdf(report: SessionReport) -> bytes:
("Итоговая оценка", report.score_final),
):
story.append(Paragraph(f"<b>{escape(label)}:</b> {escape(str(value))}", base))
# Когда итог правили вручную, без этой строки не понять, какой балл дал разбор.
if report.score_reviewed is not None:
story.append(Paragraph(f"<b>После разбора отметок:</b> {report.score_reviewed}", base))
if report.overridden_by:
story.append(Paragraph(f"<b>Оценка изменена:</b> {escape(report.overridden_by)}", base))
if report.override_comment:
@ -183,7 +208,9 @@ def to_pdf(report: SessionReport) -> bytes:
story.append(p("Ошибок не выявлено", muted))
for item in report.findings:
story.append(p(f"{item.code.value} - {item.summary}"))
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. Источник: {item.ref or 'не указан'}", muted))
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. "
f"Источник: {item.ref or 'не указан'}"
f"{_finding_origin(item)}{_finding_decision(item)}", muted))
section("Компетенции")
for item in report.competencies:

View file

@ -73,6 +73,7 @@ class _Builder:
norm=norm,
ref=ref,
competency=competency,
metric_key=key,
)
)
@ -174,6 +175,7 @@ def evaluate(
norm=f"эталонный вопрос: «{question}»" if question else "обязательный факт сценария",
ref="чек-лист сценария",
competency=Competency.INTERVIEW,
metric_key="checklist_completeness",
)
)

View file

@ -106,6 +106,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
self_assessment_diff=difference,
notes=[InstructorNoteShown.model_validate(note) for note in state.notes],
score_auto=score.get("score_auto", 0.0),
score_reviewed=score.get("score_reviewed"),
score_final=score.get("score_final", score.get("score_auto", 0.0)),
overridden_by=score.get("overridden_by"),
override_comment=score.get("override_comment"),

View file

@ -0,0 +1,193 @@
"""Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку.
Баллы по-прежнему считает код. Преподаватель меняет входы — какие отметки
действуют, — а не формулу: балл пересчитывается теми же метриками и весами.
Связь «отметка → метрика» — методика `METRIC_MAP` в пределах карточки и службы;
если генератор знает метрику точно, она записана в `Finding.metric_key`.
Функции чистые и работают со словарём сохранённой оценки: живое занятие,
строка `Score` и архивный разбор меняются одним и тем же путём, как правка
итога в `apply_score_override`.
"""
from app.domain.events import Metric
from app.domain.taxonomy import Finding, FindingDecision, FindingReview, FindingSource
from app.scoring.competency import radar
from app.scoring.gost import GostResult
from app.scoring.taxonomy import INSTRUCTOR_METRIC, METRIC_MAP
__all__ = ["INSTRUCTOR_METRIC", "add_finding", "has_finding", "linked", "review_finding",
"reviewed_metrics"]
def _in_scope(finding: Finding, metric: Metric) -> bool:
if finding.card != metric.card:
return False
return finding.service is None or metric.service is None or finding.service == metric.service
def linked(finding: Finding, metric: Metric) -> bool:
"""Отметка объясняет провал этой метрики."""
if not _in_scope(finding, metric):
return False
if finding.metric_key is not None:
return finding.metric_key == metric.key
mapping = METRIC_MAP.get(metric.key)
return mapping is not None and mapping[0] is finding.code
def _dismissed(finding: Finding) -> bool:
return finding.review is not None and finding.review.decision is FindingDecision.DISMISSED
def _credit(metric: Metric) -> float:
return metric.credit if metric.credit is not None else float(metric.passed)
def reviewed_metrics(metrics: list[Metric], findings: list[Finding],
instructor_weight: float = 1.0) -> list[Metric]:
"""Метрики с учётом решений преподавателя.
Проваленная метрика засчитывается, только если все её автоматические
отметки сняты. Ручная отметка проваливает связанные метрики; если их нет,
появляется отдельная метрика преподавателя. Исходный вердикт остаётся в
`passed_auto`/`credit_auto`, повторный пересчёт начинается с него.
"""
base = [
metric.model_copy(update={
"passed": metric.passed_auto if metric.passed_auto is not None else metric.passed,
"credit": metric.credit_auto if metric.passed_auto is not None else metric.credit,
"passed_auto": None,
"credit_auto": None,
})
for metric in metrics
if metric.key != INSTRUCTOR_METRIC
]
result: list[Metric] = []
for metric in base:
links = [finding for finding in findings if linked(finding, metric)]
manual = [finding for finding in links
if finding.source is FindingSource.INSTRUCTOR and not _dismissed(finding)]
automatic = [finding for finding in links if finding.source is not FindingSource.INSTRUCTOR]
if manual and _credit(metric) > 0:
metric = metric.model_copy(update={
"passed": False, "credit": 0.0,
"passed_auto": metric.passed, "credit_auto": metric.credit,
})
elif (not manual and _credit(metric) < 1 and automatic
and all(_dismissed(finding) for finding in automatic)):
metric = metric.model_copy(update={
"passed": True, "credit": 1.0,
"passed_auto": metric.passed, "credit_auto": metric.credit,
})
result.append(metric)
for finding in findings:
if (finding.source is FindingSource.INSTRUCTOR and not _dismissed(finding)
and not any(linked(finding, metric) for metric in base)):
result.append(Metric(
key=INSTRUCTOR_METRIC,
title=f"Отметка преподавателя: {finding.summary}",
fact=finding.fact,
norm=finding.norm or "по решению преподавателя",
ref=finding.ref,
passed=False,
weight=instructor_weight,
card=finding.card,
service=finding.service,
))
return result
def _instructor_weight(report: dict) -> float:
"""Вес метрики преподавателя из весов занятия: веса настраивает преподаватель."""
criteria = (report.get("full_report") or {}).get("criteria") or {}
return float((criteria.get("score_weights") or {}).get(INSTRUCTOR_METRIC, 1.0))
def _codes(findings: list[Finding]) -> dict[str, int]:
"""Коды действующих отметок: по ним строятся частые ошибки группы и профиль."""
codes: dict[str, int] = {}
for finding in findings:
if not _dismissed(finding):
codes[finding.code.value] = codes.get(finding.code.value, 0) + 1
return codes
def _cards(cards: list[dict], metrics: list[Metric], findings: list[Finding]) -> list[dict]:
"""Карточки очереди ДДС с тем же разбором: их метрики — часть метрик занятия."""
updated = []
for number, card in enumerate(cards, 1):
own = [metric for metric in metrics if metric.card == number]
updated.append({
**card,
"metrics": [metric.model_dump(mode="json") for metric in own],
"findings": [finding.model_dump(mode="json")
for finding in findings if finding.card == number],
"score_reviewed": GostResult(metrics=own).score,
})
return updated
def _rescore(report: dict, findings: list[Finding]) -> dict:
metrics = reviewed_metrics(
[Metric.model_validate(item) for item in report.get("metrics", [])], findings,
_instructor_weight(report),
)
score = GostResult(metrics=metrics).score
fields = {
"findings": [finding.model_dump(mode="json") for finding in findings],
"metrics": [metric.model_dump(mode="json") for metric in metrics],
"competencies": [item.model_dump() for item in radar(metrics)],
"score_reviewed": score,
}
# Правка итога — последнее слово преподавателя; решение по отметке её не отменяет.
if not report.get("overridden_by"):
fields["score_final"] = score
updated = {**report, **fields}
if "summary" in report:
updated["summary"] = {**(report.get("summary") or {}), "codes": _codes(findings)}
if report.get("card_results"):
updated["card_results"] = _cards(report["card_results"], metrics, findings)
archived = report.get("full_report")
if isinstance(archived, dict):
failed = sum(not metric.passed and metric.weight > 0 for metric in metrics)
allowed = (archived.get("criteria") or {}).get("allowed_errors", 0)
updated["full_report"] = {
**archived, **fields,
"card_results": _cards(archived.get("card_results") or [], metrics, findings),
"failed_metrics": failed,
"passed": failed <= allowed,
"score_final": updated.get("score_final", archived.get("score_final")),
}
return updated
def _findings(report: dict) -> list[Finding]:
return [Finding.model_validate(item) for item in report.get("findings", [])]
def review_finding(report: dict, index: int, review: FindingReview) -> dict:
"""Подтвердить или снять отметку. Решение дописывается, действует последнее."""
findings = _findings(report)
if not 0 <= index < len(findings):
raise LookupError(f"нет отметки {index}")
findings[index] = findings[index].model_copy(
update={"reviews": [*findings[index].reviews, review]})
return _rescore(report, findings)
def has_finding(report: dict, client_id) -> bool:
"""Ручная отметка с этим ключом уже в разборе — повтор запроса."""
return client_id is not None and any(
finding.client_id == client_id for finding in _findings(report))
def add_finding(report: dict, finding: Finding) -> dict:
"""Добавить отметку преподавателя; она штрафует связанную метрику."""
if finding.source is not FindingSource.INSTRUCTOR:
raise ValueError("вручную добавляется только отметка преподавателя")
# Повтор (ретрай сети, вторая вкладка) не штрафует второй раз.
if has_finding(report, finding.client_id):
return report
return _rescore(report, [*_findings(report), finding])

View file

@ -31,10 +31,16 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
"dds_progress": (ErrorCode.D6, Competency.CARD),
"dds_completion": (ErrorCode.D6, Competency.CARD),
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
"dds_report_reaction": (ErrorCode.D6, Competency.NORMS),
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
}
#: Метрика ручной отметки, которой нечего провалить (у кода нет метрики в
#: METRIC_MAP или в карточке она не считалась). Кода у неё нет: код несёт сама
#: отметка. Вес задаёт преподаватель в весах занятия, по умолчанию 1.
INSTRUCTOR_METRIC = "instructor_finding"
#: Вес метрики в детерминированной оценке.
#:
#: **Предварительные значения, требуют утверждения методистом.** Без весов все
@ -59,4 +65,5 @@ METRIC_WEIGHTS: dict[str, float] = {
"description_grammar": 1.0,
"card_fill_time": 1.5,
"dds_work_time": 1.5,
"dds_report_reaction": 1.5,
}

View file

@ -23,8 +23,8 @@ def time_credit(elapsed_ms: int | None, limit_ms: int) -> float:
@dataclass(frozen=True)
class TimeNorm:
"""Формулировки норматива времени: карточка 112 и карточка ДДС различаются
только словами, расчёт и отметка E3 у них общие."""
"""Формулировки норматива времени: нормативы различаются словами и кодом
отметки, расчёт вклада и формат факта у них общие."""
key: str
title: str
@ -32,6 +32,10 @@ class TimeNorm:
action: str
unmeasured: str
incomplete: str
code: ErrorCode = ErrorCode.E3
source: FindingSource = FindingSource.TIMERS
competency: Competency = Competency.NORMS
ref: str = TIME_REF
CARD_FILL_TIME = TimeNorm(
@ -55,7 +59,7 @@ DDS_WORK_TIME = TimeNorm(
def time_metric(
norm: TimeNorm, elapsed_ms: int | None, limit_ms: int, *, completed: bool
) -> tuple[Metric, Finding | None]:
"""Метрика норматива и отметка E3, если он не выполнен.
"""Метрика норматива и отметка (по умолчанию E3), если он не выполнен.
Незавершённая работа не получает временного вклада: быстрое, но брошенное
заполнение не должно выглядеть скоростью.
@ -64,13 +68,20 @@ def time_metric(
fact = norm.unmeasured
else:
delta_ms = elapsed_ms - limit_ms
# Превышение до полусекунды при округлении до целых читалось бы
# как «45 с (+0 с)» при нормативе 45 с — тогда нужна десятая доля.
precise = 0 < delta_ms <= 500
def seconds(ms: int) -> str:
return f"{ms / 1000:.1f}" if precise else f"{round(ms / 1000)}"
if delta_ms > 0:
deviation = f"+{round(delta_ms / 1000)} с сверх норматива"
deviation = f"+{seconds(delta_ms)} с сверх норматива"
elif delta_ms < 0:
deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива"
else:
deviation = "точно в норматив"
fact = f"{round(elapsed_ms / 1000)} с ({deviation})"
fact = f"{seconds(elapsed_ms)} с ({deviation})"
if not completed:
fact = f"{norm.incomplete}; {fact}"
norm_text = f"{norm.action} за {round(limit_ms / 1000)} с"
@ -80,7 +91,7 @@ def time_metric(
title=norm.title,
fact=fact,
norm=norm_text,
ref=TIME_REF,
ref=norm.ref,
passed=passed,
weight=METRIC_WEIGHTS[norm.key],
credit=time_credit(elapsed_ms, limit_ms) if completed else 0.0,
@ -88,11 +99,12 @@ def time_metric(
if passed:
return metric, None
return metric, Finding(
code=ErrorCode.E3,
source=FindingSource.TIMERS,
code=norm.code,
source=norm.source,
summary=norm.summary,
fact=fact,
norm=norm_text,
ref=TIME_REF,
competency=Competency.NORMS,
ref=norm.ref,
competency=norm.competency,
metric_key=norm.key,
)

View file

@ -446,6 +446,7 @@ class DdsDesk(BaseModel):
services[0], ServiceStatus.ACCEPTED, command.comment,
author="диспетчер",
)
session.consecutive_refusals = 0
except StatusError:
pass # статус уже стоит: повторное нажатие ничего не меняет
case "card.status":
@ -462,6 +463,13 @@ class DdsDesk(BaseModel):
# Первичный статус останавливает норматив 30 секунд.
if command.status in PRIMARY:
card.on_event("card.ack")
# Сигнал реестра "повторные отказы" считает подряд идущие
# отказы, а не сумму за занятие.
session.consecutive_refusals = (
session.consecutive_refusals + 1
if command.status is ServiceStatus.DECLINED
else 0
)
if command.status in {
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
}:

View file

@ -26,11 +26,14 @@ from app.session.hub import hub
from app.session.score import ScoringSnapshot, score_session, scoring_scenario
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
from app.session.store import (
FindingAdded,
FindingReviewed,
LessonEnded,
LessonResumed,
ScoreArchived,
ScoreCalculated,
ScoreOverridden,
apply_finding_change,
apply_score_override,
)
@ -46,6 +49,7 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
services = state.card_services(card)
decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK]
dds_decision = card.scenario.dds_decision
reaction_ms = state.criteria.dds_report_reaction_limit_seconds * 1000
findings = evaluate_dispatcher(
entries=card.status_log,
services=services,
@ -55,10 +59,12 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
reply_text=card.reply_text,
expected_decision=dds_decision.expected,
expected_decision_reason=dds_decision.reason,
phone_reports=card.phone_reports,
report_reaction_ms=reaction_ms,
)
metrics = dispatcher_metrics(
card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
services=services,
services=services, report_reaction_ms=reaction_ms,
)
weighted = GostResult(metrics=metrics, findings=findings)
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]
@ -101,9 +107,11 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
card_id=card.card_id,
scenario_id=card.scenario.id,
reply_text=card.reply_text,
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}"})
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}",
"card": number})
for metric in weighted.metrics],
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"})
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}",
"card": number})
for finding in weighted.findings],
actions=actions,
duration_ms=max(0, int((now_utc() - card.dispatched_at).total_seconds() * 1000)),
@ -247,3 +255,14 @@ def override_score(state, override: ScoreOverridden) -> None:
hub.record(state.session_id, override)
state.score = apply_score_override(state.score, override)
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))
def change_findings(state, change: FindingReviewed | FindingAdded) -> None:
"""Решение преподавателя по отметке — тем же путём, что правка итога.
Вызывается внутри `hub.operation`: отметки, пересчитанный балл, архивный
разбор и строка аудита уходят одним commit.
"""
state.score = apply_finding_change(state.score, change)
hub.record(state.session_id, change)
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))

View file

@ -24,6 +24,7 @@ from app.domain.events import (
)
from app.session.state import SessionState
from app.session.store import MemorySessionStore, Record, SessionLeaseLost, SessionStore
from app.session.timers import now_utc
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
#: очередь ограничена, переполнение роняет соединение, а не сессию.
@ -53,6 +54,9 @@ class SessionHub:
self._observers: dict[UUID, set[asyncio.Queue]] = {}
self._trainees: dict[UUID, set[asyncio.Queue]] = {}
self._stations: dict[UUID, set[asyncio.Queue]] = {}
self._trainee_calls: dict[UUID, set[asyncio.Queue]] = {}
self._trainee_stations: dict[UUID, set[asyncio.Queue]] = {}
self._pending_adoptions: dict[UUID, SessionState] = {}
self._tickers: dict[UUID, asyncio.Task] = {}
# Задачи, порождённые внутри операции, наследуют контекст; после
# закрытия операции их события идут напрямую (`_Operation.open`).
@ -116,6 +120,7 @@ class SessionHub:
def drop(self, session_id: UUID) -> None:
self._sessions.pop(session_id, None)
self._pending_adoptions.pop(session_id, None)
self.stop_ticker(session_id)
def live_count(self) -> int:
@ -135,9 +140,10 @@ class SessionHub:
if not self.store.persistent:
return 0
restored = await self.store.restore_active()
adopted = 0
for state in restored:
self._adopt(state)
return len(restored)
adopted += await self._adopt(state)
return adopted
async def maintain_lease(self) -> None:
"""Один оборот супервизора: продлить свои lease, подхватить просроченные чужие.
@ -154,6 +160,18 @@ class SessionHub:
log.warning("продление lease занятия %s не удалось", state.session_id,
exc_info=True)
await self.fence(state)
for state in list(self._pending_adoptions.values()):
try:
await self.store.commit(state)
except SessionLeaseLost:
self._pending_adoptions.pop(state.session_id, None)
except Exception:
log.warning("повторное сохранение занятия %s после takeover не удалось",
state.session_id, exc_info=True)
else:
self._pending_adoptions.pop(state.session_id, None)
self.register(state)
self.start_ticker(state.session_id)
try:
claimed = await self.store.claim_expired()
except Exception: # следующий оборот попробует снова
@ -163,17 +181,32 @@ class SessionHub:
current = self._sessions.get(state.session_id)
if current is not None and not current.lease_fenced:
continue
self._adopt(state)
await self._adopt(state)
async def supervise_lease(self, interval: float) -> None:
while True:
await asyncio.sleep(interval)
await self.maintain_lease()
def _adopt(self, state: SessionState) -> None:
async def _adopt(self, state: SessionState) -> bool:
self.stop_ticker(state.session_id)
if state.socket_connected_at_checkpoint:
# Разрыв произошёл при потере узла; старое время подключения не
# доказывает, что курсант отсутствовал всё это время.
state.socket_last_seen_at = now_utc()
state.socket_connected_at_checkpoint = False
try:
await self.store.commit(state)
except SessionLeaseLost:
return False
except Exception:
log.exception("не удалось сохранить присутствие после takeover %s", state.session_id)
self._pending_adoptions[state.session_id] = state
return False
self._pending_adoptions.pop(state.session_id, None)
self.register(state)
self.start_ticker(state.session_id)
return True
async def save_all(self) -> None:
"""Снимок живых занятий при остановке узла: следующий владелец продолжит с него."""
@ -285,6 +318,44 @@ class SessionHub:
def station(self, session_id: UUID):
return self._subscribe(self._stations, session_id)
async def _record_presence(self, session_id: UUID, *, station: bool) -> None:
state = self.get(session_id)
if state is None or state.ended or state.dds_phase != station:
return
async with self.operation(session_id):
state.socket_last_seen_at = now_utc()
present = self._trainee_stations if station else self._trainee_calls
state.socket_connected_at_checkpoint = bool(present.get(session_id))
@contextlib.asynccontextmanager
async def trainee_socket(
self, session_id: UUID, *, station: bool, trainee: bool,
) -> AsyncIterator[asyncio.Queue]:
"""Учесть только сокет курсанта; вещание преподавателю остаётся общим."""
registry = self._stations if station else self._trainees
presence = self._trainee_stations if station else self._trainee_calls
with self._subscribe(registry, session_id) as queue:
if trainee:
presence.setdefault(session_id, set()).add(queue)
try:
if trainee:
try:
await self._record_presence(session_id, station=station)
except Exception:
if not self.is_lease_fenced(session_id):
raise
yield queue
finally:
if trainee:
presence[session_id].discard(queue)
if not presence[session_id]:
presence.pop(session_id)
try:
await self._record_presence(session_id, station=station)
except Exception:
if not self.is_lease_fenced(session_id):
raise
# ── вещание ──
@staticmethod
@ -314,6 +385,12 @@ class SessionHub:
def observer_count(self, session_id: UUID) -> int:
return len(self._observers.get(session_id, set()))
def station_connected(self, session_id: UUID) -> bool:
return bool(self._trainee_stations.get(session_id))
def trainee_connected(self, session_id: UUID) -> bool:
return bool(self._trainee_calls.get(session_id))
# ── такт таймеров ──
def start_ticker(self, session_id: UUID) -> None:

View file

@ -29,6 +29,8 @@ from app.domain.events import Mood, Speaker, TranscriptEntry
from app.session.checkpoint import dump_state, load_state
from app.session.state import SessionState, now_utc
from app.session.store import (
FindingAdded,
FindingReviewed,
HintRecorded,
LessonEnded,
LessonIdentity,
@ -44,8 +46,10 @@ from app.session.store import (
SelfAssessed,
SessionLeaseLost,
UtteranceAppended,
apply_finding_change,
apply_score_override,
)
from app.scoring.review import has_finding
log = logging.getLogger(__name__)
LEASE_SECONDS = 15
@ -251,6 +255,40 @@ class PostgresSessionStore:
detail=(f"{score.score_auto} → {record.score_final}; "
f"comment_chars={len(record.comment)}"),
))
case FindingReviewed() | FindingAdded():
await db.flush()
score = await db.scalar(
select(Score).where(Score.session_id == session_id).with_for_update()
)
if score is None:
raise LookupError(f"нет оценки занятия {session_id}")
# Повтор ручной отметки под блокировкой: ни изменения, ни второй строки аудита.
if (isinstance(record, FindingAdded)
and has_finding(score.report or {}, record.finding.client_id)):
return
before = score.score_final
findings = (score.report or {}).get("findings", [])
score.report = apply_finding_change(dict(score.report or {}), record)
score.score_final = score.report.get("score_final", score.score_final)
if isinstance(record, FindingReviewed):
code = (findings[record.index].get("code", "?")
if 0 <= record.index < len(findings) else "?")
actor, action = record.review.author, "finding.review"
what = (f"#{record.index} {code} {record.review.decision.value}; "
f"причина: {record.review.reason}")
else:
finding = record.finding
actor, action = finding.author or "instructor", "finding.add"
where = f", карточка {finding.card}" if finding.card is not None else ""
what = (f"{finding.code.value}{where}; факт: {finding.fact}; "
f"норма: {finding.norm or ''}")
# Решение по отметке меняет чужой балл: аудит сам по себе должен
# ответить «кто, когда, что и почему», даже если разбор потом правили.
db.add(AuditLog(
actor=actor, role=record.role, action=action,
object_id=str(session_id),
detail=f"{what}; {before} → {score.score_final}",
))
await db.flush()
@staticmethod

View file

@ -210,6 +210,7 @@ async def score_session(
norm="грамматически корректное описание",
ref="критерий занятия; правила русского языка",
competency=Competency.COMMUNICATION,
metric_key="description_grammar",
))
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
@ -218,6 +219,7 @@ async def score_session(
if live_card is not None:
decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK]
services = list(snapshot.call_card_services)
reaction_ms = snapshot.criteria.dds_report_reaction_limit_seconds * 1000
result.findings.extend(evaluate_dispatcher(
entries=live_card.status_log,
services=services,
@ -227,10 +229,12 @@ async def score_session(
reply_text=live_card.reply_text,
expected_decision=scenario.dds_decision.expected,
expected_decision_reason=scenario.dds_decision.reason,
phone_reports=live_card.phone_reports,
report_reaction_ms=reaction_ms,
))
result.metrics.extend(dispatcher_metrics(
live_card, decision_limit_ms, scenario.dds_decision.expected,
scenario.dds_decision.reason, services=services,
scenario.dds_decision.reason, services=services, report_reaction_ms=reaction_ms,
))
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
# занятия. Веса первого сценария поверх них испортили бы остальные.

View file

@ -130,6 +130,15 @@ class PersistedSession(BaseModel):
#: a lost WebSocket acknowledgement cannot apply an operation twice.
processed_station_commands: list[str] = Field(default_factory=list)
text_revealed_facts: dict[str, str] = Field(default_factory=dict)
#: Подряд идущих первичных отказов без принятой карточки между ними —
#: сигнал реестра преподавателя, не влияет на балл.
consecutive_refusals: int = 0
#: Последнее подключение/отключение сокета курсанта или начало фазы ДДС.
#: Сохраняется вместе с занятием, чтобы чтение реестра не меняло состояние.
socket_last_seen_at: datetime | None = None
#: На момент последнего checkpoint курсант был подключён на текущем АРМ.
#: После аварии узла момент потери связи неизвестен: окно начинается при takeover.
socket_connected_at_checkpoint: bool = False
@field_validator("processed_station_commands")
@classmethod
@ -241,6 +250,9 @@ class SessionState(PersistedSession):
self.dispatched_at = now_utc()
if self.exercise is Exercise.CALL:
self._receive_call_card()
if self.handoff_to_dds:
self.socket_last_seen_at = self.dispatched_at
self.socket_connected_at_checkpoint = False
return self.dispatched_card
def _receive_call_card(self) -> None:

View file

@ -16,6 +16,8 @@ from typing import Protocol
from uuid import UUID
from app.domain.events import TranscriptEntry
from app.domain.taxonomy import Finding, FindingReview
from app.scoring.review import add_finding, review_finding
from app.session.checkpoint import dump_state
from app.session.state import SessionState
@ -90,6 +92,14 @@ class LessonPaused:
role: str
@dataclass(frozen=True)
class FindingReviewed:
"""Преподаватель подтвердил или снял отметку разбора."""
index: int
review: FindingReview
role: str
@dataclass(frozen=True)
class LessonResumed:
at: datetime
@ -99,9 +109,23 @@ class LessonResumed:
paused_ms: int
@dataclass(frozen=True)
class FindingAdded:
"""Преподаватель добавил свою отметку (`source=instructor`)."""
finding: Finding
role: str
Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden
| LessonPaused | LessonResumed)
| LessonPaused | LessonResumed | FindingReviewed | FindingAdded)
def apply_finding_change(report: dict, change: "FindingReviewed | FindingAdded") -> dict:
"""Решение по отметке одинаково для живой сессии, строки `Score` и архива."""
if isinstance(change, FindingReviewed):
return review_finding(report, change.index, change.review)
return add_finding(report, change.finding)
def apply_score_override(report: dict, override: ScoreOverridden) -> dict:
@ -217,6 +241,10 @@ class MemorySessionStore:
if session_id not in self.scores:
raise LookupError(f"нет оценки занятия {session_id}")
self.scores[session_id] = apply_score_override(self.scores[session_id], record)
elif isinstance(record, FindingReviewed | FindingAdded):
if session_id not in self.scores:
raise LookupError(f"нет оценки занятия {session_id}")
self.scores[session_id] = apply_finding_change(self.scores[session_id], record)
self.records.setdefault(session_id, []).extend(records)
def snapshot(self, session_id: UUID) -> dict | None:

View file

@ -12,6 +12,20 @@ from pathlib import Path
from urllib.parse import urlparse
os.environ.setdefault("DEV_AUTH_BYPASS", "true")
# Тесты ждут локальную модель по умолчанию. Личный backend/.env с облачным
# провайдером иначе валит проверки офлайн-гарда; переменные окружения
# перекрывают .env, а живые проверки test_llm.py выставляют свои поверх.
# Рекомендации Vikhr выключены: на WSL2 подключение к закрытому loopback-порту
# висит до таймаута, и финиш занятия ждал бы 6 с. Тесты рекомендаций
# подменяют coach сами.
os.environ.update({
"LLM_PROVIDER": "local",
"LLM_BASE_URL": "http://127.0.0.1:18080/v1",
"LLM_API_KEY": "",
"LLM_MODEL_CALLER": "Qwen3-1.7B",
"LLM_MODEL_JUDGE": "",
"ASSESSMENT_FEEDBACK_ENABLED": "false",
})
import pytest # noqa: E402
@ -36,6 +50,19 @@ def postgres_access():
pytest.skip(f"PostgreSQL unavailable ({exc})")
@pytest.fixture(scope="module")
def template_caller():
"""Звонящий без модели: сквозные тесты занятия ждут заготовки и офлайн-
ответы. С моделью по умолчанию (loopback Qwen) фабрика выбрала бы
LlmCaller и ждала неподнятый сервер."""
from app.dialog import factory
settings = get_settings().model_copy(update={"llm_provider": "disabled"})
with pytest.MonkeyPatch.context() as patch:
patch.setattr(factory, "get_settings", lambda: settings)
yield
@pytest.fixture(autouse=True, scope="session")
def _dev_auth():
"""Флаг обхода читается один раз при создании настроек."""

View file

@ -272,6 +272,40 @@ def test_peer_node_generation_sync_closes_revoked_websocket(monkeypatch):
asyncio.run(run())
def test_generation_sync_keeps_revocation_of_login_missing_from_db(monkeypatch):
# Демо-логины синтетические, в users их нет. Сброс поколения к 0 после
# выхода вернул бы силу cookie, выданной до выхода.
login = "demo-instructor"
class FakeResult:
def all(self):
return []
class FakeDb:
async def execute(self, _query):
return FakeResult()
class FakeSession:
async def __aenter__(self):
return FakeDb()
async def __aexit__(self, *_args):
return None
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
auth.prime_generations({})
who = auth._demo_principal(Role.INSTRUCTOR)
request = SimpleNamespace(session={})
auth._issue_session(request, who)
stale = dict(request.session)
auth.invalidate_login(login)
asyncio.run(auth.sync_generations())
assert auth._session_principal(stale) is None
auth._generations.pop(login, None)
def test_revocation_does_not_log_error_if_socket_already_disconnected():
class DisconnectedSocket:
async def close(self, **_kwargs):

View file

@ -2,7 +2,7 @@
import asyncio
import time
from datetime import datetime
from datetime import datetime, timedelta
from uuid import uuid4
import pytest
@ -396,6 +396,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
client,
criteria={
"decision_time_limit_seconds": 45,
"dds_report_reaction_limit_seconds": 20,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
@ -454,6 +455,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
"decision_time_limit_seconds": 45,
"card_fill_time_limit_seconds": 180,
"dds_card_work_time_limit_seconds": 180,
"dds_report_reaction_limit_seconds": 20,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
@ -797,6 +799,118 @@ def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
]
assert all(report.crew == crew for report in state.desk.active.phone_reports)
assert not score["findings"]
reaction = [m for m in score["metrics"] if m["key"] == "dds_report_reaction"]
assert len(reaction) == 1 and reaction[0]["passed"], "каждый доклад отмечен вовремя"
assert "с нарушением 0 из 4 докладов" in reaction[0]["fact"]
finally:
control.__exit__(None, None, None)
STATUS_COMMENT = "Основание: доклад бригады.\nСведения: передано старшему бригады."
def accept_with_crew(station):
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
crew = next(option for option in snapshot["crew_options"]
if option.startswith(service + " — "))
station.send_json({"type": "card.status", "service": service, "status": "accepted",
"comment": STATUS_COMMENT})
read_until(station, "station.state")
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
return service
def send_status(station, service, status):
station.send_json({"type": "card.status", "service": service, "status": status,
"comment": STATUS_COMMENT})
read_until(station, "station.state")
@pytest.mark.parametrize(("reaction", "passed"), [
("fast", True), ("slow", False), ("missing", False), ("early", True),
])
def test_dds_report_reaction_goes_from_criteria_to_report(client, reaction, passed):
"""Лимит из `scenario.start` доходит до метрики, отметки D6 и отчёта."""
session_id, control = start(client, criteria={
"dds_report_reaction_limit_seconds": 20,
"score_weights": {"dds_report_reaction": 3.5},
})
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
service = accept_with_crew(station)
if reaction == "early":
send_status(station, service, "responding")
complete_phone_call(station, state, "dispatched")
read_until(station, "station.state")
if reaction in {"fast", "slow"}:
send_status(station, service, "responding")
if reaction == "slow":
# Доклад на 70 с раньше статуса: время доклада — серверное.
state.desk.active.phone_reports[0].at -= timedelta(seconds=70)
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
metrics = [item for item in score["metrics"] if item["key"] == "dds_report_reaction"]
assert len(metrics) == 1
metric = metrics[0]
assert metric["passed"] is passed
assert metric["weight"] == 3.5
assert metric["norm"] == "отметить статус по докладу за 20 с"
assert "«Начало реагирования» по докладу бригады" in metric["fact"]
findings = [item for item in score["findings"]
if item["metric_key"] == "dds_report_reaction"]
assert [item["code"] for item in findings] == ([] if passed else ["D6"])
assert all(item["competency"] == "norms" for item in findings)
if reaction == "slow":
assert "70 с (+50 с сверх норматива)" in metric["fact"]
assert findings[0]["fact"] == metric["fact"]
if reaction == "missing":
assert metric["fact"].endswith(": не отмечен") and metric["credit"] == 0.0
if reaction == "early":
assert metric["fact"].endswith("отмечен не позже доклада")
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["criteria"]["dds_report_reaction_limit_seconds"] == 20
assert [item for item in report["metrics"]
if item["key"] == "dds_report_reaction"] == metrics
if reaction == "slow":
csv_text = client.get(f"/api/sessions/{session_id}/report.csv").content.decode("utf-8-sig")
assert "Лимит реакции на доклад бригады" in csv_text
assert "70 с (+50 с сверх норматива)" in csv_text
finally:
control.__exit__(None, None, None)
def test_refusal_after_crew_report_does_not_lower_score_without_finding(client):
"""Бригада доложила о прибытии, но работы не проводились: отказ с причиной
— терминальный исход, неотмеченное «Прибытие» не штрафуется."""
session_id, control = start(client)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
service = accept_with_crew(station)
complete_phone_call(station, state, "dispatched")
read_until(station, "station.state")
send_status(station, service, "responding")
complete_phone_call(station, state, "arrived")
read_until(station, "station.state")
send_status(station, service, "refused")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
assert [report.phase for report in state.desk.active.phone_reports] == [
"dispatched", "arrived",
]
metric = next(item for item in score["metrics"] if item["key"] == "dds_report_reaction")
assert metric["passed"], "прибытие при отказе не требуется"
assert "о выезде" in metric["fact"] and "докладов" not in metric["fact"]
assert not [item for item in score["findings"] if item["code"] == "D6"]
finally:
control.__exit__(None, None, None)

View file

@ -1,13 +1,14 @@
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from pydantic import ValidationError
from app.domain.statuses import ServiceStatus, StatusEntry
from app.domain.taxonomy import ErrorCode
from app.domain.statuses import PhoneReportRecord, ServiceStatus, StatusEntry
from app.domain.taxonomy import Competency, ErrorCode, FindingSource
from app.scenarios.loader import load_file
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
from app.scoring.timing import time_credit
from app.session.dds import DdsLiveCard, build_card
from app.session.timers import SessionTimers
@ -294,3 +295,158 @@ def test_reasoned_refusal_after_acceptance_is_a_valid_terminal_path():
deadline_ms=30_000, elapsed_ms=0,
)
assert ErrorCode.D6 not in [finding.code for finding in findings]
# ── Реакция на доклад бригады (lct-37) ──
T0 = datetime(2026, 9, 27, 10, 0, tzinfo=timezone.utc)
COMMENT = "Основание: доклад старшего.\nСведения: передано дежурному."
def at_second(seconds: int) -> datetime:
return T0 + timedelta(seconds=seconds)
def mark(status: ServiceStatus, seconds: int) -> StatusEntry:
return StatusEntry(service=SERVICES[0], status=status, at=at_second(seconds), comment=COMMENT)
def crew_report(phase: str, seconds: int) -> PhoneReportRecord:
return PhoneReportRecord(service=SERVICES[0], crew="Бригада 12", phase=phase,
text="доклад", at=at_second(seconds))
def reaction(status_log, reports, limit_ms=45_000):
card = live_card(status_log=status_log, crew_assignments={SERVICES[0]: "Бригада 12"},
dispatched_at=T0)
card.phone_reports = reports
findings = evaluate_dispatcher(
entries=status_log, services=SERVICES, crew_assignments=card.crew_assignments,
deadline_ms=30_000, elapsed_ms=5_000, phone_reports=reports,
report_reaction_ms=limit_ms,
)
metric = next((m for m in dispatcher_metrics(card, 30_000, services=SERVICES,
report_reaction_ms=limit_ms)
if m.key == "dds_report_reaction"), None)
return [f for f in findings if f.metric_key == "dds_report_reaction"], metric
def full_run(arrived_at: int) -> list[StatusEntry]:
return [mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20),
mark(ServiceStatus.ARRIVED, arrived_at), mark(ServiceStatus.WORKING, arrived_at + 10),
mark(ServiceStatus.COMPLETED, arrived_at + 20)]
def test_status_soon_after_crew_report_passes():
findings, metric = reaction(full_run(arrived_at=110), [crew_report("arrived", 100)])
assert findings == []
assert metric.passed and metric.weight == 1.5
assert metric.fact == (
"статус «Прибытие» по докладу бригады «Бригада 12» о прибытии: "
"10 с (на 35 с быстрее норматива)"
)
assert metric.credit == time_credit(10_000, 45_000)
assert metric.title == f"{SERVICES[0]}: реакция на доклад бригады"
def test_status_later_than_limit_after_crew_report_is_d6_with_fact_and_norm():
findings, metric = reaction(full_run(arrived_at=170), [crew_report("arrived", 100)])
assert [f.code for f in findings] == [ErrorCode.D6]
assert "«Прибытие»" in findings[0].fact and "70 с (+25 с сверх норматива)" in findings[0].fact
assert "о прибытии" in findings[0].fact, "факт ссылается на доклад"
assert findings[0].norm == metric.norm == "отметить статус по докладу за 45 с"
assert findings[0].competency is Competency.NORMS, "отметка и метрика в одной компетенции"
assert findings[0].source is FindingSource.DISPATCHER
assert findings[0].service == SERVICES[0]
assert not metric.passed and metric.fact == findings[0].fact
assert metric.credit == time_credit(70_000, 45_000)
def test_violation_by_a_fraction_of_a_second_is_not_shown_as_the_limit():
status_log = full_run(arrived_at=170)
status_log[2] = status_log[2].model_copy(
update={"at": at_second(100) + timedelta(milliseconds=45_400)},
)
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
assert not metric.passed
assert "45.4 с (+0.4 с сверх норматива)" in findings[0].fact
def test_missing_status_after_crew_report_is_d6():
status_log = [mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20)]
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
assert [f.code for f in findings] == [ErrorCode.D6]
assert "не отмечен" in findings[0].fact and "Бригада 12" in findings[0].fact
assert not metric.passed and metric.credit == 0.0
assert metric.fact == "статус «Прибытие» по докладу бригады «Бригада 12» о прибытии: не отмечен"
def test_metric_takes_the_worst_report_and_counts_violations():
reports = [crew_report("dispatched", 10), crew_report("arrived", 100),
crew_report("working", 175)]
findings, metric = reaction(full_run(arrived_at=170), reports)
assert len(findings) == 1, "одна отметка на службу, как у других нормативов"
assert "о прибытии: 70 с" in metric.fact
assert "с нарушением 1 из 3 докладов" in metric.fact
def test_status_set_before_the_report_and_custom_limit():
findings, metric = reaction(full_run(arrived_at=90), [crew_report("arrived", 100)])
assert findings == [] and metric.passed, "статус раньше доклада — не нарушение"
assert metric.fact.endswith("отмечен не позже доклада") and metric.credit == 1.0
strict, _ = reaction(full_run(arrived_at=130), [crew_report("arrived", 100)], limit_ms=20_000)
assert strict[0].norm == "отметить статус по докладу за 20 с"
def test_no_crew_reports_means_no_reaction_metric():
findings, metric = reaction(full_run(arrived_at=110), [])
assert findings == [] and metric is None
def test_refusal_after_crew_report_is_neither_d6_nor_failed_metric():
status_log = [
mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20),
StatusEntry(service=SERVICES[0], status=ServiceStatus.REFUSED, at=at_second(120),
comment="Основание: доклад старшего.\nСведения: бригаде отказано в доступе."),
]
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
assert findings == [], "работ не было — статус «Прибытие» отмечать не нужно"
assert metric is None, "метрика не снижает балл без отметки"
def test_refusal_keeps_late_reaction_on_reported_status():
status_log = [
mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 80),
StatusEntry(service=SERVICES[0], status=ServiceStatus.REFUSED, at=at_second(120),
comment="Основание: доклад старшего.\nСведения: бригаде отказано в доступе."),
]
findings, metric = reaction(status_log, [crew_report("dispatched", 10),
crew_report("arrived", 100)])
assert [f.code for f in findings] == [ErrorCode.D6]
assert not metric.passed and "о выезде: 70 с" in metric.fact
assert "докладов" not in metric.fact, "неотмеченное прибытие при отказе не считается"
def test_unfinished_work_after_completion_report_names_the_report():
status_log = full_run(arrived_at=110)[:-1]
findings = evaluate_dispatcher(
entries=status_log, services=SERVICES, crew_assignments={SERVICES[0]: "Бригада 12"},
deadline_ms=30_000, elapsed_ms=5_000, phone_reports=[crew_report("completed", 150)],
)
unfinished = next(f for f in findings if f.summary.endswith("не доведён до конца"))
assert "бригада доложила о завершении работ" in unfinished.fact

View file

@ -0,0 +1,644 @@
"""Разбор по отметкам (lct-35): преподаватель подтверждает, снимает и добавляет отметку.
Балл пересчитывается теми же метриками и весами: снятая отметка не штрафует
свою метрику, ручная — штрафует. Автоматическая оценка остаётся рядом.
"""
import asyncio
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app.api.auth import Principal
from app.api.http import sessions
from app.domain.events import Metric
from app.domain.roles import Role
from app.domain.taxonomy import (
ErrorCode,
Finding,
FindingDecision,
FindingReview,
FindingSource,
)
from app.main import app
from app.scoring.export import to_csv
from app.scoring.review import INSTRUCTOR_METRIC, add_finding, review_finding
from app.session import finish as finish_module
from app.session.hub import hub
from app.session.store import FindingAdded, FindingReviewed, MemorySessionStore
from tests.test_dds_desk_scores import dds_exercise_early_finish
AT = datetime(2026, 9, 27, 12, 0, tzinfo=UTC)
def review(decision: FindingDecision) -> FindingReview:
return FindingReview(decision=decision, reason="доклад пришёл по рации", author="teacher", at=AT)
def report_with(metrics: list[Metric], findings: list[Finding]) -> dict:
return {
"score_auto": 50.0,
"metrics": [metric.model_dump(mode="json") for metric in metrics],
"findings": [finding.model_dump(mode="json") for finding in findings],
"full_report": {"criteria": {"allowed_errors": 0}, "score_auto": 50.0,
"score_final": 50.0, "failed_metrics": 1, "passed": False},
}
def dds_metric(key: str, passed: bool, card: int = 1) -> Metric:
return Metric(key=key, title=key, fact="факт", norm="норма", passed=passed,
weight=1.0, card=card, service="01")
def dds_finding(code: ErrorCode, card: int = 1) -> Finding:
return Finding(code=code, source=FindingSource.DISPATCHER, summary=code.value,
fact="факт", norm="норма", card=card, service="01")
def test_dismissed_finding_stops_penalizing_its_metric_but_stays_in_report():
report = report_with(
[dds_metric("dds_primary", True), dds_metric("dds_progress", False)],
[dds_finding(ErrorCode.D6)],
)
updated = review_finding(report, 0, review(FindingDecision.DISMISSED))
assert updated["score_reviewed"] == 100.0
assert updated["score_final"] == 100.0
assert updated["score_auto"] == 50.0, "автооценка остаётся рядом"
progress = updated["metrics"][1]
assert (progress["passed"], progress["passed_auto"]) == (True, False)
finding = updated["findings"][0]
assert finding["reviews"][-1]["decision"] == "dismissed"
assert finding["reviews"][-1]["reason"] == "доклад пришёл по рации"
archived = updated["full_report"]
assert (archived["score_final"], archived["failed_metrics"], archived["passed"]) == (100.0, 0, True)
def test_confirmed_finding_and_other_card_keep_the_metric_failed():
report = report_with(
[dds_metric("dds_progress", False, card=1), dds_metric("dds_progress", False, card=2)],
[dds_finding(ErrorCode.D6, card=1), dds_finding(ErrorCode.D6, card=2)],
)
confirmed = review_finding(report, 0, review(FindingDecision.CONFIRMED))
assert confirmed["score_reviewed"] == 0.0
dismissed = review_finding(report, 0, review(FindingDecision.DISMISSED))
assert [metric["passed"] for metric in dismissed["metrics"]] == [True, False], (
"снятие отметки первой карточки не трогает вторую"
)
def test_metric_stays_failed_while_one_of_its_findings_is_active():
report = report_with(
[dds_metric("dds_reply", False)],
[dds_finding(ErrorCode.D5), dds_finding(ErrorCode.D5)],
)
updated = review_finding(report, 0, review(FindingDecision.DISMISSED))
assert updated["metrics"][0]["passed"] is False
again = review_finding(updated, 1, review(FindingDecision.DISMISSED))
assert again["metrics"][0]["passed"] is True
def test_instructor_finding_penalizes_linked_metric_and_unlinked_code_adds_one():
report = report_with([dds_metric("dds_progress", True), dds_metric("dds_primary", True)], [])
manual = Finding(code=ErrorCode.D6, source=FindingSource.INSTRUCTOR, summary="Карточка 1: D6",
fact="«Прибыла» отмечено до доклада", norm="по факту доклада",
card=1, service="01", author="teacher", at=AT)
updated = add_finding(report, manual)
assert updated["score_reviewed"] == 50.0
assert updated["metrics"][0]["passed"] is False
assert updated["metrics"][0]["passed_auto"] is True
assert updated["findings"][0]["source"] == "instructor"
# У D4 в методике нет метрики — ручная отметка всё равно должна штрафовать.
no_metric = manual.model_copy(update={"code": ErrorCode.D4})
extra = add_finding(report, no_metric)
assert [metric["key"] for metric in extra["metrics"]][-1] == INSTRUCTOR_METRIC
assert extra["score_reviewed"] == pytest.approx(66.7)
# Снятая ручная отметка не штрафует, и её метрика исчезает при пересчёте.
lifted = review_finding(extra, 0, review(FindingDecision.DISMISSED))
assert lifted["score_reviewed"] == 100.0
assert INSTRUCTOR_METRIC not in {metric["key"] for metric in lifted["metrics"]}
def test_score_override_stays_final_after_finding_review():
report = {**report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)]),
"score_final": 70.0, "overridden_by": "teacher"}
updated = review_finding(report, 0, review(FindingDecision.DISMISSED))
assert updated["score_reviewed"] == 100.0
assert updated["score_final"] == 70.0
def test_unknown_finding_index_is_refused():
with pytest.raises(LookupError):
review_finding(report_with([], []), 0, review(FindingDecision.DISMISSED))
# ── HTTP: живое занятие ДДС ──
@pytest.fixture
def lesson(monkeypatch):
"""Завершённое занятие ДДС преподавателя `dev` в памяти узла."""
class NoCoaching:
def model_dump(self, **_kwargs):
return {}
async def no_coach(_metrics):
return NoCoaching()
async def audit_in_memory(*_args, **_kwargs):
return None
async def no_database():
yield None
# Живое занятие в памяти: чтение отчёта не должно ходить в PostgreSQL.
monkeypatch.setattr(finish_module, "coach", no_coach)
monkeypatch.setattr(sessions, "audit_required", audit_in_memory)
monkeypatch.setitem(app.dependency_overrides, sessions.optional_session, no_database)
store = MemorySessionStore()
monkeypatch.setattr(hub, "store", store)
state = dds_exercise_early_finish()
state.owner_login = "dev"
with TestClient(app) as browser:
browser.post("/api/auth/dev-token")
hub.store = store
hub.register(state)
yield browser, state, store
def as_role(monkeypatch, role: Role, login: str = "dev") -> None:
def require(_request, *roles):
if roles and role not in roles:
raise HTTPException(status_code=403, detail="forbidden")
return Principal(login=login, full_name="Проверка", role=role,
trainee_id=uuid4() if role is Role.TRAINEE else None)
monkeypatch.setattr(sessions, "require", require)
def test_owner_dismisses_and_adds_finding_through_http(lesson):
browser, state, store = lesson
session_id = state.session_id
before = browser.get(f"/api/sessions/{session_id}/report").json()
target = next(index for index, finding in enumerate(before["findings"])
if finding["code"] == "D1")
dismissed = browser.post(
f"/api/sessions/{session_id}/findings/{target}/review",
json={"decision": "dismissed", "reason": "карточку выдали с опозданием"},
)
assert dismissed.status_code == 200, dismissed.text
body = dismissed.json()
assert body["findings"][target]["reviews"][-1]["decision"] == "dismissed"
assert body["findings"][target]["reviews"][-1]["author"] == "dev"
assert body["score_auto"] == before["score_auto"]
assert body["score_reviewed"] >= before["score_auto"]
added = browser.post(
f"/api/sessions/{session_id}/findings",
json={"code": "D5", "fact": "в комментарии нет адресата", "norm": "назвать адресата",
"card": 1},
)
assert added.status_code == 200, added.text
manual = added.json()["findings"][-1]
assert (manual["source"], manual["card"], manual["author"]) == ("instructor", 1, "dev")
assert manual["summary"].startswith("Карточка 1: ")
records = [record for _, batch in store.commits for record in batch]
assert [type(record) for record in records[-2:]] == [FindingReviewed, FindingAdded]
assert store.scores[session_id]["findings"][target]["reviews"][-1]["decision"] == "dismissed"
rows = to_csv(sessions.SessionReport.model_validate(added.json())).decode("utf-8-sig")
assert "карточку выдали с опозданием" in rows
assert "в комментарии нет адресата" in rows
def test_review_requires_reason_and_existing_card(lesson):
browser, state, _store = lesson
session_id = state.session_id
blank = browser.post(f"/api/sessions/{session_id}/findings/0/review",
json={"decision": "dismissed", "reason": " "})
assert blank.status_code == 422
missing = browser.post(f"/api/sessions/{session_id}/findings/999/review",
json={"decision": "confirmed", "reason": "проверено"})
assert missing.status_code == 404
card = browser.post(f"/api/sessions/{session_id}/findings",
json={"code": "D1", "fact": "факт", "norm": "норма", "card": 99})
assert card.status_code == 422
@pytest.mark.parametrize("role, login, status", [
(Role.TRAINEE, "trainee", 403),
(Role.INSTRUCTOR, "someone-else", 404),
])
def test_trainee_and_foreign_instructor_cannot_review(lesson, monkeypatch, role, login, status):
browser, state, store = lesson
commits = len(store.commits)
as_role(monkeypatch, role, login)
reviewed = browser.post(f"/api/sessions/{state.session_id}/findings/0/review",
json={"decision": "dismissed", "reason": "не согласен"})
added = browser.post(f"/api/sessions/{state.session_id}/findings",
json={"code": "D1", "fact": "факт", "norm": "норма"})
assert (reviewed.status_code, added.status_code) == (status, status)
assert len(store.commits) == commits, "отказ ничего не пишет"
def test_finding_review_is_audited_in_postgres(postgres_access):
"""Решение и аудит — одна транзакция: строка Score и AuditLog вместе."""
from sqlalchemy import select
from app.db.base import get_sessionmaker
from app.db.models import AuditLog, Score
from app.session.pg_store import PostgresSessionStore
from app.session.store import LessonRequest
store = PostgresSessionStore(get_sessionmaker())
session_id = uuid4()
report = report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)])
async def scenario():
from app.session.state import SessionState
from app.domain.events import Exercise, SessionMode
def build(_identity):
return SessionState(session_id=session_id, scenario_id="fire-apartment-l2",
scenario_title="Пожар", level="L2", mode=SessionMode.TRAINING,
exercise=Exercise.DDS)
await store.open(LessonRequest(
session_id=session_id, scenario_id="fire-apartment-l2", mode="training",
trainee_name=None, trainee_id=None, owner_login="teacher", backend_node_id=None,
), build)
from app.session.store import ScoreCalculated
await store.commit_archived(session_id, [ScoreCalculated(50.0, report)])
await store.commit_archived(session_id, [FindingReviewed(
index=0, role="instructor", review=review(FindingDecision.DISMISSED),
)])
async with get_sessionmaker()() as db:
score = await db.scalar(select(Score).where(Score.session_id == session_id))
audit = await db.scalar(select(AuditLog).where(
AuditLog.object_id == str(session_id), AuditLog.action == "finding.review",
))
return score, audit
score, audit = asyncio.run(scenario())
assert score.score_auto == 50.0
assert score.score_final == 100.0
assert score.report["findings"][0]["reviews"][-1]["decision"] == "dismissed"
assert audit is not None and audit.actor == "teacher"
assert "dismissed" in audit.detail and "50.0 → 100.0" in audit.detail
# ── История решений, коды, карточки, веса ──
def test_repeated_decision_keeps_history_and_last_one_applies():
report = report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)])
dismissed = review_finding(report, 0, review(FindingDecision.DISMISSED))
confirmed = review_finding(dismissed, 0, FindingReview(
decision=FindingDecision.CONFIRMED, reason="перепроверил запись", author="head", at=AT,
))
reviews = confirmed["findings"][0]["reviews"]
assert [(item["decision"], item["author"], item["reason"]) for item in reviews] == [
("dismissed", "teacher", "доклад пришёл по рации"),
("confirmed", "head", "перепроверил запись"),
]
assert confirmed["score_reviewed"] == 0.0, "действует последнее решение"
def test_report_saved_with_single_review_reads_as_history():
legacy = dds_finding(ErrorCode.D6).model_dump(mode="json")
legacy["review"] = review(FindingDecision.DISMISSED).model_dump(mode="json")
finding = Finding.model_validate(legacy)
assert [item.decision for item in finding.reviews] == [FindingDecision.DISMISSED]
assert finding.review.decision is FindingDecision.DISMISSED
def test_call_code_in_handoff_penalizes_call_metric_not_instructor_metric():
"""Связка 112 → ДДС: E1 без карточки проваливает метрику 112, а не добавляет свою."""
call = Metric(key="checklist_completeness", title="Полнота опроса", fact="все", norm="все",
passed=True, weight=1.0)
report = report_with([call, dds_metric("dds_progress", True)], [])
manual = Finding(code=ErrorCode.E1, source=FindingSource.INSTRUCTOR, summary="E1",
fact="не уточнён подъезд", norm="уточнить подъезд", author="teacher", at=AT)
updated = add_finding(report, manual)
assert [metric["passed"] for metric in updated["metrics"]] == [False, True]
assert INSTRUCTOR_METRIC not in {metric["key"] for metric in updated["metrics"]}
assert updated["score_reviewed"] == 50.0
def test_codes_and_card_scores_follow_the_review():
card = {"card_id": str(uuid4()), "scenario_id": "s", "score_auto": 50.0, "reply_text": "",
"metrics": [dds_metric("dds_primary", True).model_dump(mode="json"),
dds_metric("dds_progress", False).model_dump(mode="json")],
"findings": [dds_finding(ErrorCode.D6).model_dump(mode="json")]}
report = {**report_with([dds_metric("dds_primary", True), dds_metric("dds_progress", False)],
[dds_finding(ErrorCode.D6)]),
"summary": {"hints": 2, "codes": {"D6": 1}}, "card_results": [card]}
report["full_report"]["card_results"] = [card]
dismissed = review_finding(report, 0, review(FindingDecision.DISMISSED))
assert dismissed["summary"] == {"hints": 2, "codes": {}}, "снятая отметка не ошибка группы"
for cards in (dismissed["card_results"], dismissed["full_report"]["card_results"]):
assert cards[0]["score_auto"] == 50.0
assert cards[0]["score_reviewed"] == 100.0
assert cards[0]["metrics"][1]["passed"] is True
assert cards[0]["findings"][0]["reviews"][-1]["decision"] == "dismissed"
manual = Finding(code=ErrorCode.D4, source=FindingSource.INSTRUCTOR, summary="D4",
fact="отказ без причины", norm="причина обязательна", card=1,
author="teacher", at=AT)
added = add_finding(dismissed, manual)
assert added["summary"]["codes"] == {"D4": 1}
assert added["card_results"][0]["score_reviewed"] == pytest.approx(66.7)
def test_instructor_metric_weight_comes_from_lesson_weights():
report = report_with([dds_metric("dds_primary", True)], [])
report["full_report"]["criteria"]["score_weights"] = {INSTRUCTOR_METRIC: 3.0}
manual = Finding(code=ErrorCode.D4, source=FindingSource.INSTRUCTOR, summary="D4",
fact="отказ без причины", norm="причина обязательна", card=1,
author="teacher", at=AT)
updated = add_finding(report, manual)
assert updated["metrics"][-1]["weight"] == 3.0
assert updated["score_reviewed"] == 25.0
def test_lesson_criteria_accept_instructor_metric_weight():
from app.domain.events import LessonCriteria
assert LessonCriteria(score_weights={INSTRUCTOR_METRIC: 2.0}).score_weights == {
INSTRUCTOR_METRIC: 2.0}
def test_repeated_manual_finding_with_same_key_is_added_once():
report = report_with([dds_metric("dds_primary", True)], [])
manual = Finding(code=ErrorCode.D4, source=FindingSource.INSTRUCTOR, summary="D4",
fact="отказ без причины", norm="причина обязательна", card=1,
author="teacher", at=AT, client_id=uuid4())
once = add_finding(report, manual)
twice = add_finding(once, manual)
assert len(twice["findings"]) == 1
assert twice["score_reviewed"] == once["score_reviewed"] == 50.0
def test_pdf_shows_score_after_review(monkeypatch):
from reportlab import platypus
from app.scoring.export import to_pdf
from tests.test_report_export import sample_report
texts: list[str] = []
class Recorded(platypus.Paragraph):
def __init__(self, text, *args, **kwargs):
texts.append(text)
super().__init__(text, *args, **kwargs)
# Абзацы перехватываются до вёрстки: извлечь текст из PDF нечем без poppler.
monkeypatch.setattr(platypus, "Paragraph", Recorded)
assert to_pdf(sample_report().model_copy(update={"score_reviewed": 91.5})).startswith(b"%PDF-")
assert "<b>После разбора отметок:</b> 91.5" in texts
# ── HTTP: связка 112 → ДДС, идемпотентность, занятие из истории ──
def test_call_code_with_queue_card_is_refused_in_handoff(lesson):
browser, state, store = lesson
commits = len(store.commits)
state.score["full_report"]["exercise"] = "call"
refused = browser.post(f"/api/sessions/{state.session_id}/findings",
json={"code": "E1", "fact": "факт", "norm": "норма", "card": 1})
assert refused.status_code == 422
assert refused.json()["detail"] == "call_code_without_card"
assert len(store.commits) == commits
def test_repeated_add_request_does_not_add_second_finding(lesson):
browser, state, store = lesson
body = {"code": "D5", "fact": "нет адресата", "norm": "назвать адресата", "card": 1,
"client_id": str(uuid4())}
first = browser.post(f"/api/sessions/{state.session_id}/findings", json=body)
commits = len(store.commits)
second = browser.post(f"/api/sessions/{state.session_id}/findings", json=body)
assert first.status_code == second.status_code == 200
assert len(second.json()["findings"]) == len(first.json()["findings"])
assert second.json()["score_reviewed"] == first.json()["score_reviewed"]
assert not [record for _, batch in store.commits[commits:] for record in batch], (
"повтор ничего не пишет")
def test_archived_lesson_is_reviewed_through_http(monkeypatch):
"""Занятия нет в памяти узла: решение идёт через commit_archived, ответ — по строке после записи."""
from types import SimpleNamespace
from app.session.pg_store import PostgresSessionStore
from tests.test_report_export import sample_report
archived = sample_report().model_dump(mode="json")
archived["findings"] = [dds_finding(ErrorCode.D6).model_dump(mode="json")]
archived["metrics"] = [dds_metric("dds_progress", False).model_dump(mode="json")]
archived.update(score_final=0.0, overridden_by=None, override_comment=None, score_auto=0.0)
session_id = archived["session_id"]
session_row = SimpleNamespace(id=session_id, trainee_id=uuid4(), owner_login="dev")
score_row = SimpleNamespace(
session_id=session_id, score_auto=0.0, score_final=0.0,
report={"score_auto": 0.0, "summary": {"codes": {"D6": 1}},
"findings": archived["findings"], "metrics": archived["metrics"],
"full_report": archived},
)
class FakeDb:
def __init__(self):
self.added = []
self.commits = 0
async def get(self, _model, _key):
return session_row
async def scalar(self, _statement):
return score_row
def add(self, row):
self.added.append(row)
async def flush(self):
pass
async def rollback(self):
pass
async def commit(self):
self.commits += 1
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
db = FakeDb()
async def fake_session():
yield db
monkeypatch.setattr(hub, "store", PostgresSessionStore(lambda: db))
monkeypatch.setattr(hub, "get", lambda _session_id: None)
monkeypatch.setitem(app.dependency_overrides, sessions.optional_session, fake_session)
with TestClient(app) as browser:
browser.post("/api/auth/dev-token")
hub.store = PostgresSessionStore(lambda: db)
response = browser.post(f"/api/sessions/{session_id}/findings/0/review",
json={"decision": "dismissed", "reason": "доклад по рации"})
assert response.status_code == 200, response.text
body = response.json()
assert body["score_reviewed"] == 100.0 and body["score_final"] == 100.0
assert body["findings"][0]["reviews"][-1]["reason"] == "доклад по рации"
assert db.commits == 1, "решение и аудит — один commit"
assert score_row.score_final == 100.0
assert score_row.report["summary"]["codes"] == {}
audit = db.added[0]
assert audit.action == "finding.review" and audit.actor == "dev"
assert "D6 dismissed" in audit.detail and "доклад по рации" in audit.detail
def test_finding_review_is_not_saved_when_audit_fails(postgres_access):
"""Fail-closed: строка аудита не записалась — решения и нового балла тоже нет."""
from sqlalchemy import select
from app.db.base import get_sessionmaker
from app.db.models import AuditLog, Score
from app.domain.events import Exercise, SessionMode
from app.session.pg_store import PostgresSessionStore
from app.session.state import SessionState
from app.session.store import LessonRequest, ScoreCalculated
store = PostgresSessionStore(get_sessionmaker())
session_id = uuid4()
report = report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)])
# actor в audit_log — String(80): длинный логин роняет INSERT аудита
# на стороне PostgreSQL, а Score меняется в той же транзакции.
broken = FindingReview(decision=FindingDecision.DISMISSED, reason="доклад по рации",
author="x" * 200, at=AT)
async def scenario():
def build(_identity):
return SessionState(session_id=session_id, scenario_id="fire-apartment-l2",
scenario_title="Пожар", level="L2", mode=SessionMode.TRAINING,
exercise=Exercise.DDS)
await store.open(LessonRequest(
session_id=session_id, scenario_id="fire-apartment-l2", mode="training",
trainee_name=None, trainee_id=None, owner_login="teacher", backend_node_id=None,
), build)
await store.commit_archived(session_id, [ScoreCalculated(50.0, report)])
with pytest.raises(Exception):
await store.commit_archived(session_id, [FindingReviewed(
index=0, role="instructor", review=broken,
)])
async with get_sessionmaker()() as db:
score = await db.scalar(select(Score).where(Score.session_id == session_id))
audit = await db.scalar(select(AuditLog).where(
AuditLog.object_id == str(session_id), AuditLog.action == "finding.review",
))
return score, audit
score, audit = asyncio.run(scenario())
assert audit is None
assert score.score_final == 50.0
assert score.report["findings"][0].get("reviews", []) == []
assert "score_reviewed" not in score.report
def test_archived_lesson_review_through_http_in_postgres(postgres_access, monkeypatch):
"""Тот же маршрут на настоящей PostgreSQL: ответ перечитан после commit под блокировкой."""
from sqlalchemy import select
from app.db.base import get_sessionmaker
from app.db.models import AuditLog
from app.domain.events import Exercise, SessionMode
from app.session.pg_store import PostgresSessionStore
from app.session.state import SessionState
from app.session.store import LessonRequest, ScoreCalculated
from tests.test_report_export import sample_report
session_id = uuid4()
archived = sample_report().model_dump(mode="json")
archived.update(session_id=str(session_id), score_final=0.0, score_auto=0.0,
overridden_by=None, override_comment=None,
findings=[dds_finding(ErrorCode.D6).model_dump(mode="json")],
metrics=[dds_metric("dds_progress", False).model_dump(mode="json")])
report = {"score_auto": 0.0, "summary": {"codes": {"D6": 1}},
"findings": archived["findings"], "metrics": archived["metrics"],
"full_report": archived}
store = PostgresSessionStore(get_sessionmaker())
async def prepare():
def build(_identity):
return SessionState(session_id=session_id, scenario_id="fire-apartment-l2",
scenario_title="Пожар", level="L2", mode=SessionMode.TRAINING,
exercise=Exercise.DDS)
await store.open(LessonRequest(
session_id=session_id, scenario_id="fire-apartment-l2", mode="training",
trainee_name=None, trainee_id=None, owner_login="dev", backend_node_id=None,
), build)
await store.commit_archived(session_id, [ScoreCalculated(0.0, report)])
async def audit():
async with get_sessionmaker()() as db:
return await db.scalar(select(AuditLog).where(
AuditLog.object_id == str(session_id), AuditLog.action == "finding.review"))
monkeypatch.setattr(hub, "get", lambda _session_id: None)
# Пул соединений привязан к циклу событий: подготовка и проверка идут
# в цикле того же TestClient, что и запрос.
with TestClient(app) as browser:
browser.post("/api/auth/dev-token")
previous, hub.store = hub.store, store
try:
browser.portal.call(prepare)
response = browser.post(f"/api/sessions/{session_id}/findings/0/review",
json={"decision": "dismissed", "reason": "доклад по рации"})
row = browser.portal.call(audit)
finally:
hub.store = previous
assert response.status_code == 200, response.text
assert response.json()["score_final"] == 100.0
assert response.json()["findings"][0]["reviews"][-1]["reason"] == "доклад по рации"
assert row is not None and "доклад по рации" in row.detail

View file

@ -21,7 +21,7 @@ from app.session.hub import hub
@pytest.fixture(scope="module")
def client():
def client(template_caller):
# Доступность базы проверяется подключением к порту: синхронного драйвера
# в проекте нет, и попытка проверить им даёт ложный пропуск теста.
import socket

View file

@ -68,6 +68,7 @@ def test_csv_contains_sections_and_blocks_formula_injection():
assert any(row[0] == "Занятие" and row[3] == "'=1+1" for row in rows)
assert any(row[0] == "Ошибки" and row[3] == "'+HYPERLINK(\"x\")" for row in rows)
assert any(row[0] == "Самооценка" and row[3] == "'@SUM(1,2)" for row in rows)
assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "45", ""] in rows
assert _cell(" =cmd") == "' =cmd"
assert _cell("\tОбычный текст") == "'\tОбычный текст"
@ -89,6 +90,7 @@ def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
assert "Отчёт по учебному занятию" in extracted
assert "Адрес должен быть уточнён" in extracted
assert "Заявитель сообщает о дыме" in extracted
assert "Лимит реакции на доклад бригады" in extracted
def test_certificate_pdf_contains_saved_result(tmp_path):

View file

@ -12,13 +12,14 @@ from pathlib import Path
API_ROOT = Path(__file__).parents[1] / "app" / "api"
# Public by design: credential entry/session bootstrap and the non-sensitive
# incident classification dictionary. dev-token has its own fail-closed flag
# incident classification dictionary and service catalog. dev-token has its own fail-closed flag
# and remains hidden in production.
PUBLIC_HTTP_ROUTES = {
("auth.py", "post", "/login"),
("auth.py", "post", "/dev-token"),
("http/ekp.py", "get", "/groups"),
("http/ekp.py", "get", "/signs"),
("http/ekp.py", "get", "/services"),
}
# Routes that centralize ownership + authentication checks in a shared helper.
@ -83,6 +84,8 @@ HTTP_ROLE_GATE_POLICY = {
("http/sessions.py", "get", "/{session_id}/checklist"): ((),),
("http/sessions.py", "get", "/{session_id}/recording.wav"): (("INSTRUCTOR", "TRAINEE"),),
("http/sessions.py", "patch", "/{session_id}/report"): (("INSTRUCTOR",),),
("http/sessions.py", "post", "/{session_id}/findings/{index}/review"): (("INSTRUCTOR",),),
("http/sessions.py", "post", "/{session_id}/findings"): (("INSTRUCTOR",),),
("http/sessions.py", "get", ""): ((),),
("http/trainees.py", "get", "/{trainee_id}/certificate.pdf"): ((),),
("http/trainees.py", "get", ""): (("ADMIN", "INSTRUCTOR"),),

View file

@ -0,0 +1,167 @@
"""Каталог служб из «СЛУЖБЫ 112» (lct-38).
Каталог только расширяет ручной выбор служб в карточке. Список оповещения
по-прежнему считается по классификатору, эталоны сценариев от каталога не зависят.
"""
import asyncio
import json
from pathlib import Path
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from app.api.ws import call as call_ws
from app.domain import ekp
from app.domain.events import ErrorEvent, KioPatchIn, KioPatchOut, PatchSource, SessionMode
from app.domain.kio import EDITABLE_KIO_FIELDS, KIO, PatchRejected, apply_patch
from app.main import app
from app.scenarios.loader import load_file
from app.scoring.card import evaluate_card
from app.session.hub import hub
from app.session.state import SessionState
from app.session.store import MemorySessionStore
def test_catalog_keeps_all_209_services_with_attribution_and_source():
data = ekp.catalog()
assert len(data.services) == 209
assert len({item.name for item in data.services}) == 209
assert sum(item.service_level == "ДДС" for item in data.services) == 157
assert "ded466988f505082922aacac9fff32b777d1279a" in data.transcription
assert data.license.startswith("MIT")
assert all(item.source_reference.startswith("СЛУЖБЫ 112.docx#word/media/image")
for item in data.services)
@pytest.mark.parametrize("name", [
'Служба 101 (ГУ МЧС России по г.Москве, ГКУ "Пожарно спасательный центр" ОДС)',
'Служба 104 (АО "МОСГАЗ" Диспетчерское управление)',
"ЦЭМП",
'ЦОДД (ГКУ "Центр организации дорожного движения")',
'ЭВАЖД (ГБУ "Учреждение по эксплуатации высотных административных и жилых домов")',
])
def test_rows_checked_against_our_screenshots_are_verbatim(name):
assert name in ekp.catalog_names()
def test_classifier_services_and_notify_lists_are_untouched():
"""Индексы служб классификатора — основа списков оповещения 96 сценариев."""
raw = json.loads(ekp.DATA.read_text(encoding="utf-8"))
assert [item.name for item in ekp.services()] == [item["name"] for item in raw["services"]]
assert len(ekp.services()) == 61
def test_operator_adds_catalog_services_without_changing_the_notify_list():
assert "notify_extra" in EDITABLE_KIO_FIELDS
card = KIO(notify=["Служба 101"])
updated = apply_patch(card, {"notify_extra": ["ФСБ", "ЦЭМП", "ФСБ"], "notify": []})
assert updated.notify_extra == ["ФСБ", "ЦЭМП"]
assert updated.notify == ["Служба 101"], "автоматический список оператор не правит"
with pytest.raises(PatchRejected):
apply_patch(card, {"notify_extra": ["Служба, которой нет"]})
with pytest.raises(ValidationError):
apply_patch(card, {"notify_extra": [item.name for item in ekp.catalog().services[:21]]})
def test_service_already_in_notify_is_not_added_twice():
"""ЦЭМП, ФСБ, МОЭК, Метро, Мосгортранс есть и в каталоге, и в классификаторе."""
card = KIO(notify=["ЦЭМП"])
updated = apply_patch(card, {"notify_extra": ["ЦЭМП", "ФСБ"]})
assert updated.notify_extra == ["ФСБ"]
def test_saved_card_with_renamed_service_still_loads():
"""Каталог проверяется на входе kio.patch: правка названия в каталоге
не должна ломать восстановление снимков и сданных карточек."""
card = KIO.model_validate({"notify_extra": ["Прежнее название службы"]})
assert card.notify_extra == ["Прежнее название службы"]
def test_added_services_do_not_change_the_score_against_the_reference():
library = Path(__file__).resolve().parents[2] / "scenarios"
scenario = load_file(library / "fire-apartment-l2.yaml", library)
card = KIO(address="улица Ленина, 14", description="горит балкон")
plain = evaluate_card(scenario, card)
extended = evaluate_card(scenario, card.model_copy(update={"notify_extra": ["ФСБ"]}))
assert [(m.key, m.passed) for m in plain.metrics] == [(m.key, m.passed) for m in extended.metrics]
assert plain.score == extended.score
def test_catalog_is_served_like_the_classifier():
with TestClient(app) as client:
response = client.get("/api/ekp/services")
assert response.status_code == 200
body = response.json()
assert len(body["services"]) == 209
assert {"id", "name", "organization", "service_level", "source_reference"} <= body["services"][0].keys()
@pytest.fixture
def lesson(monkeypatch):
sent = []
monkeypatch.setattr(hub, "store", MemorySessionStore())
monkeypatch.setattr(hub, "to_trainee", lambda _session_id, event: sent.append(event))
monkeypatch.setattr(hub, "to_observers", lambda *_args: None)
state = SessionState(session_id=uuid4(), scenario_id="test", scenario_title="Тест",
level="L1", mode=SessionMode.TRAINING)
hub.register(state)
def patch(fields):
sent.clear()
async def run():
async with hub.operation(state.session_id):
await call_ws._handle(state.session_id, state, KioPatchIn(fields=fields))
asyncio.run(run())
return list(sent)
return state, patch
def test_unknown_service_in_kio_patch_is_refused_without_failing_the_lesson(lesson):
state, patch = lesson
sent = patch({"notify_extra": ["Служба, которой нет"]})
assert isinstance(sent[0], ErrorEvent) and "не принято" in sent[0].message
assert state.kio.notify_extra == []
sent = patch({"notify_extra": ["ФСБ"]})
assert state.kio.notify_extra == ["ФСБ"]
def test_refused_kio_patch_returns_server_values_to_drop_pending(lesson):
"""Пакет отклоняется целиком — фронт должен получить серверные значения
всех его полей с `auto`, иначе pending висит до следующего kio.state."""
state, patch = lesson
patch({"address": "улица Ленина, 14", "notify_extra": ["ФСБ"]})
sent = patch({"address": "Тверская, 1", "fire.floors": 5, "notify_extra": ["ФСБ", "Служба, которой нет"]})
assert isinstance(sent[0], ErrorEvent)
assert isinstance(sent[1], KioPatchOut) and sent[1].source is PatchSource.AUTO
assert sent[1].fields == {"address": "улица Ленина, 14", "fire.floors": None, "notify_extra": ["ФСБ"]}
assert state.kio.address == "улица Ленина, 14"
def test_cleaned_notify_extra_is_sent_back_as_auto(lesson):
state, patch = lesson
state.kio = state.kio.model_copy(update={"notify": ["ЦЭМП"]})
sent = patch({"notify_extra": ["ЦЭМП", "ФСБ", "ФСБ"]})
auto = [event for event in sent if isinstance(event, KioPatchOut) and event.source is PatchSource.AUTO]
assert auto and auto[-1].fields["notify_extra"] == ["ФСБ"]
assert state.kio.notify_extra == ["ФСБ"]

View file

@ -12,12 +12,13 @@ from app.domain.events import (
SessionMode,
)
from app.domain.kio import KIO
from app.domain.statuses import PhoneCallPending, ServiceStatus
from app.domain.statuses import PhoneCallPending, PhoneReportRecord, ServiceStatus
from app.domain.taxonomy import ErrorCode, Finding, FindingSource
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
from app.session.checkpoint import dump_state, load_state
from app.session.dds import prepare_handoff_queue, prepare_queue
from app.session.finish import score_dds_card
from app.session.state import DdsCardRecord, SessionState, now_utc
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@ -85,6 +86,38 @@ def test_active_dds_session_round_trips_without_losing_work():
assert timer.limit_ms == 45_000
def test_report_reaction_keeps_times_and_limit_after_restore():
before = dds_state()
before.criteria.dds_report_reaction_limit_seconds = 20
card = before.desk.active
service = before.card_services(card)[0]
card.phone_reports.append(PhoneReportRecord(
service=service, crew=card.crew_selected, phase="dispatched",
text="Бригада выехала", at=now_utc(),
))
card.set_service_status(service, ServiceStatus.RESPONDING,
"Основание: доклад.\nСведения: бригада выехала.")
card.phone_reports[0].at -= timedelta(seconds=70)
restored = load_state(dump_state(before), now_utc())
assert restored.criteria.dds_report_reaction_limit_seconds == 20
assert restored.desk.active.phone_reports == card.phone_reports
def reaction(state):
result = score_dds_card(state, state.desk.active)
metric = next(item for item in result.metrics if item.key == "dds_report_reaction")
findings = [item for item in result.findings if item.metric_key == "dds_report_reaction"]
return metric, findings
metric, findings = reaction(restored)
assert not metric.passed
assert metric.norm == "отметить статус по докладу за 20 с"
assert "70 с (+50 с сверх норматива)" in metric.fact
assert [item.code for item in findings] == [ErrorCode.D6]
assert reaction(restored) == reaction(before), "оценка после восстановления та же"
def completed_card() -> DdsCardRecord:
return DdsCardRecord(
card_id=uuid4(),

View file

@ -0,0 +1,517 @@
"""Колонка сигналов реестра: очередь, повторные отказы, курсант не на связи."""
import asyncio
import time
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.engine.result import IteratorResult, SimpleResultMetaData
from starlette.websockets import WebSocketDisconnect
from app.api.auth import Principal
from app.api.http import sessions as sessions_http
from app.api.ws import session as session_ws
from app.config import get_settings
from app.db.models import Session
from app.domain.events import Exercise, SessionMode
from app.domain.roles import Role
from app.main import app
from app.session.checkpoint import dump_state, load_state
from app.session import hub as hub_module
from app.session.hub import SessionHub, hub
from app.session.state import SessionState
from app.session.store import MemorySessionStore
POOL = ["fire-apartment-l2", "t01-1-fire-container"]
@pytest.fixture
def client(monkeypatch):
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
get_settings.cache_clear()
async def audit_override(*_args, **_kwargs):
return None
async def optional_session_override():
yield None
monkeypatch.setattr(sessions_http, "audit_required", audit_override)
monkeypatch.setitem(
app.dependency_overrides, sessions_http.optional_session, optional_session_override
)
try:
with TestClient(app) as test_client:
test_client.post("/api/auth/dev-token")
hub.store = MemorySessionStore()
before = set(hub._sessions)
try:
yield test_client
finally:
for session_id in set(hub._sessions) - before:
hub.drop(session_id)
finally:
get_settings.cache_clear()
def wait_for(predicate, timeout=3):
end = time.monotonic() + timeout
while time.monotonic() < end:
value = predicate()
if value:
return value
time.sleep(0.02)
raise AssertionError("состояние не обновилось")
def read_until(socket, wanted):
received = []
for _ in range(20):
event = socket.receive_json()
received.append(event["type"])
if event["type"] == wanted:
return event
raise AssertionError(f"событие {wanted} не пришло; получены: {received}")
def start_two_card_dds(client, scenario_ids=POOL):
session_id = uuid4()
context = client.websocket_connect(f"/ws/control/{session_id}")
control = context.__enter__()
control.send_json({
"type": "scenario.start",
"scenario_id": POOL[0],
"trainee": "Иванов",
"mode": "training",
"exercise": "dds",
"random_scenario_ids": scenario_ids,
})
wait_for(lambda: hub.get(session_id))
return session_id, control
def start_card_handoff(client):
session_id = uuid4()
context = client.websocket_connect(f"/ws/control/{session_id}")
control = context.__enter__()
control.send_json({
"type": "scenario.start", "scenario_id": POOL[0], "trainee": "Иванов",
"mode": "training", "exercise": "card", "handoff_to_dds": True,
"scenario_ids": POOL,
})
wait_for(lambda: hub.get(session_id))
return session_id, control
def start_call(client):
session_id = uuid4()
context = client.websocket_connect(f"/ws/control/{session_id}")
control = context.__enter__()
control.send_json({
"type": "scenario.start", "scenario_id": POOL[0], "trainee": "Иванов",
"mode": "training", "exercise": "call",
})
wait_for(lambda: hub.get(session_id))
return session_id, control
def row_for(client, session_id):
rows = client.get("/api/sessions/active").json()
return next(item for item in rows if item["session_id"] == str(session_id))
def signal_kinds(row):
return {signal["kind"] for signal in row["signals"]}
def assigned_trainee(state, monkeypatch):
state.trainee_id = uuid4()
who = Principal(login="курсант", full_name="Курсант", role=Role.TRAINEE,
trainee_id=state.trainee_id)
monkeypatch.setattr(session_ws, "principal_of", lambda _ws: who)
def registry_time(monkeypatch, since, seconds):
class Clock(datetime):
@classmethod
def now(cls, tz=None):
return since + timedelta(seconds=seconds)
monkeypatch.setattr(sessions_http, "datetime", Clock)
def test_signal_backlog_when_queue_reaches_threshold(client, monkeypatch):
monkeypatch.setenv("SIGNAL_BACKLOG_THRESHOLD", "2")
get_settings.cache_clear()
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
assert len(state.desk.cards) == 2, "оба билета должны прийти сразу без интервала"
row = row_for(client, session_id)
assert row["dds_open_cards"] == 2
assert "backlog" in signal_kinds(row)
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_declined_card_leaves_backlog(client, monkeypatch):
monkeypatch.setenv("SIGNAL_BACKLOG_THRESHOLD", "2")
get_settings.cache_clear()
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
first_card = state.desk.ordered()[0]
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
read_until(station, "station.state")
station.send_json({
"type": "card.status", "service": state.card_services(first_card)[0],
"status": "declined", "comment": "не наш адрес, передано в УК",
})
read_until(station, "station.state")
row = row_for(client, session_id)
assert row["dds_open_cards"] == 2, "отклонённая карточка ждёт «Следующей» на пульте"
assert "backlog" not in signal_kinds(row)
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_signal_refusals_after_two_consecutive_declines(client):
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
first_card, second_card = state.desk.ordered()
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
read_until(station, "station.state")
first_service = state.card_services(first_card)[0]
station.send_json({
"type": "card.status", "service": first_service, "status": "declined",
"comment": "не наш адрес, передано в УК",
})
read_until(station, "station.state")
row = row_for(client, session_id)
assert "refusals" not in signal_kinds(row), "одного отказа недостаточно для сигнала"
station.send_json({"type": "card.open", "card_id": str(second_card.card_id)})
read_until(station, "station.state")
second_service = state.card_services(second_card)[0]
station.send_json({
"type": "card.status", "service": second_service, "status": "declined",
"comment": "не наша территория, передано в ОМВД",
})
read_until(station, "station.state")
row = row_for(client, session_id)
assert state.consecutive_refusals == 2
assert "refusals" in signal_kinds(row)
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_acceptance_clears_refusal_streak(client):
session_id, control = start_two_card_dds(
client, [*POOL, "t20-2-stroke"],
)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "station.state")
for card in state.desk.ordered()[:2]:
station.send_json({"type": "card.open", "card_id": str(card.card_id)})
read_until(station, "station.state")
station.send_json({
"type": "card.status", "service": state.card_services(card)[0],
"status": "declined", "comment": "Не наша территория, передано дежурному",
})
read_until(station, "station.state")
assert state.consecutive_refusals == 2
third = state.desk.ordered()[2]
station.send_json({"type": "card.open", "card_id": str(third.card_id)})
read_until(station, "station.state")
station.send_json({
"type": "card.status", "service": state.card_services(third)[0],
"status": "accepted", "comment": "Карточка принята диспетчером",
})
read_until(station, "station.state")
assert state.consecutive_refusals == 0
assert "refusals" not in signal_kinds(row_for(client, session_id))
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_signal_offline_when_station_socket_is_closed_past_the_window(client, monkeypatch):
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
assigned_trainee(state, monkeypatch)
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
read_until(station, "station.state")
assert hub.station_connected(session_id)
assert not hub.station_connected(session_id)
disconnected_at = state.socket_last_seen_at
assert disconnected_at is not None
window = get_settings().signal_offline_window_seconds
registry_time(monkeypatch, disconnected_at, window - 1)
assert "offline" not in signal_kinds(row_for(client, session_id))
registry_time(monkeypatch, disconnected_at, window + 1)
row = row_for(client, session_id)
assert "offline" in signal_kinds(row)
assert state.socket_last_seen_at == disconnected_at, "GET реестра не должен менять занятие"
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_signal_offline_without_first_connection(client, monkeypatch):
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
started = state.socket_last_seen_at
assert started is not None
registry_time(monkeypatch, started, get_settings().signal_offline_window_seconds + 1)
assert "offline" in signal_kinds(row_for(client, session_id))
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_call_socket_disconnection_triggers_offline(client, monkeypatch):
session_id, control = start_call(client)
try:
state = hub.get(session_id)
assigned_trainee(state, monkeypatch)
with client.websocket_connect(f"/ws/call/{session_id}") as call:
read_until(call, "call.incoming")
assert hub.trainee_connected(session_id)
assert not hub.trainee_connected(session_id)
disconnected_at = state.socket_last_seen_at
registry_time(monkeypatch, disconnected_at,
get_settings().signal_offline_window_seconds + 1)
assert "offline" in signal_kinds(row_for(client, session_id))
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_teacher_socket_does_not_hide_offline(client, monkeypatch):
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "station.state")
assert not hub.station_connected(session_id)
registry_time(monkeypatch, state.socket_last_seen_at,
get_settings().signal_offline_window_seconds + 1)
assert "offline" in signal_kinds(row_for(client, session_id))
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_last_of_two_trainee_sockets_starts_offline_window(client, monkeypatch):
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
assigned_trainee(state, monkeypatch)
with client.websocket_connect(f"/ws/station/{session_id}") as first:
read_until(first, "station.state")
with client.websocket_connect(f"/ws/station/{session_id}") as second:
read_until(second, "station.state")
assert hub.station_connected(session_id)
registry_time(monkeypatch, state.socket_last_seen_at,
get_settings().signal_offline_window_seconds + 1)
assert "offline" not in signal_kinds(row_for(client, session_id))
disconnected_at = state.socket_last_seen_at
registry_time(monkeypatch, disconnected_at,
get_settings().signal_offline_window_seconds + 1)
assert "offline" in signal_kinds(row_for(client, session_id))
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_offline_window_survives_checkpoint_restore(client, monkeypatch):
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
assigned_trainee(state, monkeypatch)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "station.state")
disconnected_at = state.socket_last_seen_at
restored = load_state(dump_state(state), datetime.now(UTC))
hub.drop(session_id)
asyncio.run(hub._adopt(restored))
assert restored.socket_last_seen_at == disconnected_at
registry_time(monkeypatch, disconnected_at,
get_settings().signal_offline_window_seconds + 1)
assert "offline" in signal_kinds(row_for(client, session_id))
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_takeover_starts_window_when_socket_was_open(client, monkeypatch):
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
assigned_trainee(state, monkeypatch)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "station.state")
assert state.socket_connected_at_checkpoint
snapshot = dump_state(state)
old_time = state.socket_last_seen_at
takeover_at = old_time + timedelta(minutes=10)
monkeypatch.setattr(hub_module, "now_utc", lambda: takeover_at)
restored = load_state(snapshot, datetime.now(UTC))
class CapturingStore(MemorySessionStore):
def __init__(self):
self.saved = None
async def commit(self, state, _records=()):
self.saved = dump_state(state)
store = CapturingStore()
local_hub = SessionHub(store)
try:
assert asyncio.run(local_hub._adopt(restored))
assert restored.socket_last_seen_at == takeover_at
assert not restored.socket_connected_at_checkpoint
assert datetime.fromisoformat(
store.saved["socket_last_seen_at"].replace("Z", "+00:00")
) == takeover_at
assert store.saved["socket_connected_at_checkpoint"] is False
finally:
local_hub.stop_ticker(session_id)
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_card_handoff_uses_trainee_station_presence(client, monkeypatch):
session_id, control = start_card_handoff(client)
try:
state = hub.get(session_id)
assigned_trainee(state, monkeypatch)
with monkeypatch.context() as clock:
with client.websocket_connect(f"/ws/call/{session_id}") as call:
read_until(call, "card.briefing")
call.send_json({"type": "card.submit"})
read_until(call, "call.ended")
handoff_at = state.socket_last_seen_at
clock.setattr(hub_module, "now_utc", lambda: handoff_at + timedelta(minutes=1))
assert state.socket_last_seen_at == handoff_at, "старый call-сокет не сбрасывает окно ДДС"
assert state.exercise.value == "card" and state.dds_phase
assert state.desk.scenarios
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "station.state")
registry_time(monkeypatch, state.socket_last_seen_at,
get_settings().signal_offline_window_seconds + 1)
row = row_for(client, session_id)
assert row["dds_open_cards"] > 0
assert "offline" not in signal_kinds(row)
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_checkpoint_on_another_node_marks_presence_unknown(client, monkeypatch):
session_id, control = start_two_card_dds(client)
try:
state = hub.get(session_id)
snapshot = dump_state(state)
checkpoint_at = datetime.now(UTC)
hub.drop(session_id)
row = Session(id=session_id, owner_login="dev",
live_state=snapshot, checkpoint_at=checkpoint_at)
class CheckpointDB:
async def scalars(self, _query):
return IteratorResult(SimpleResultMetaData(["Session"]), iter([(row,)])).scalars()
monkeypatch.setattr(sessions_http, "require", lambda _request, _role:
Principal(login="dev", full_name="Преподаватель",
role=Role.INSTRUCTOR))
rows = asyncio.run(sessions_http.active(None, db=CheckpointDB()))
row = next(item for item in rows if item.session_id == session_id)
assert row.presence_known is False
assert "offline" not in {item.kind for item in row.signals}
finally:
hub.stop_ticker(session_id)
control.__exit__(None, None, None)
def test_failed_presence_commit_does_not_leave_connected_socket():
class BrokenStore(MemorySessionStore):
async def commit(self, _state, _records=()):
raise OSError("test commit failure")
local_hub = SessionHub(BrokenStore())
state = local_hub.register(SessionState(
session_id=uuid4(), scenario_id="test", scenario_title="Тест", level="L1",
mode=SessionMode.TRAINING, exercise=Exercise.DDS,
))
async def connect():
async with local_hub.trainee_socket(state.session_id, station=True, trainee=True):
assert local_hub.is_lease_fenced(state.session_id)
asyncio.run(connect())
assert not local_hub.station_connected(state.session_id)
def test_failed_presence_commit_closes_station_with_fencing_code(client, monkeypatch):
class BrokenStore(MemorySessionStore):
async def commit(self, _state, _records=()):
raise OSError("test commit failure")
session_id, control = start_two_card_dds(client)
try:
assigned_trainee(hub.get(session_id), monkeypatch)
hub.store = BrokenStore()
with client.websocket_connect(f"/ws/station/{session_id}") as station:
event = station.receive_json()
assert event["type"] == "error" and event["code"] == "internal"
with pytest.raises(WebSocketDisconnect) as closed:
station.receive_json()
assert closed.value.code == 1012
assert not hub.station_connected(session_id)
finally:
control.__exit__(None, None, None)
def test_takeover_retries_failed_presence_checkpoint():
class FlakyStore(MemorySessionStore):
def __init__(self):
self.commits = 0
async def commit(self, _state, _records=()):
self.commits += 1
if self.commits == 1:
raise OSError("temporary database outage")
store = FlakyStore()
local_hub = SessionHub(store)
state = SessionState(
session_id=uuid4(), scenario_id="test", scenario_title="Тест", level="L1",
mode=SessionMode.TRAINING, exercise=Exercise.DDS,
socket_last_seen_at=datetime.now(UTC) - timedelta(minutes=10),
socket_connected_at_checkpoint=True,
)
async def recover():
assert not await local_hub._adopt(state)
assert local_hub.get(state.session_id) is None
await local_hub.maintain_lease()
assert local_hub.get(state.session_id) is state
assert store.commits == 2
await local_hub.shutdown()
asyncio.run(recover())

View file

@ -22,7 +22,7 @@ from app.session.store import MemorySessionStore
@pytest.fixture
def client(monkeypatch):
def client(monkeypatch, template_caller):
async def audit_in_memory(*_args, **_kwargs):
return None

View file

@ -74,6 +74,18 @@ socket.receive({ type: "kio.patch", fields: {
}, source: "operator" });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [], "matching checkpoint echo clears the KIO patch");
const refused = { type: "kio.patch", fields: { address: "ул. Новая, 3", notify_extra: ["Нет в каталоге"] } };
call.send(refused);
socket.receive({ type: "kio.patch", fields: { address: patch.fields.address, notify_extra: [] }, source: "auto" });
socket.sent = [];
call.send({ type: "kio.patch", fields: { floor: "2" } });
assert.deepEqual(socket.sent.at(-1).fields, { floor: "2" },
"server values after a refused patch acknowledge it: refused fields are not merged into the next edit");
socket.receive({ type: "kio.patch", fields: { floor: "2" }, source: "operator" });
socket = reconnect(socket);
assert.deepEqual(socket.sent, [], "nothing left to replay after refusal and the next echo");
const refusedCard = applyPatch(edit(empty, "notify_extra", ["Нет в каталоге"]), { notify_extra: [] }, "auto");
assert.deepEqual(refusedCard.pending, {}, "server value after refusal drops the pending edit");
const edited = edit(empty, "coords", { latitude: 55.7, longitude: 37.6 });
assert.deepEqual(applyPatch(edited, { coords: { longitude: 37.6, latitude: 55.7 } }, "operator").pending, {});
assert.deepEqual(applyState(edited, { coords: { longitude: 37.6, latitude: 55.7 } }).pending, {});

View file

@ -39,6 +39,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
<article className="debrief-stat">
<span>Автоматическая оценка</span><strong>{report.score_auto.toFixed(0)}<small> из 100</small></strong>
<small>{report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"}</small>
{report.score_reviewed != null && <small>после разбора отметок: {report.score_reviewed.toFixed(0)}</small>}
</article>
<article className="debrief-stat">
<span>Упражнение</span><strong>{report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"}</strong>
@ -80,6 +81,9 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}</>}
{report.exercise === "card" && <>Норматив заполнения КИО —
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>}
{/* 112 → ДДС приходит как exercise "card": работу ДДС выдают карточки пульта. */}
{(report.exercise === "dds" || Boolean(report.card_results?.length)) && <>; статус по докладу бригады —
{` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`}</>}
{report.exercise && report.exercise !== "dds" && <>
; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку
</>}.
@ -92,7 +96,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{Boolean(report.card_results?.length) && <section className="debrief-card-results">
<h3>Карточки занятия ({report.card_results!.length})</h3>
{report.card_results!.map((card, index) => <details key={card.card_id}>
<summary>Карточка {index + 1}: {card.title || card.scenario_id} · {card.score_auto.toFixed(0)} / 100
<summary>Карточка {index + 1}: {card.title || card.scenario_id} · {(card.score_reviewed ?? card.score_auto).toFixed(0)} / 100
{card.score_reviewed != null && ` после разбора (автооценка ${card.score_auto.toFixed(0)})`}
{card.address ? ` · ${card.address}` : ""}</summary>
{card.managed_service && <p><strong>Ответственная служба:</strong> {card.managed_service}</p>}
<p><strong>Ответ диспетчера:</strong> {card.reply_text || "не внесён"}</p>
@ -147,6 +152,10 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
<td>
{finding.summary}
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
{finding.source === "instructor" && <div className="ref">отметка преподавателя {finding.author ?? ""}</div>}
{(finding.reviews ?? []).map((review, reviewIndex) => <div className="ref" key={reviewIndex}>
{review.decision === "dismissed" ? "снята" : "подтверждена"} преподавателем {review.author}: {review.reason}
</div>)}
</td>
</tr>
))}

View file

@ -0,0 +1,162 @@
// Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку.
//
// Балл пересчитывает сервер теми же метриками и весами (lct-35): здесь только
// решение и его причина. Причина обязательна — она остаётся в отчёте и в CSV/PDF.
import { useRef, useState } from "react";
import type { ErrorCode, FindingDecision, SessionReport } from "@/shared/types/generated";
const DDS_CODES: ErrorCode[] = ["D1", "D2", "D3", "D4", "D5", "D6"];
const CALL_CODES: ErrorCode[] = ["E1", "E2", "E3", "E4", "E5", "E6"];
async function send(url: string, body: unknown): Promise<SessionReport> {
const response = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
const detail = data && typeof data === "object" && "detail" in data ? data.detail : response.statusText;
throw new Error(typeof detail === "string" ? detail : "Не удалось сохранить решение.");
}
return data as SessionReport;
}
export function FindingReview({ report, onChange }: {
report: SessionReport;
onChange: (report: SessionReport) => void;
}) {
const cards = report.card_results ?? [];
const codes = report.exercise === "dds" ? DDS_CODES
: cards.length ? [...CALL_CODES, ...DDS_CODES] : CALL_CODES;
const [reasons, setReasons] = useState<Record<number, string>>({});
const [code, setCode] = useState<ErrorCode>(codes[0]);
// В ДДС все метрики принадлежат карточкам очереди. В связке 112 → ДДС
// E-коды относятся к занятию, а D-код без карточки — к живой карточке звонка.
const [card, setCard] = useState(report.exercise === "dds" && cards.length ? "1" : "");
const cardChoice = cards.length > 0 && (report.exercise === "dds" || code.startsWith("D"));
// Один ключ на отметку, пока она не сохранена: повтор после сбоя сети
// не добавит её второй раз.
const pendingId = useRef<string | null>(null);
const [fact, setFact] = useState("");
const [norm, setNorm] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const base = `/api/sessions/${report.session_id}/findings`;
async function run(action: () => Promise<SessionReport>) {
setBusy(true);
setError("");
try {
onChange(await action());
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Не удалось сохранить решение.");
} finally {
setBusy(false);
}
}
function decide(index: number, decision: FindingDecision) {
const reason = (reasons[index] ?? "").trim();
if (!reason) {
setError("Укажите причину решения по отметке.");
return;
}
void run(async () => {
const updated = await send(`${base}/${index}/review`, { decision, reason });
setReasons((current) => ({ ...current, [index]: "" }));
return updated;
});
}
function addFinding() {
if (!fact.trim() || !norm.trim()) {
setError("Для отметки нужны факт и норма.");
return;
}
pendingId.current ??= crypto.randomUUID();
const clientId = pendingId.current;
void run(async () => {
const updated = await send(base, {
code, fact: fact.trim(), norm: norm.trim(), card: cardChoice && card ? Number(card) : null,
client_id: clientId,
});
pendingId.current = null;
setFact("");
setNorm("");
return updated;
});
}
return (
<section className="debrief finding-review">
<h2>Разбор по отметкам</h2>
<p className="ref">
Снятая отметка не штрафует свою метрику, добавленная — штрафует. Балл пересчитывается
по тем же критериям и весам; автоматическая оценка ({report.score_auto.toFixed(1)}) сохраняется рядом.
{report.score_reviewed != null && ` После разбора: ${report.score_reviewed.toFixed(1)}.`}
</p>
<table className="grid">
<tbody>
{report.findings.map((finding, index) => (
<tr key={index}>
<th>{finding.code}</th>
<td>
{finding.summary}
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
{(finding.reviews ?? []).map((review, reviewIndex, all) => <div key={reviewIndex}
className={reviewIndex < all.length - 1 || review.decision === "dismissed" ? "ref" : "state-violated"}>
{review.decision === "dismissed" ? "Снята" : "Подтверждена"} ({review.author},{" "}
{new Date(review.at).toLocaleString("ru-RU")}): {review.reason}
</div>)}
<div className="finding-review-actions">
<input aria-label={`Причина решения по отметке ${index + 1}`} maxLength={1000}
placeholder="Причина решения" value={reasons[index] ?? ""}
onChange={(event) => setReasons((current) => ({ ...current, [index]: event.target.value }))} />
<button type="button" disabled={busy} onClick={() => decide(index, "confirmed")}>Подтвердить</button>
<button type="button" disabled={busy} onClick={() => decide(index, "dismissed")}>Снять</button>
</div>
</td>
</tr>
))}
{report.findings.length === 0 && <tr><td colSpan={2}>Автоматических отметок нет.</td></tr>}
</tbody>
</table>
<h3>Добавить отметку</h3>
<div className="finding-review-form">
<label>Код
<select value={code} onChange={(event) => {
pendingId.current = null;
setCode(event.target.value as ErrorCode);
}}>
{codes.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
{cardChoice && <label>Карточка
<select value={card} onChange={(event) => { pendingId.current = null; setCard(event.target.value); }}>
{report.exercise !== "dds" && <option value="">занятие (без карточки)</option>}
{cards.map((item, index) => <option key={item.card_id} value={String(index + 1)}>
{index + 1}: {item.title || item.scenario_id}
</option>)}
</select>
</label>}
<label>Факт
<input maxLength={1000} value={fact} onChange={(event) => { pendingId.current = null; setFact(event.target.value); }}
placeholder="Что сделал курсант" />
</label>
<label>Норма
<input maxLength={1000} value={norm} onChange={(event) => { pendingId.current = null; setNorm(event.target.value); }}
placeholder="Как требовалось" />
</label>
<button type="button" disabled={busy || !fact.trim() || !norm.trim()} onClick={addFinding}>
{busy ? "Сохраняем…" : "Добавить отметку"}
</button>
</div>
{error && <p className="violated" role="alert">{error}</p>}
</section>
);
}

View file

@ -32,6 +32,7 @@ const DDS_METRICS: MetricOption[] = [
{ key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." },
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
{ key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." },
{ key: "dds_report_reaction", label: "Реакция на доклад бригады", hint: "Статус по докладу бригады не позже лимита занятия." },
];
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
@ -39,6 +40,10 @@ const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
"required_fields", "description_grammar", "card_fill_time",
].includes(key));
const CALL_METRICS = OPERATOR_METRICS.filter(({ key }) => key !== "card_fill_time");
// Метрика появляется, только когда ручной отметке преподавателя нечего провалить.
const REVIEW_METRICS: MetricOption[] = [
{ key: "instructor_finding", label: "Отметка преподавателя", hint: "Ручная отметка на разборе без связанной метрики (например, D4)." },
];
export function ScoreWeightsEditor({
exercise,
@ -57,6 +62,7 @@ export function ScoreWeightsEditor({
: exercise === "card_dds"
? [["Карточка КИО", CARD_METRICS], ["Реагирование ДДС", DDS_METRICS]]
: [["Опрос 112", CALL_METRICS], ["Реагирование ДДС", DDS_METRICS]];
groups.push(["Разбор", REVIEW_METRICS]);
function setWeight(key: string, raw: string) {
const next = { ...values };

View file

@ -1,7 +1,9 @@
// Карточка КИО. Один и тот же компонент рисуется у курсанта, на пульте,
// на внешнем мониторе и на АРМ ДДС — там в режиме `readOnly`.
import { useIncidentGroups, useSigns } from "@/shared/api/http";
import { useState } from "react";
import { useIncidentGroups, useServiceCatalog, useSigns } from "@/shared/api/http";
import { type CardState, display } from "./merge";
import { type Field, type FieldGroup, visibleGroups } from "./fields";
@ -17,6 +19,7 @@ interface Props {
export function KioCard({ state, required = [], readOnly = false, referenceArm = false, onChange }: Props) {
const dds = display(state, "dds") as string | null;
const incidentGroup = display(state, "incident_group") as number | null;
const notify = (display(state, "notify") as string[] | null) ?? [];
const groups = visibleGroups(dds);
const section = (group: FieldGroup) => (
<section className={`kio-arm-section kio-arm-${group.area}`} key={group.title}>
@ -36,6 +39,7 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
readOnly={readOnly || field.readOnly}
referenceArm={referenceArm}
incidentGroup={incidentGroup}
notify={notify}
onChange={onChange}
/>
))}
@ -58,7 +62,7 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
}
function Row({
field, value, pending, required, readOnly, referenceArm, incidentGroup, onChange,
field, value, pending, required, readOnly, referenceArm, incidentGroup, notify, onChange,
}: {
field: Field;
value: unknown;
@ -67,6 +71,7 @@ function Row({
readOnly?: boolean;
referenceArm: boolean;
incidentGroup: number | null;
notify: string[];
onChange?: (path: string, value: unknown) => void;
}) {
const empty = value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
@ -85,7 +90,7 @@ function Row({
{readOnly ? (
<span className="kio-arm-readonly">{formatValue(value, field)}</span>
) : (
<Input field={field} value={value} referenceArm={referenceArm} incidentGroup={incidentGroup} onChange={onChange} />
<Input field={field} value={value} referenceArm={referenceArm} incidentGroup={incidentGroup} notify={notify} onChange={onChange} />
)}
</div>
</div>
@ -93,12 +98,13 @@ function Row({
}
function Input({
field, value, referenceArm, incidentGroup, onChange,
field, value, referenceArm, incidentGroup, notify, onChange,
}: {
field: Field;
value: unknown;
referenceArm: boolean;
incidentGroup: number | null;
notify: string[];
onChange?: (path: string, value: unknown) => void;
}) {
const emit = (next: unknown) => onChange?.(field.path, next);
@ -141,6 +147,9 @@ function Input({
</select>
);
}
if (field.kind === "services") {
return <ServicesPicker value={(value as string[]) ?? []} notify={notify} onChange={(next) => emit(next)} />;
}
if (field.kind === "signs") {
return <SignsPicker value={(value as string[]) ?? []} group={incidentGroup} onChange={(next) => emit(next)} />;
}
@ -170,6 +179,46 @@ function Input({
);
}
/** Как `max_length` у `KIO.notify_extra`. */
const SERVICES_LIMIT = 20;
/** Ручное добавление служб из каталога — окно «Добавьте службы» боевого АРМ.
* Список оповещения по признакам остаётся как есть: здесь только добавки. */
function ServicesPicker({ value, notify, onChange }: {
value: string[]; notify: string[]; onChange: (next: string[]) => void;
}) {
const catalog = useServiceCatalog();
const [query, setQuery] = useState("");
const needle = query.trim().toLowerCase();
// Лимит и службы из списка оповещения — те же правила, что на сервере:
// лишняя служба отклонила бы весь пакет правок.
const full = value.length >= SERVICES_LIMIT;
const found = needle && !full
? (catalog.data?.services ?? [])
.filter((item) => !value.includes(item.name) && !notify.includes(item.name)
&& item.name.toLowerCase().includes(needle))
.slice(0, 8)
: [];
return (
<div className="kio-services">
{value.length > 0 && <div className="signs-selected" aria-label="Добавленные службы">
{value.map((name) => <span key={name}>
{name} <button type="button" aria-label={`Убрать ${name}`}
onClick={() => onChange(value.filter((item) => item !== name))}>×</button>
</span>)}
</div>}
<input type="search" aria-label="Поиск службы в каталоге"
placeholder={full ? `Не больше ${SERVICES_LIMIT} служб` : "Поиск службы…"} disabled={full}
value={query} onChange={(event) => setQuery(event.target.value)} />
{found.length > 0 && <ul className="kio-services-found">
{found.map((item) => <li key={item.id}>
<button type="button" onClick={() => { onChange([...value, item.name]); setQuery(""); }}>{item.name}</button>
</li>)}
</ul>}
</div>
);
}
function IncidentGroupPicker({ value, onChange }: { value: number | null; onChange: (next: number | null) => void }) {
const page = useIncidentGroups();
return <select aria-label="Категория происшествия" value={value ?? ""}

View file

@ -6,7 +6,7 @@
import type { DDSCode, IncidentType } from "@/shared/types/generated";
export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "group" | "list";
export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "group" | "list" | "services";
export interface Field {
/** Путь в карточке: «floor», «fire.floors» — так же, как в `kio.patch`. */
@ -107,6 +107,8 @@ export const GROUPS: FieldGroup[] = [
fields: [
// Список оповещения оператор не выбирает: он производен от признаков.
{ path: "notify", label: "Список оповещения", kind: "list", readOnly: true },
// Как «Службы: +» в боевом АРМ: добавить можно, убрать автоматическую — нет.
{ path: "notify_extra", label: "Дополнительно оповестить", kind: "services" },
{ path: "dds", label: "Служба (устаревшее)", kind: "select", options: DDS },
{ path: "dispatch_order_at", label: "Приказ на выезд", kind: "text", readOnly: true },
{ path: "arrival_at", label: "Прибытие", kind: "text", readOnly: true },

View file

@ -47,6 +47,11 @@ function field(card: CardState, path: string): string {
const value = readPath(card.server, path);
return value === null || value === undefined || value === "" ? "—" : String(value);
}
/** Список из карточки: `field()` не годится — `String([])` пустая строка, а не «—». */
function names(card: CardState, path: string): string[] {
const value = readPath(card.server, path);
return Array.isArray(value) ? value.map(String) : [];
}
function time(value: string): string {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString("ru-RU");
@ -386,7 +391,8 @@ export function Dds() {
{Boolean(report.card_results?.length) && <>
<h3>Результаты по карточкам</h3>
{report.card_results?.map((item, index) => <details className="dds-details" key={item.card_id}>
<summary>Карточка {index + 1} · {item.scenario_id} · {item.score_auto.toFixed(0)} / 100</summary>
<summary>Карточка {index + 1} · {item.scenario_id} · {(item.score_reviewed ?? item.score_auto).toFixed(0)} / 100
{item.score_reviewed != null && ` после разбора (автооценка ${item.score_auto.toFixed(0)})`}</summary>
<p><b>Ответ диспетчера:</b> {item.reply_text || "не внесён"}</p>
{item.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) =>
<p key={metric.key} className={metric.passed ? "ref" : "state-violated"}>
@ -556,6 +562,7 @@ export function Dds() {
<div><b>{field(card, "description")}</b></div>
<div>Класс: <b>{field(card, "incident_type")}</b></div>
<div>Получатели карточки: <b>{station?.recipient_services?.join(", ") || field(card, "notify")}</b></div>
{names(card, "notify_extra").length > 0 && <div>Добавлены оператором: <b>{names(card, "notify_extra").join(", ")}</b></div>}
<div>Ваша ДДС: <b>{station?.managed_service || "служба не назначена"}</b></div></div>
</div>
<div className="dds-case-stage">

View file

@ -7,6 +7,7 @@
import { useEffect, useRef, useState } from "react";
import { Debrief } from "@/features/debrief/Debrief";
import { FindingReview } from "@/features/debrief/FindingReview";
import { Director } from "@/features/instructor/Director";
import { ScoreWeightsEditor } from "@/features/instructor/ScoreWeightsEditor";
import { ScenarioEditor } from "@/features/instructor/ScenarioEditor";
@ -22,6 +23,7 @@ import {
useActiveSessions, useIncidentGroups, useReviewScenarioSubmission, useScenarioSubmissions,
useScenarios, useSessions, useTrainees,
} from "@/shared/api/http";
import type { Signal } from "@/shared/api/http";
import { sessionIdFromUrl } from "@/shared/api/session";
import type { Level, SessionMode, SessionReport } from "@/shared/types/generated";
@ -44,6 +46,12 @@ function modeLabel(mode: SessionMode): string {
return MODES.find((item) => item.value === mode)?.label ?? mode;
}
function worstSignalSeverity(signals: Signal[]): "warn" | "violated" | null {
return signals.some((signal) => signal.severity === "violated")
? "violated"
: signals.length ? "warn" : null;
}
function sessionDateLabel(value: string | null): string | null {
if (!value) return null;
const date = new Date(value);
@ -85,6 +93,7 @@ export function Instructor() {
const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180);
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45);
const [allowedErrors, setAllowedErrors] = useState(0);
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
const [lessonScoreWeights, setLessonScoreWeights] = useState<Record<string, number>>({});
@ -299,6 +308,7 @@ export function Instructor() {
decision_time_limit_seconds: decisionTimeLimit,
card_fill_time_limit_seconds: cardFillTimeLimit,
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
dds_report_reaction_limit_seconds: ddsReportReactionLimit,
allowed_errors: allowedErrors,
require_correct_grammar: requireCorrectGrammar,
score_weights: lessonScoreWeights,
@ -306,7 +316,7 @@ export function Instructor() {
});
if (sent) toStart.current = null;
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
randomScenario, visibleScenarios]);
@ -338,7 +348,7 @@ export function Instructor() {
Список ваших активных сессий обновляется автоматически каждые 2 секунды.
</p>
<div className="dds-table-wrap"><table className="grid">
<thead><tr><th>Курсант</th><th>Упражнение · карточка</th><th>Ход</th><th>ДДС</th><th>Задержки</th><th></th></tr></thead>
<thead><tr><th>Курсант</th><th>Упражнение · карточка</th><th>Ход</th><th>ДДС</th><th>Задержки</th><th>Сигналы</th><th></th></tr></thead>
<tbody>
{(activeSessions.data ?? []).map((item) => (
<tr key={item.session_id}>
@ -383,12 +393,18 @@ export function Instructor() {
? `Просрочено: первичная реакция ${item.dds_overdue_cards}; отработка карточки ${item.dds_work_overdue_cards}`
: "Нормативы не нарушены"}
</td>
<td className={worstSignalSeverity(item.signals) ? `state-${worstSignalSeverity(item.signals)}` : item.presence_known ? "state-ok" : undefined}>
{item.signals.length
? item.signals.map((signal) => <span key={signal.kind}>{signal.text}<br /></span>)
: item.presence_known ? "Сигналов нет" : null}
{!item.presence_known && <span>Связь курсанта не проверена</span>}
</td>
<td><button type="button" onClick={() => openActiveSession(item)}>
{sessionId === item.session_id ? "Открыто" : "Наблюдать"}
</button></td>
</tr>
))}
{!activeSessions.data?.length && <tr><td colSpan={6}>
{!activeSessions.data?.length && <tr><td colSpan={7}>
{activeSessions.isLoading ? "Загружаем активные занятия…" : "Активных занятий пока нет."}
</td></tr>}
</tbody>
@ -648,6 +664,13 @@ export function Instructor() {
onCommit={setDdsCardWorkTimeLimit} />
<small className="ref">По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.</small>
</label>}
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
Реакция на доклад бригады, секунд
<LessonNumberInput label="Лимит реакции на доклад бригады, секунд"
value={ddsReportReactionLimit} min={5} max={600} fallback={45}
onCommit={setDdsReportReactionLimit} />
<small className="ref">По умолчанию 45 секунд: статус по докладу бригады — не позже лимита.</small>
</label>}
<label className="lesson-criterion">
Допустимо нарушенных метрик
<LessonNumberInput label="Допустимое число ошибок" value={allowedErrors}
@ -809,6 +832,8 @@ export function Instructor() {
{session.report && <>
<Debrief report={overrideReport?.session_id === session.report.session_id
? overrideReport : session.report} />
<FindingReview report={overrideReport?.session_id === session.report.session_id
? overrideReport : session.report} onChange={setOverrideReport} />
<section className="debrief debrief-score-override">
<h2>Ручная корректировка оценки</h2>
<p className="ref">Автоматическая оценка: {session.report.score_auto.toFixed(1)} из 100. Меняйте её только при необходимости: причина сохранится в журнале вместе с вашим логином.</p>

View file

@ -3,7 +3,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Level, SessionMode, SessionReport, StationSnapshot } from "@/shared/types/generated";
import type {
Level, SessionMode, SessionReport, StationSnapshot, TimerState,
} from "@/shared/types/generated";
import { reportErrorMessage, reportRetryDelay, shouldRetryReport } from "./report.mjs";
@ -94,6 +96,12 @@ export interface SessionInfo {
end_reason: string | null;
}
export interface Signal {
kind: "backlog" | "refusals" | "offline";
severity: TimerState;
text: string;
}
export interface ActiveSessionInfo {
session_id: string;
trainee_name: string | null;
@ -109,6 +117,8 @@ export interface ActiveSessionInfo {
dds_work_overdue_cards: number;
dds_statuses: Record<string, string>;
dds_snapshot: StationSnapshot | null;
signals: Signal[];
presence_known: boolean;
}
export const useActiveSessions = () =>
@ -230,6 +240,17 @@ export const useIncidentGroups = () => useQuery({
queryFn: () => request<IncidentGroupsPage>("/api/ekp/groups"),
});
export interface ServiceCatalogPage {
source: string;
services: { id: string; name: string; organization: string | null; service_level: string | null; source_reference: string }[];
}
/** Каталог окна «Добавьте службы»: 209 служб, включая районные ДДС. */
export const useServiceCatalog = () => useQuery({
queryKey: ["ekp-services"], staleTime: Infinity,
queryFn: () => request<ServiceCatalogPage>("/api/ekp/services"),
});
export function useSigns(prefix: string[], group: number | null = null) {
const params = new URLSearchParams();
if (group !== null) params.set("group", String(group));

View file

@ -164,8 +164,17 @@ export const callChannel = (
...options,
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
isAcknowledged: (incoming, pending) => {
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch"
|| incoming.source !== "operator") return false;
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch") return false;
// `auto` по всем полям команды с другим значением — сервер её переписал
// (так он отвечает и на отклонённый пакет), а merge.ts уже снял правку из
// pending. Повтор затёр бы серверную истину, а слияние с новыми правками
// отклоняло бы и их. Совпадающий `auto` ничего не доказывает: команда
// могла не дойти, повтор безвреден.
if (incoming.source === "auto") {
const keys = Object.keys(pending.fields);
return keys.every((key) => key in incoming.fields)
&& keys.some((key) => !sameJsonValue(pending.fields[key], incoming.fields[key]));
}
return Object.entries(pending.fields).every(([key, value]) =>
sameJsonValue(value, incoming.fields[key]),
);

View file

@ -177,6 +177,7 @@ export interface DdsCardReport {
card_id: string;
scenario_id: string;
score_auto: number;
score_reviewed?: number | null;
reply_text: string;
metrics: Array<Metric>;
findings: Array<Finding>;
@ -267,6 +268,23 @@ export interface Finding {
competency?: Competency | null;
transcript_ref?: string | null;
at?: string | null;
metric_key?: string | null;
card?: number | null;
service?: string | null;
author?: string | null;
client_id?: string | null;
reviews?: Array<FindingReview>;
}
/** Решение преподавателя по отметке на разборе. */
export type FindingDecision = "confirmed" | "dismissed";
/** Кто, когда и почему подтвердил или снял отметку. Сама отметка остаётся. */
export interface FindingReview {
decision: FindingDecision;
reason: string;
author: string;
at: string;
}
/** Источник объяснимой отметки; `judge` оставлен для старых отчётов. */
@ -346,6 +364,8 @@ export interface KIO {
evacuation_needed?: boolean;
/** Список оповещения */
notify?: Array<string>;
/** Дополнительно оповестить */
notify_extra?: Array<string>;
/** Устаревшее: одна служба. Заменено списком оповещения */
dds?: DDSCode | null;
dispatch_order_at?: string | null;
@ -380,6 +400,7 @@ export interface LessonCriteria {
decision_time_limit_seconds?: number;
card_fill_time_limit_seconds?: number;
dds_card_work_time_limit_seconds?: number;
dds_report_reaction_limit_seconds?: number;
allowed_errors?: number;
require_correct_grammar?: boolean;
score_weights?: Record<string, number>;
@ -407,6 +428,10 @@ export interface Metric {
passed: boolean;
weight?: number;
credit?: number | null;
card?: number | null;
service?: string | null;
passed_auto?: boolean | null;
credit_auto?: number | null;
}
export interface ModeSet {
@ -619,6 +644,7 @@ export interface SessionReport {
self_assessment_diff?: SelfAssessmentDiff | null;
notes?: Array<InstructorNoteShown>;
score_auto: number;
score_reviewed?: number | null;
score_final: number;
overridden_by?: string | null;
override_comment?: string | null;

View file

@ -197,6 +197,9 @@ button:disabled { opacity: 0.5; cursor: default; }
.debrief-stat > strong { font-size: 24px; line-height: 1.15; overflow-wrap: anywhere; }
.debrief-stat > strong small { font-size: 14px; font-weight: 500; }
.debrief-stat > small { color: #5a6068; font-size: 12px; }
.finding-review-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.finding-review-actions input { flex: 1 1 200px; min-width: 0; }
.finding-review-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; align-items: end; }
.debrief-stat.is-passed { border-left-color: var(--ok); }
.debrief-stat.is-failed { border-left-color: var(--violated); }
.debrief-next-step { margin: 12px 0 18px; padding: 12px 15px; border: 1px solid var(--line); border-left: 4px solid #6b7780; border-radius: 5px; background: #fff; }
@ -785,6 +788,11 @@ button:disabled { opacity: 0.5; cursor: default; }
.kio-arm-incident .signs select { flex: 1 1 180px; width: auto; max-width: 100%; padding: 7px 8px; border: 1px solid #acb5bc; background: white; }
.kio-arm-ref .signs-selected { display: flex; flex: 1 1 100%; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
.kio-arm-ref .signs-selected span { padding: 7px 10px; background: #1876b4; color: white; font-size: 12px; font-weight: 700; }
.kio-services { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.kio-services .signs-selected { display: flex; flex-wrap: wrap; gap: 5px; }
.kio-services .signs-selected button { border: 0; background: transparent; color: inherit; cursor: pointer; padding: 0 0 0 4px; }
.kio-services-found { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); max-height: 220px; overflow-y: auto; }
.kio-services-found button { width: 100%; text-align: left; border: 0; border-bottom: 1px solid var(--line); background: var(--panel); padding: 6px 8px; cursor: pointer; }
.kio-arm-choice { display: flex; flex-wrap: wrap; gap: 6px; padding: 5px 0; }
.kio-arm-choice button { min-width: 49px; padding: 6px 11px; border: 1px solid #aeb6bd; background: white; font-size: 13px; font-weight: 700; }
.kio-arm-choice .kio-arm-selected { border-color: #1979bb; background: #1979bb; color: white; }

View file

@ -174,31 +174,62 @@ def ws_connect(ws_base: str, path: str, cookie: str):
)
async def wait_for_fence(socket, timeout: float = 10) -> dict:
# Текст `_close_auth_state_unavailable` в backend/app/api/auth.py: 1013 с ним —
# закрытие от устаревшего кэша поколений, а не произвольный 1013.
AUTH_UNAVAILABLE_REASON = "Состояние доступа временно недоступно"
async def wait_auth_fresh(base: str, cookie: str, timeout: float = 10) -> bool:
"""Узел принял cookie: кэш поколений свежий и логин ему известен."""
request = urllib.request.Request(f"{base}/api/auth/me", headers={"Cookie": cookie})
deadline = time.monotonic() + timeout
while True:
try:
with urllib.request.urlopen(request, timeout=2) as response:
if response.status == 200:
return True
except OSError:
pass
if time.monotonic() > deadline:
return False
await asyncio.sleep(0.2)
async def wait_for_fence(socket, timeout: float = 10, ack_id: str | None = None) -> dict:
acked = False
def outcome(**closure) -> dict:
if ack_id is not None:
closure["command_acked"] = acked
return closure
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
message = await asyncio.wait_for(socket.recv(), timeout=deadline - time.monotonic())
except TimeoutError:
return {"closed": False, "fence_notice": False, "reason": "timeout"}
return outcome(closed=False, fence_notice=False, reason="timeout")
except Exception as exc:
close = getattr(exc, "rcvd", None) or getattr(exc, "sent", None)
close_code = getattr(close, "code", None) or getattr(exc, "code", None)
return {
"closed": True,
"fence_notice": close_code == 1012,
"close_code": close_code,
"reason": getattr(close, "reason", "") if close else str(exc),
}
return outcome(
closed=True,
fence_notice=close_code == 1012,
close_code=close_code,
reason=getattr(close, "reason", "") if close else str(exc),
)
if isinstance(message, str):
try:
event = json.loads(message)
except json.JSONDecodeError:
continue
if ack_id is not None and event.get("type") == "command.ack" \
and event.get("command_id") == ack_id:
acked = True
if event.get("message") == "Занятие передано другому backend-узлу; переподключитесь.":
return {"closed": True, "fence_notice": True, "close_code": 1012,
"reason": event.get("message")}
return {"closed": False, "fence_notice": False, "reason": "timeout"}
return outcome(closed=True, fence_notice=True, close_code=1012,
reason=event.get("message"))
return outcome(closed=False, fence_notice=False, reason="timeout")
async def main(args: argparse.Namespace) -> int:
@ -250,6 +281,13 @@ async def main(args: argparse.Namespace) -> int:
instructor_cookie = login(backend, instructor_login, instructor_password)
trainee_cookie = login(backend, trainee_login, trainee_password)
# Учётки созданы прямо в БД, вход — на узле A, а сокет занятия Nginx
# может отдать узлу B. B узнаёт новый логин при очередной сверке
# поколений (раз в секунду); до неё middleware считает cookie отозванной.
for port in (args.backend_port, args.backend_b_port):
for cookie in (instructor_cookie, trainee_cookie):
if not await wait_auth_fresh(f"http://127.0.0.1:{port}", cookie):
raise RuntimeError(f"backend :{port} не узнал новый логин за 10 с")
await send_control(ws_base, session_id, instructor_cookie, {
"type": "scenario.start", "scenario_id": args.scenario,
"trainee": trainee.name, "trainee_id": str(trainee.id),
@ -400,14 +438,37 @@ async def main(args: argparse.Namespace) -> int:
)
compose(args, "stop", proxy_name)
fault_stopped = True
result["boundary_command_sent_to_old_owner"] = "arrived"
# Реальная мутация старому владельцу уже без БД: commit не пройдёт,
# ACK не придёт, а id не должен попасть в checkpoint нового
# владельца — прямое доказательство запрета записи после потери БД.
# Отправка сразу, до `compose ps`: через 2 с auth закроет сокет.
boundary_command_id = str(uuid4())
boundary = {"command_id": boundary_command_id, "status": "working"}
try:
await active_sockets["station"].send(json.dumps({
"type": "card.status", "service": service, "status": "working",
"comment": (
"Основание: доклад по карточке.\n"
f"Сведения: начало работ после обрыва БД; {service} notified."
),
"_command_id": boundary_command_id,
}, ensure_ascii=False))
boundary["sent"] = True
except websockets.exceptions.WebSocketException as exc:
boundary["sent"] = False
boundary["error"] = f"{type(exc).__name__}: {exc}"
result["boundary_command_sent_to_old_owner"] = boundary
result["checks"]["boundary_command_sent_after_partition"] = boundary["sent"]
running_services = compose(args, "ps", "--status", "running", "--services").splitlines()
result["checks"]["owner_process_running"] = owner_service in running_services
if owner_service not in running_services:
raise RuntimeError("owner process did not remain running during DB partition")
closures = await asyncio.gather(*(
wait_for_fence(socket, timeout=args.takeover_timeout)
for socket in active_sockets.values()
wait_for_fence(
socket, timeout=args.takeover_timeout,
ack_id=boundary_command_id if channel == "station" else None,
)
for channel, socket in active_sockets.items()
))
result["existing_channel_closures"] = dict(zip(active_sockets, closures))
result["existing_channel_fence_notices"] = {
@ -418,10 +479,27 @@ async def main(args: argparse.Namespace) -> int:
f"existing_{channel}_closed": closure["closed"]
for channel, closure in result["existing_channel_closures"].items()
})
# Без БД старый владелец закрывает каналы fail-closed одним из двух
# путей: auth не может сверить поколения учёток дольше 2 с — 1013,
# supervisor lease замечает потерю владения (цикл 5 с) — 1012.
# Auth обычно успевает первым; оба кода корректны, фронт их не
# различает. Фактический код остаётся в existing_channel_closures.
result["existing_channel_close_codes"] = {
channel: closure.get("close_code")
for channel, closure in result["existing_channel_closures"].items()
}
result["checks"].update({
f"existing_{channel}_fenced_with_1012": closure["fence_notice"]
f"existing_{channel}_closed_fail_closed": (
closure["fence_notice"] or (
closure.get("close_code") == 1013
and closure.get("reason") == AUTH_UNAVAILABLE_REASON
)
)
for channel, closure in result["existing_channel_closures"].items()
})
result["checks"]["boundary_command_not_acked"] = (
result["existing_channel_closures"]["station"].get("command_acked") is False
)
deadline = time.monotonic() + args.takeover_timeout
takeover = None
@ -457,12 +535,24 @@ async def main(args: argparse.Namespace) -> int:
except Exception as exc:
result["checks"]["old_owner_process_healthy"] = False
result["old_owner_health_error"] = f"{type(exc).__name__}: {exc}"
# Auth на старом узле снова свежий: 1013 до accept исключён, и 403
# на рукопожатии ниже остаётся только за fencing занятия (1012).
result["checks"]["old_owner_auth_fresh"] = await wait_auth_fresh(
old_backend, instructor_cookie,
)
stale = await probe_ws(
old_backend.replace("http://", "ws://"),
f"/ws/control/{session_id}", instructor_cookie,
)
result["stale_owner_control"] = stale
result["checks"]["stale_owner_control_rejected"] = not stale["connected"]
result["checks"]["stale_owner_control_rejected"] = (
not stale["connected"] and "HTTP 403" in stale.get("error", "")
)
# БД вернулась к старому владельцу, но его команда после обрыва так
# и не должна появиться в checkpoint: fencing epoch отверг запись.
result["checks"]["boundary_command_absent_from_checkpoint"] = (
not await wait_for_command_checkpoint(boundary_command_id, timeout=1)
)
request = urllib.request.Request(
f"{args.frontend_url}/api/sessions/{session_id}",
@ -524,6 +614,9 @@ async def main(args: argparse.Namespace) -> int:
result["checks"]["boundary_command_reconciled"] = recovered_status in {
"responding", "arrived",
}
if args.failure_mode == "partition":
# `working` после обрыва не применён: статус остался на lost-ack `arrived`.
result["checks"]["boundary_command_not_applied"] = recovered_status == "arrived"
result["dds_progress_after_takeover"] = []
if recovered_status == "responding":
recovered_snapshot = await station_command(ws_base, session_id, trainee_cookie, {