diff --git a/.env.example b/.env.example
index 486bd1a..1b64d3b 100644
--- a/.env.example
+++ b/.env.example
@@ -25,6 +25,7 @@ SIP_6002_PASSWORD=
SIP_6003_PASSWORD=
SIP_6101_PASSWORD=
SIP_6102_PASSWORD=
+SIP_6103_PASSWORD=
# IP/DNS сервера, который WebRTC-клиенты видят в локальной сети. При запуске
# только на той же машине оставьте 127.0.0.1; RTP-порты должны отображаться 1:1.
SIP_EXTERNAL_MEDIA_ADDRESS=127.0.0.1
diff --git a/Makefile b/Makefile
index ca17c3b..58e5f4c 100644
--- a/Makefile
+++ b/Makefile
@@ -47,7 +47,7 @@ sip: ## Собрать и поднять локальный Asterisk SIP/VoIP
offline-sip: ## Поднять SIP из уже собранного образа без сети
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --pull never --no-build -d sip
-sip-credentials: ## Показать локальные пароли софтфонов 6001–6003 и WebRTC 6101–6102
+sip-credentials: ## Показать локальные пароли софтфонов 6001–6003 и WebRTC 6101–6103
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml exec -T sip cat /var/lib/lct-sip/credentials.env
sip-test: ## Проверить регистрацию, звонок, RTP и задержку (args="--rtp-host-offset 1000")
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
index 17af9ce..0570d32 100644
--- a/backend/app/api/auth.py
+++ b/backend/app/api/auth.py
@@ -28,7 +28,7 @@ from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import APIRouter, HTTPException, Request, WebSocket
from pydantic import BaseModel, Field
-from sqlalchemy import select
+from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from starlette.websockets import WebSocketDisconnect
@@ -56,6 +56,26 @@ _vanished: set[str] = set()
AUTH_GENERATION_SYNC_SECONDS = 1.0
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
_generations_synced_at: float | None = None
+# Один lock на ещё неизвестный узлу логин: параллельные HTTP и WS входы
+# разделяют один запрос к БД до очередной синхронизации (lct-42). Запись
+# удаляется, когда lock больше никто не ждёт: иначе словарь растёт с каждым
+# логином, который узел когда-либо проверял.
+_lookup_locks: dict[str, "_LoginLookup"] = {}
+# Логины, которых разовая проверка не нашла в users. Держатся до следующей
+# сверки: повтор старой cookie не должен давать SELECT на каждый запрос.
+_missing_logins: set[str] = set()
+
+
+class _LoginLookup:
+ __slots__ = ("lock", "users")
+
+ def __init__(self) -> None:
+ self.lock = asyncio.Lock()
+ self.users = 0
+
+
+class _AuthStateUnavailable(Exception):
+ """Разовая проверка неизвестного узлу логина не смогла обратиться к БД."""
async def _close_revoked(ws: WebSocket) -> None:
@@ -98,6 +118,7 @@ def prime_generations(values: dict[str, int]) -> None:
global _generations_synced_at
_generations.clear()
_generations.update(values)
+ _missing_logins.clear()
_generations_synced_at = time.monotonic()
@@ -108,15 +129,38 @@ async def load_generations() -> None:
async def sync_generations() -> None:
- """Refresh shared account epochs and close sockets revoked on peer nodes."""
+ """Сверить версии полномочий и закрыть отозванные на другом узле сокеты."""
+ known_before_query = set(_generations)
async with get_sessionmaker()() as db:
rows = (await db.execute(select(User.login, User.auth_version))).all()
- current = {login: version for login, version in rows}
+ current = {login: version for login, version in rows}
+ # Версия узла выше БД, если отзыв не удалось записать (неудачный
+ # logout) или учётку пересоздали после удаления. Без записи в БД
+ # узлы расходятся навсегда: соседний узел выдаёт cookie со старой
+ # версией, а этот её отвергает. Поднимаем БД до версии узла — отзыв
+ # сохраняется, остальные узлы догоняют за одну сверку. При гонке со
+ # снимком БД уже выше, и UPDATE ничего не меняет.
+ ahead = {
+ login: _generations[login]
+ for login, version in current.items()
+ if login in _generations and _generations[login] > version
+ }
+ if ahead:
+ try:
+ for login, local in ahead.items():
+ await db.execute(_raise_auth_version(login, local))
+ await db.commit()
+ except Exception as exc: # noqa: BLE001 — повторим на следующей сверке
+ log.error("не удалось записать версию полномочий узла (%s)",
+ type(exc).__name__)
+ await db.rollback()
for login, version in current.items():
previous = _generations.get(login)
+ # Снимок БД мог устареть за время запроса; меньшая версия не должна
+ # отменять локальный отзыв, который уже поднял поколение.
if previous is None:
_generations[login] = version
- elif previous != version:
+ elif version > previous:
invalidate_login(login, version)
# Account deletion is not exposed by the application. Still close active
# sockets if an operator removes one directly from the shared directory DB.
@@ -127,13 +171,88 @@ async def sync_generations() -> None:
# lookup fall back to 0 and re-accept cookies issued before the revocation.
synthetic = {"dev"} if get_settings().dev_auth_bypass else set()
_vanished.intersection_update(_generations.keys() - current.keys())
- for login in _generations.keys() - current.keys() - synthetic - _vanished:
+ # Разовая проверка могла найти учётку после снимка этого запроса.
+ # Её отсутствие в старом снимке не означает отзыв.
+ for login in known_before_query - current.keys() - synthetic - _vanished:
invalidate_login(login)
_vanished.add(login)
+ # Снимок только что прочитан: учётка, созданная до него, уже в кэше.
+ _missing_logins.clear()
global _generations_synced_at
_generations_synced_at = time.monotonic()
+def _raise_auth_version(login: str, version: int):
+ # Только вверх: параллельная запись в БД могла уже поднять версию выше.
+ return (
+ update(User)
+ .where(User.login == login, User.auth_version < version)
+ .values(auth_version=version)
+ )
+
+
+async def _resolve_unknown_login(login: str) -> int | None:
+ """Проверить неизвестный узлу логин сразу, не дожидаясь опроса БД.
+
+ Отозванный логин остаётся в `_generations` (см. `_vanished`), поэтому
+ отсутствие в кэше означает, что этот узел ещё не видел учётку.
+ Возвращает версию или None, если строки в `users` нет. При недоступной
+ или зависшей БД вызывает `_AuthStateUnavailable`, чтобы вход был закрыт
+ с 503/1013.
+ """
+ if login in _missing_logins:
+ return None
+ entry = _lookup_locks.get(login)
+ if entry is None:
+ entry = _lookup_locks[login] = _LoginLookup()
+ entry.users += 1
+ try:
+ # Тот же предел, что у сверки: при partition handshake не ждёт
+ # таймаута TCP, а очередь за lock не растягивает ожидание сверх него.
+ async with asyncio.timeout(AUTH_GENERATION_MAX_AGE_SECONDS):
+ async with entry.lock:
+ cached = _generations.get(login)
+ if cached is not None:
+ return cached # параллельный запрос уже получил версию
+ if login in _missing_logins:
+ return None
+ async with get_sessionmaker()() as db:
+ version = await db.scalar(
+ select(User.auth_version).where(User.login == login)
+ )
+ # Пока шёл SELECT, локальный отзыв или синхронизация могли
+ # записать новую версию. Старый ответ не должен вернуть
+ # отозванную cookie.
+ cached = _generations.get(login)
+ if cached is not None:
+ return cached
+ if version is None:
+ _missing_logins.add(login)
+ return None
+ _generations[login] = version
+ log.warning(
+ "учётка %s найдена разовой проверкой до сверки узла",
+ login_log_marker(login),
+ )
+ return version
+ except Exception as exc: # noqa: BLE001 — ошибка или таймаут БД закрывают вход
+ log.error("разовая проверка полномочий не удалась (%s)", type(exc).__name__)
+ raise _AuthStateUnavailable from exc
+ finally:
+ entry.users -= 1
+ if entry.users == 0 and _lookup_locks.get(login) is entry:
+ del _lookup_locks[login]
+
+
+def login_log_marker(login: str) -> str:
+ """Метка логина для журнала без самого логина.
+
+ По ней кластерный смоук доказывает, что узел прошёл через разовую
+ проверку, а не увидел учётку обычной сверкой.
+ """
+ return hashlib.sha256(f"lct-login:{login}".encode()).hexdigest()[:12]
+
+
async def watch_generations() -> None:
"""Poll PostgreSQL once per node so remote logout/role changes close WS."""
while True:
@@ -151,6 +270,21 @@ async def watch_generations() -> None:
await asyncio.sleep(AUTH_GENERATION_SYNC_SECONDS)
+async def _send_auth_state_unavailable(scope, send) -> None:
+ if scope["type"] == "websocket":
+ await send({"type": "websocket.close", "code": 1013})
+ else:
+ await send({
+ "type": "http.response.start",
+ "status": 503,
+ "headers": [(b"content-type", b"application/json")],
+ })
+ await send({
+ "type": "http.response.body",
+ "body": b'{"detail":"auth_state_unavailable"}',
+ })
+
+
class AuthVersionMiddleware:
"""Check signed-cookie epochs against the fresh, DB-synchronized node cache."""
@@ -186,22 +320,17 @@ class AuthVersionMiddleware:
synced_at is None
or time.monotonic() - synced_at > AUTH_GENERATION_MAX_AGE_SECONDS
):
- if scope["type"] == "websocket":
- await send({"type": "websocket.close", "code": 1013})
- else:
- await send({
- "type": "http.response.start",
- "status": 503,
- "headers": [(b"content-type", b"application/json")],
- })
- await send({
- "type": "http.response.body",
- "body": b'{"detail":"auth_state_unavailable"}',
- })
+ await _send_auth_state_unavailable(scope, send)
return
cookie_version = session.get("auth_generation")
version = _generations.get(login)
+ if version is None:
+ try:
+ version = await _resolve_unknown_login(login)
+ except _AuthStateUnavailable:
+ await _send_auth_state_unavailable(scope, send)
+ return
if version is None or cookie_version != version:
if session is not None:
session.clear()
@@ -536,7 +665,20 @@ async def login(payload: LoginIn, request: Request) -> dict:
await audit_required(user.login, user.role, "login.blocked")
raise HTTPException(status_code=403, detail="blocked")
- if _generations.get(user.login) != user.auth_version:
+ local_version = _generations.get(user.login)
+ if local_version is not None and local_version > user.auth_version:
+ # Отзыв на этом узле не дошёл до БД (неудачный logout). Сброс к версии
+ # БД вернул бы силу cookie, выданной до выхода; выдача cookie с
+ # версией узла без записи в БД развела бы узлы. Поднимаем БД.
+ try:
+ async with get_sessionmaker()() as db:
+ await db.execute(_raise_auth_version(user.login, local_version))
+ await db.commit()
+ except Exception as exc: # noqa: BLE001 — cookie без записанной версии не выдаём
+ log.error("вход: версию полномочий не удалось записать (%s)",
+ type(exc).__name__)
+ raise HTTPException(status_code=503, detail="auth_state_unavailable") from exc
+ elif local_version != user.auth_version:
invalidate_login(user.login, user.auth_version)
who = Principal(
diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py
index dc74837..ad3c152 100644
--- a/backend/app/api/http/sessions.py
+++ b/backend/app/api/http/sessions.py
@@ -23,6 +23,14 @@ 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,
@@ -34,9 +42,13 @@ from app.domain.statuses import (
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
@@ -117,6 +129,7 @@ class ActiveSessionOut(BaseModel):
dds_overdue_cards: int
dds_work_overdue_cards: int
dds_statuses: dict[str, str]
+ paused: bool = False
dds_snapshot: StationSnapshot | None = None
signals: list[Signal] = []
presence_known: bool = True
@@ -298,7 +311,10 @@ async def active(
checkpoint_only.add(state.session_id)
for state in states.values():
- elapsed = (max(0, int((now - state.started_at).total_seconds()))
+ paused_ms = state.total_paused_ms
+ if state.paused and state.paused_at is not None:
+ paused_ms += max(0, int((now - state.paused_at).total_seconds() * 1000))
+ elapsed = (max(0, int((now - state.started_at).total_seconds() - paused_ms / 1000))
if state.started_at else 0)
station = state.station_snapshot() if state.dds_phase else None
queue = station.queue_cards if station else []
@@ -326,12 +342,15 @@ async def active(
),
dds_work_overdue_cards=sum(
(timer := card.timers.timers.get(TimerCode.DDS_WORK)) is not None
- and timer.started_at is not None
+ # Пауза держит `started_at=None`, хотя таймер уже шёл: судить
+ # по нему одному спрятало бы уже случившееся нарушение (lct-39).
+ and (timer.started_at is not None or timer.paused)
and not timer.stopped
and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK]
for card in state.desk.cards.values()
),
dds_statuses=latest_statuses,
+ paused=state.paused,
dds_snapshot=station,
signals=_signals(state, queue, now, live=state.session_id not in checkpoint_only),
presence_known=state.session_id not in checkpoint_only,
@@ -657,6 +676,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,
diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py
index 4925c41..bb867be 100644
--- a/backend/app/api/ws/call.py
+++ b/backend/app/api/ws/call.py
@@ -31,6 +31,7 @@ from app.domain.events import (
PatchSource,
ScoreReady,
SessionMode,
+ SessionPaused,
StationState,
TimerTick,
TextTurnAccepted,
@@ -192,6 +193,14 @@ async def _handle(session_id: UUID, state, event) -> None:
code=ErrorKind.UNSUPPORTED_EVENT, message="Занятие уже завершено",
))
return
+ if state.paused:
+ # Отказ временный, в отличие от отклонённого значения: `kio.patch`
+ # без `auto`-отката. useCall держит правку в pending и повторяет её
+ # после `session.paused: false` — откат стёр бы ввод курсанта.
+ hub.to_trainee(session_id, ErrorEvent(
+ code=ErrorKind.UNSUPPORTED_EVENT, message="Пауза, ждите преподавателя",
+ ))
+ return
if (event.type == "text.turn" and state.exercise is not Exercise.CARD) or state.exercise is Exercise.DDS or (
state.exercise is Exercise.CARD and event.type not in {"kio.patch", "card.submit", "text.turn"}
) or (state.exercise is Exercise.CALL and event.type == "card.submit"):
@@ -230,6 +239,9 @@ async def _handle(session_id: UUID, state, event) -> None:
code=ErrorKind.INTERNAL, message="Не удалось получить ответ заявителя. Попробуйте ещё раз.",
))
return
+ if state.paused:
+ # Ответ модели мог закончиться уже после команды преподавателя.
+ return
caller_entry = state.append(Speaker.CALLER, line.text, line.mood)
hub.to_trainee(session_id, CallerUtterance(
utterance_id=uuid4(), text=line.text,
@@ -420,6 +432,8 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
if hub.is_lease_fenced(session_id):
await close_fenced(ws)
return
+ # Переподключившийся клиент сначала узнаёт, разрешён ли ввод.
+ queue.put_nowait(SessionPaused(paused=state.paused))
if state.exercise is Exercise.CARD:
from app.api.ws.control import card_briefing
@@ -462,6 +476,10 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
# Бинарные кадры — аудио, текстовые — события. Направление определяется
# каналом, обёртки JSON вокруг звука нет (docs/arch/CONTRACT.md).
if message.get("bytes") is not None:
+ if state.paused:
+ # JSON-команды отказывает `_handle`; бинарные кадры сюда
+ # не заходят, поэтому пауза глушит звук здесь же (lct-39).
+ continue
if state.exercise is Exercise.CALL:
_on_audio(session_id, state, message["bytes"])
else:
diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py
index 179302f..ac507fb 100644
--- a/backend/app/api/ws/control.py
+++ b/backend/app/api/ws/control.py
@@ -41,6 +41,7 @@ from app.domain.events import (
ModeSet,
ReferenceStarted,
SessionEnded,
+ SessionPaused,
StationState,
)
from app.domain.roles import Role
@@ -51,7 +52,14 @@ from app.session.access import can_access
from app.session.hub import hub
from app.session.finish import end_session, override_score
from app.session.state import SessionState, now_utc
-from app.session.store import LessonIdentity, LessonRequest, NoteAdded, ScoreOverridden
+from app.session.store import (
+ LessonIdentity,
+ LessonPaused,
+ LessonRequest,
+ LessonResumed,
+ NoteAdded,
+ ScoreOverridden,
+)
from app.voice.models import get_voice_models
from app.voice.pipeline import FILLERS, prefetch
@@ -345,6 +353,26 @@ async def _command(session_id: UUID, event, who) -> None:
await _start(session_id, event, who)
case "session.stop":
await _stop(session_id)
+ case "session.pause":
+ if state is None or state.ended or state.paused:
+ return
+ state.pause()
+ hub.record(session_id, LessonPaused(
+ at=state.paused_at, author=who.login, role=who.role.value,
+ ))
+ hub.broadcast(session_id, SessionPaused(paused=True))
+ hub.to_station(session_id, SessionPaused(paused=True))
+ case "session.resume":
+ if state is None or state.ended or not state.paused:
+ return
+ paused_ms_before = state.total_paused_ms
+ state.resume()
+ hub.record(session_id, LessonResumed(
+ at=now_utc(), author=who.login, role=who.role.value,
+ paused_ms=state.total_paused_ms - paused_ms_before,
+ ))
+ hub.broadcast(session_id, SessionPaused(paused=False))
+ hub.to_station(session_id, SessionPaused(paused=False))
case "instructor_note.add":
if state is not None:
state.notes.append({
@@ -391,6 +419,14 @@ async def _command(session_id: UUID, event, who) -> None:
case "director.inject":
if state is None:
return
+ if state.paused:
+ # Обрыв на паузе запустил бы норматив обратного дозвона, и простой
+ # ушёл бы в него; курсант же за баннером паузы не может ответить.
+ hub.to_observers(session_id, ErrorEvent(
+ code=ErrorKind.UNSUPPORTED_EVENT,
+ message="Занятие на паузе",
+ ))
+ return
if state.exercise is not Exercise.CALL:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py
index 596341c..e00153a 100644
--- a/backend/app/api/ws/station.py
+++ b/backend/app/api/ws/station.py
@@ -3,9 +3,9 @@
Учебный исходящий звонок бригаде имитируется событиями JSON. Для целевого
локального прототипа не нужна внешняя телефония или аудиомодель.
-Самая ценная механика цепочки — `card.bounce`: диспетчер видит, что не указан
-этаж, и отбивает карточку обратно. Неполнота КИО перестаёт быть процентом
-в отчёте и становится сорванным выездом с конкретной причиной.
+`card.bounce` сервер отклоняет: ДДС не возвращает карточку в 112 при принятии
+и не правит её поля. Об ошибке, которую вскрыл доклад бригады с места,
+диспетчер сообщает в 112 по телефону (ответ заказчика, П.3).
Правила пульта — в `DdsDesk.apply`; здесь разбор команды и рассылка итога.
Отсев повторов и операция хранилища — в `run_command`.
@@ -39,6 +39,9 @@ def _error(session_id: UUID, message: str) -> None:
async def _handle(session_id: UUID, state, event) -> None:
+ if state.paused:
+ _error(session_id, "Пауза, ждите преподавателя")
+ return
outcome = state.desk.apply(event, state)
if outcome.error is not None:
_error(session_id, outcome.error)
diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py
index 495708e..9965d1d 100644
--- a/backend/app/domain/events.py
+++ b/backend/app/domain/events.py
@@ -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,9 @@ 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_enabled: bool = False
+ 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 +63,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()):
@@ -274,6 +277,13 @@ class ErrorEvent(BaseModel):
message: str
+class SessionPaused(BaseModel):
+ """Пульт остановил или возобновил время занятия (lct-39)."""
+
+ type: Literal["session.paused"] = "session.paused"
+ paused: bool
+
+
ServerToTrainee = Annotated[
CallIncoming
| CardBriefing
@@ -293,6 +303,7 @@ ServerToTrainee = Annotated[
| TimerTick
| CallEnded
| ScoreReady
+ | SessionPaused
| ErrorEvent,
Field(discriminator="type"),
]
@@ -404,6 +415,8 @@ class SessionSnapshot(BaseModel):
timers: list[TimerSnapshot]
hints_used: int = 0
ended: bool = False
+ #: Пульт остановил время занятия (lct-39).
+ paused: bool = False
class TranscriptAppend(BaseModel):
@@ -458,6 +471,7 @@ ServerToObserver = Annotated[
| ScoreReady
| InstructorNoteShown
| ReferenceStarted
+ | SessionPaused
| ErrorEvent,
Field(discriminator="type"),
]
@@ -518,13 +532,25 @@ class SessionStop(BaseModel):
type: Literal["session.stop"] = "session.stop"
+class SessionPause(BaseModel):
+ """Остановить время занятия; карточку курсанта команда не меняет (lct-39)."""
+
+ type: Literal["session.pause"] = "session.pause"
+
+
+class SessionResume(BaseModel):
+ type: Literal["session.resume"] = "session.resume"
+
+
InstructorToServer = Annotated[
ScenarioStart
| DirectorInject
| ReferencePlay
| InstructorNoteAdd
| ScoreOverride
- | SessionStop,
+ | SessionStop
+ | SessionPause
+ | SessionResume,
Field(discriminator="type"),
]
@@ -665,7 +691,7 @@ class CrewArrived(BaseModel):
ServerToStation = Annotated[
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded
- | ScoreReady | CommandAck | ErrorEvent,
+ | ScoreReady | CommandAck | SessionPaused | ErrorEvent,
Field(discriminator="type"),
]
@@ -692,6 +718,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):
@@ -741,6 +773,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]
@@ -780,9 +814,13 @@ 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
+ #: Суммарная длительность пауз преподавателя — время объяснимо (lct-39).
+ total_paused_ms: int = 0
class Principal(BaseModel):
diff --git a/backend/app/domain/kio.py b/backend/app/domain/kio.py
index a8158a8..6760c27 100644
--- a/backend/app/domain/kio.py
+++ b/backend/app/domain/kio.py
@@ -112,6 +112,7 @@ class KIO(BaseModel):
description: str | None = None
victims_count: int | None = None
is_emergency: bool = False
+ offence_signs: bool | None = None
life_threat: bool = False
evacuation_needed: bool = False
@@ -153,7 +154,7 @@ EDITABLE_KIO_FIELDS: frozenset[str] = frozenset({
"caller_name", "caller_contact", "phone_on_scene", "language",
"okato", "address", "street", "building", "entrance", "floor",
"intercom_code", "description", "incident_group", "signs",
- "incident_type", "victims_count", "is_emergency", "life_threat",
+ "incident_type", "victims_count", "is_emergency", "offence_signs", "life_threat",
"evacuation_needed", "dds", "notify_extra",
"fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified",
"fire.people_inside", "fire.smoke_spread",
@@ -193,7 +194,10 @@ def missing_fields(card: KIO, required: list[str]) -> list[str]:
#: те же «признаки», только не про тип происшествия, а про обстоятельства:
#: пострадавшие поднимают скорую, газификация — МОСГАЗ (domain/ekp.py).
MODIFIER_FIELDS: frozenset[str] = frozenset(
- {"incident_group", "signs", "victims_count", "life_threat", "evacuation_needed", "fire.gasified"}
+ {
+ "incident_group", "signs", "victims_count", "life_threat",
+ "evacuation_needed", "fire.gasified", "offence_signs", "police.offence_kind",
+ }
)
@@ -208,6 +212,12 @@ def modifiers(card: KIO) -> list[str]:
keys.append("evacuation")
if card.fire is not None and card.fire.gasified:
keys.append("gas")
+ if card.offence_signs is True or (
+ card.police is not None
+ and card.police.offence_kind is not None
+ and bool(card.police.offence_kind.strip())
+ ):
+ keys.append("offence")
return keys
diff --git a/backend/app/domain/statuses.py b/backend/app/domain/statuses.py
index 2f5be73..133ea46 100644
--- a/backend/app/domain/statuses.py
+++ b/backend/app/domain/statuses.py
@@ -276,3 +276,5 @@ class StationSnapshot(BaseModel):
pending_cards_count: int = 0
next_arrival_in_seconds: int | None = None
max_waiting_cards: int = 3
+ #: Пульт на паузе: время не идёт, новая карточка не поступит (lct-39).
+ paused: bool = False
diff --git a/backend/app/domain/taxonomy.py b/backend/app/domain/taxonomy.py
index 3de1537..476212f 100644
--- a/backend/app/domain/taxonomy.py
+++ b/backend/app/domain/taxonomy.py
@@ -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
diff --git a/backend/app/scoring/card.py b/backend/app/scoring/card.py
index 7bc761a..37ce36b 100644
--- a/backend/app/scoring/card.py
+++ b/backend/app/scoring/card.py
@@ -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:
diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py
index 1efc1d9..d2598eb 100644
--- a/backend/app/scoring/dispatcher.py
+++ b/backend/app/scoring/dispatcher.py
@@ -11,11 +11,16 @@
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
"""
+import math
+from collections.abc import Sequence
+from datetime import datetime
+
from app.domain.statuses import (
COMMENT_REQUIRED,
PRIMARY,
REFUSAL_COMMENT_REQUIRED,
SERVICE_STATUS_LABELS,
+ PhoneReportRecord,
ServiceStatus,
StatusEntry,
current,
@@ -23,19 +28,121 @@ 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],
+ pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
+) -> 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)
+ if after is None:
+ result.append((report, None))
+ continue
+ elapsed_ms = int((after - report.at).total_seconds() * 1000)
+ for start, end in pause_intervals or ():
+ overlap_start = max(report.at, start)
+ overlap_end = min(after, end)
+ if overlap_end > overlap_start:
+ elapsed_ms -= int((overlap_end - overlap_start).total_seconds() * 1000)
+ result.append((report, max(0, elapsed_ms)))
+ return result
+
+
+def report_reaction(
+ service: str, marks: list[StatusEntry], reports: list[PhoneReportRecord], limit_ms: int,
+ pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
+) -> 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, pause_intervals)
+ 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 +198,10 @@ 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,
+ report_reaction_enabled: bool = False,
+ pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[Finding]:
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
@@ -117,7 +228,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 +243,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 +263,7 @@ def evaluate_dispatcher(
]
if missing_recipients:
findings.append(_finding(
- ErrorCode.D5,
+ service, ErrorCode.D5,
f"{service}: отдельный комментарий не указывает получателя сведений",
fact="; ".join(missing_recipients)[:500],
norm=(
@@ -167,7 +278,7 @@ def evaluate_dispatcher(
if elapsed_ms is None or elapsed_ms > deadline_ms:
findings.append(
_finding(
- ErrorCode.D1,
+ service, ErrorCode.D1,
(f"{service}: карточка открыта с нарушением срока"
if elapsed_ms is not None else f"{service}: карточка не открыта"),
fact=(
@@ -184,7 +295,7 @@ def evaluate_dispatcher(
if primary is None:
findings.append(
_finding(
- ErrorCode.D1,
+ service, ErrorCode.D1,
f"{service}: статус реагирования не проставлен",
fact="карточка осталась без первичного статуса",
norm="проставить «Принята» или «Не принята» с комментарием",
@@ -197,7 +308,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 +320,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 +329,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 +343,7 @@ def evaluate_dispatcher(
if service not in crew_assignments:
findings.append(
_finding(
- ErrorCode.D2,
+ service, ErrorCode.D2,
f"{service}: «Принята» без назначения бригады и хода реагирования",
fact="бригада не выбрана, после приёма статусов не было",
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
@@ -241,7 +352,7 @@ def evaluate_dispatcher(
else:
findings.append(
_finding(
- ErrorCode.D2,
+ service, ErrorCode.D2,
f"{service}: «Принята», но о реагировании ничего не отмечено",
fact="после приёма статусов не было",
norm="статус должен соответствовать фактическому состоянию заявки",
@@ -252,7 +363,7 @@ def evaluate_dispatcher(
) and service not in crew_assignments:
findings.append(
_finding(
- ErrorCode.D2,
+ service, ErrorCode.D2,
f"{service}: ход реагирования без назначения бригады",
fact="статусы хода работ проставлены, бригада не выбрана",
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
@@ -262,15 +373,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,16 +392,25 @@ def evaluate_dispatcher(
)
)
+ # D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
+ if report_reaction_enabled:
+ reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals)
+ if reaction is not None and reaction[1] is not None:
+ findings.append(reaction[1])
+
return findings
def dispatcher_metrics(
- state,
+ card,
deadline_ms: int,
expected_decision: str = "accept",
expected_decision_reason: str | None = None,
*,
services: list[str] | None = None,
+ report_reaction_ms: int = REPORT_REACTION_MS,
+ report_reaction_enabled: bool = False,
+ pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
@@ -295,21 +418,21 @@ def dispatcher_metrics(
источник информации, но не обязательный шлюз статуса.
"""
metrics: list[Metric] = []
- for service in (services if services is not None else state.managed_services()):
- marks = [mark for mark in state.status_log if mark.service == service]
+ # Через таймер, а не `primary.at - dispatched_at`: календарная разница
+ # включила бы паузу преподавателя в норматив (lct-39).
+ elapsed = card.timers.measured_ms(TimerCode.DDS_ACK)
+ for service in (services if services is not None else card.managed_services()):
+ marks = [mark for mark in card.status_log if mark.service == service]
statuses = {mark.status for mark in marks}
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,
))
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
- # Время открытия — то, что зафиксировал таймер событием, а не разница
- # отметок: 30 с по ответу заказчика идут до открытия карточки.
- elapsed = state.timers.measured_ms(TimerCode.DDS_ACK)
add("dds_primary", "решение по карточке", primary is not None,
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
add("dds_ack", f"открытие карточки за {deadline_ms // 1000} с",
@@ -318,6 +441,13 @@ def dispatcher_metrics(
f"≤ {deadline_ms // 1000} с")
if primary is None:
continue
+ # Метрика есть только там, где бригада докладывала: без докладов
+ # реагировать не на что, и отсутствие звонков не штраф.
+ reports = [report for report in card.phone_reports if report.service == service]
+ if report_reaction_enabled:
+ reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals)
+ 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 "Не принята"
@@ -327,7 +457,7 @@ def dispatcher_metrics(
expected_decision_reason or f"по эталону ожидается «{expected_label}»", 2.0)
if primary.status is ServiceStatus.DECLINED or expected_decision == "decline":
continue
- crew = state.crew_assignments.get(service)
+ crew = card.crew_assignments.get(service)
add("dds_crew", "назначение бригады", bool(crew),
crew or "бригада не выбрана",
"необходимую бригаду выбирает ДДС вручную", 2.0)
diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py
index 86e6116..4775d0f 100644
--- a/backend/app/scoring/export.py
+++ b/backend/app/scoring/export.py
@@ -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,16 @@ 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
+ if report.criteria.dds_report_reaction_enabled else "не применяется")
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 +74,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 +99,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 +173,9 @@ 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} с"
+ if report.criteria.dds_report_reaction_enabled else "не применяется"),
("Допустимо ошибок", report.criteria.allowed_errors),
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
("Переопределения весов", report.criteria.score_weights or "стандартные"),
@@ -160,6 +185,9 @@ def to_pdf(report: SessionReport) -> bytes:
("Итоговая оценка", report.score_final),
):
story.append(Paragraph(f"{escape(label)}: {escape(str(value))}", base))
+ # Когда итог правили вручную, без этой строки не понять, какой балл дал разбор.
+ if report.score_reviewed is not None:
+ story.append(Paragraph(f"После разбора отметок: {report.score_reviewed}", base))
if report.overridden_by:
story.append(Paragraph(f"Оценка изменена: {escape(report.overridden_by)}", base))
if report.override_comment:
@@ -183,7 +211,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:
diff --git a/backend/app/scoring/gost.py b/backend/app/scoring/gost.py
index b1bcce6..3ed2ac9 100644
--- a/backend/app/scoring/gost.py
+++ b/backend/app/scoring/gost.py
@@ -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",
)
)
diff --git a/backend/app/scoring/report.py b/backend/app/scoring/report.py
index 3f7a9aa..c48fac4 100644
--- a/backend/app/scoring/report.py
+++ b/backend/app/scoring/report.py
@@ -106,9 +106,11 @@ 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"),
ai_coaching=(AICoaching.model_validate(score["ai_coaching"])
if score.get("ai_coaching") else None),
+ total_paused_ms=state.total_paused_ms,
)
diff --git a/backend/app/scoring/review.py b/backend/app/scoring/review.py
new file mode 100644
index 0000000..67425a2
--- /dev/null
+++ b/backend/app/scoring/review.py
@@ -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])
diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py
index 28f8dc8..55e750b 100644
--- a/backend/app/scoring/taxonomy.py
+++ b/backend/app/scoring/taxonomy.py
@@ -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,
}
diff --git a/backend/app/scoring/timing.py b/backend/app/scoring/timing.py
index 90f9db9..e444c65 100644
--- a/backend/app/scoring/timing.py
+++ b/backend/app/scoring/timing.py
@@ -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,
)
diff --git a/backend/app/session/dds.py b/backend/app/session/dds.py
index 712b908..1c30bab 100644
--- a/backend/app/session/dds.py
+++ b/backend/app/session/dds.py
@@ -597,8 +597,13 @@ class DdsDesk(BaseModel):
self.finish_work()
outcome.finished = True
case "card.bounce":
+ # Возврата карточки на этапе принятия нет; об ошибке, которую
+ # вскрыл доклад бригады с места, ДДС сообщает в 112 по телефону
+ # (ответ заказчика, П.3).
return _refused(
- "ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112"
+ "Карточку не возвращают в 112 при принятии и не правят на АРМ ДДС. "
+ "Если ошибка вскрылась по докладу бригады с места — сообщите о ней "
+ "в 112 по телефону"
)
case "zone.decision":
previous = next(
diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py
index 2899ef2..9573616 100644
--- a/backend/app/session/finish.py
+++ b/backend/app/session/finish.py
@@ -12,7 +12,7 @@ import logging
import time
from uuid import UUID
-from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded
+from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded, SessionPaused
from app.domain.timers import TimerCode
from app.scoring.ai_coach import coach
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
@@ -25,10 +25,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,
)
@@ -44,6 +48,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,
@@ -53,10 +58,16 @@ 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,
+ report_reaction_enabled=state.criteria.dds_report_reaction_enabled,
+ pause_intervals=state.pause_intervals,
)
metrics = dispatcher_metrics(
card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
- services=services,
+ services=services, report_reaction_ms=reaction_ms,
+ report_reaction_enabled=state.criteria.dds_report_reaction_enabled,
+ pause_intervals=state.pause_intervals,
)
weighted = GostResult(metrics=metrics, findings=findings)
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]
@@ -94,9 +105,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)),
@@ -175,7 +188,14 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
"""
if state.ended:
return
+ was_paused = state.paused
+ paused_ms_before = state.total_paused_ms
ended_at = state.end(reason)
+ if was_paused:
+ hub.record(session_id, LessonResumed(
+ at=ended_at, author="система", role="system",
+ paused_ms=state.total_paused_ms - paused_ms_before,
+ ))
if state.exercise is Exercise.CARD and state.dispatched_card is None:
state.on_event("card.end")
# Норматив отработки фиксируется событием, а не текущим значением часов.
@@ -194,6 +214,11 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
hub.to_station(session_id, SessionEnded(reason=reason))
else:
hub.to_trainee(session_id, CallEnded(reason=reason))
+ if was_paused:
+ # Снятие паузы — после сообщения о конце: иначе клиент дошлёт отложенные
+ # на паузе правки карточки в уже закрытое занятие.
+ hub.broadcast(session_id, SessionPaused(paused=False))
+ hub.to_station(session_id, SessionPaused(paused=False))
if state.score is None:
return
hub.to_observers(session_id, ScoreReady(session_id=session_id))
@@ -228,3 +253,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))
diff --git a/backend/app/session/hub.py b/backend/app/session/hub.py
index 2925dab..c43c756 100644
--- a/backend/app/session/hub.py
+++ b/backend/app/session/hub.py
@@ -424,7 +424,7 @@ class SessionHub:
state = self.get(session_id)
if state is None or state.ended:
return
- if state.dds_phase:
+ if state.dds_phase and not state.paused:
async with self.operation(session_id) as op:
active_before = state.desk.active_id
op.persist = bool(state.desk.deliver_due())
diff --git a/backend/app/session/pg_store.py b/backend/app/session/pg_store.py
index b35e33c..7c0b37a 100644
--- a/backend/app/session/pg_store.py
+++ b/backend/app/session/pg_store.py
@@ -29,10 +29,14 @@ 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,
+ LessonPaused,
LessonRequest,
+ LessonResumed,
LessonStarted,
NoteAdded,
Record,
@@ -42,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
@@ -196,6 +202,16 @@ class PostgresSessionStore:
object_id=str(session_id),
detail=f"missed_count={len(record.missed)}; comment_chars={len(record.comment)}",
))
+ case LessonPaused(at=at, author=author, role=role):
+ db.add(AuditLog(
+ actor=author, role=role, action="session.pause",
+ object_id=str(session_id), detail=f"at={at.isoformat()}",
+ ))
+ case LessonResumed(at=at, author=author, role=role, paused_ms=paused_ms):
+ db.add(AuditLog(
+ actor=author, role=role, action="session.resume",
+ object_id=str(session_id), detail=f"at={at.isoformat()}; paused_ms={paused_ms}",
+ ))
case LessonStarted(at=at):
await db.execute(
update(Session).where(Session.id == session_id).values(started_at=at)
@@ -239,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
diff --git a/backend/app/session/score.py b/backend/app/session/score.py
index 10c637c..35fe772 100644
--- a/backend/app/session/score.py
+++ b/backend/app/session/score.py
@@ -8,6 +8,7 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
+from datetime import datetime
from typing import Any
from app.domain.events import CallEndReason, Exercise, LessonCriteria, Metric
@@ -68,6 +69,7 @@ class ScoringSnapshot:
#: Карточка на пульте в упражнении по звонку: отметки D1–D6 рядом с E1–E6.
call_card: DdsLiveCard | None
call_card_services: tuple[str, ...]
+ pause_intervals: tuple[tuple[datetime, datetime], ...]
@property
def reports_cards(self) -> bool:
@@ -112,6 +114,7 @@ class ScoringSnapshot:
call_card=live_card.model_copy(deep=True) if live_card is not None else None,
call_card_services=(tuple(state.card_services(live_card))
if live_card is not None else ()),
+ pause_intervals=tuple(state.pause_intervals),
)
@@ -210,6 +213,7 @@ async def score_session(
norm="грамматически корректное описание",
ref="критерий занятия; правила русского языка",
competency=Competency.COMMUNICATION,
+ metric_key="description_grammar",
))
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
@@ -218,6 +222,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 +232,16 @@ 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,
+ report_reaction_enabled=snapshot.criteria.dds_report_reaction_enabled,
+ pause_intervals=snapshot.pause_intervals,
))
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,
+ report_reaction_enabled=snapshot.criteria.dds_report_reaction_enabled,
+ pause_intervals=snapshot.pause_intervals,
))
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
# занятия. Веса первого сценария поверх них испортили бы остальные.
diff --git a/backend/app/session/state.py b/backend/app/session/state.py
index 28d0237..5c99a5c 100644
--- a/backend/app/session/state.py
+++ b/backend/app/session/state.py
@@ -105,6 +105,14 @@ class PersistedSession(BaseModel):
started_at: datetime | None = None
ended_at: datetime | None = None
end_reason: CallEndReason | None = None
+ #: Пульт остановил время занятия; таймеры и очередь ДДС заморожены (lct-39).
+ paused: bool = False
+ #: Момент постановки на паузу — им же считается длительность простоя при снятии.
+ paused_at: datetime | None = None
+ #: Суммарная длительность пауз — для разбора, чтобы время было объяснимо.
+ total_paused_ms: int = 0
+ #: Закрытые интервалы пауз нужны для расчёта активного времени между докладом и статусом.
+ pause_intervals: list[tuple[datetime, datetime]] = Field(default_factory=list)
#: Карточка оператора 112, замороженная при передаче в ДДС.
dispatched_card: KIO | None = None
dispatched_at: datetime | None = None
@@ -190,6 +198,38 @@ class SessionState(PersistedSession):
"""Единственная точка, где событие двигает таймеры."""
self.timers.on_event(event_type)
+ def pause(self) -> None:
+ """Заморозить таймеры занятия и карточек пульта; очередь не выдаёт следующую."""
+ if self.paused:
+ return
+ now = time.monotonic()
+ self.timers.pause(now)
+ for card in self.desk.cards.values():
+ card.timers.pause(now)
+ self.paused = True
+ self.paused_at = now_utc()
+ if self.voice is not None:
+ self.voice.pause()
+
+ def resume(self) -> None:
+ """Снять паузу: таймеры продолжают с прежней отметки, очередь сдвигается на простой."""
+ if not self.paused:
+ return
+ now = time.monotonic()
+ self.timers.resume(now)
+ for card in self.desk.cards.values():
+ card.timers.resume(now)
+ if self.paused_at is not None:
+ resumed_at = now_utc()
+ idle = resumed_at - self.paused_at
+ self.total_paused_ms += max(0, int(idle.total_seconds() * 1000))
+ if resumed_at > self.paused_at:
+ self.pause_intervals.append((self.paused_at, resumed_at))
+ if self.desk.next_arrival_at is not None:
+ self.desk.next_arrival_at += idle
+ self.paused = False
+ self.paused_at = None
+
def append(self, speaker: Speaker, text: str, mood: Mood | None = None) -> TranscriptEntry:
entry = TranscriptEntry(
ref=f"u{len(self.transcript) + 1}",
@@ -331,14 +371,15 @@ class SessionState(PersistedSession):
services = self._managed_services_for(recipients)
log = list(card.status_log) if card is not None else []
statuses = {service: current(log, service) for service in services}
+ now_mono = time.monotonic()
overdue = False
if card is not None:
- elapsed = card.timers.measured_ms(TimerCode.DDS_ACK)
+ # Через таймер, а не через `dispatched_at`: часы разошлись бы с
+ # паузой, которая держит `current_ms` на месте (lct-39).
+ ack_timer = card.timers.timers.get(TimerCode.DDS_ACK)
limit = card.timers.limits[TimerCode.DDS_ACK]
- overdue = elapsed is None and (
- (now_utc() - card.dispatched_at).total_seconds() * 1000 > limit
- )
- now_mono = time.monotonic()
+ elapsed_ms = ack_timer.current_ms(now_mono) if ack_timer else 0
+ overdue = not (ack_timer.stopped if ack_timer else False) and elapsed_ms > limit
queue_cards: list[DdsQueueCard] = []
for item in (desk.ordered() if self.dds_phase else []):
recipients_for_card = list(item.dispatched_card.notify)
@@ -409,6 +450,7 @@ class SessionState(PersistedSession):
if desk.next_arrival_at is not None else None
),
max_waiting_cards=desk.max_waiting,
+ paused=self.paused,
)
def card_received_event(self):
@@ -431,7 +473,14 @@ class SessionState(PersistedSession):
return self.ended_at is not None
def end(self, reason: CallEndReason) -> datetime:
- """Закрыть занятие; возвращает момент окончания для журнала."""
+ """Закрыть занятие; возвращает момент окончания для журнала.
+
+ Занятие могут завершить прямо на паузе (преподаватель нажал «Завершить»,
+ не возобновив): снять флаг и досчитать простой сейчас, иначе курсант
+ навсегда останется за баннером «Пауза, ждите преподавателя», а разбор
+ потеряет последний, ещё не закрытый интервал паузы (lct-39).
+ """
+ self.resume()
self.ended_at = now_utc()
self.end_reason = reason
return self.ended_at
@@ -457,4 +506,5 @@ class SessionState(PersistedSession):
timers=self.shown_timers().snapshot(),
hints_used=len(self.hints_shown),
ended=self.ended,
+ paused=self.paused,
)
diff --git a/backend/app/session/store.py b/backend/app/session/store.py
index ea053b1..2823793 100644
--- a/backend/app/session/store.py
+++ b/backend/app/session/store.py
@@ -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
@@ -83,8 +85,47 @@ class ScoreOverridden:
comment: str
+@dataclass(frozen=True)
+class LessonPaused:
+ at: datetime
+ author: str
+ role: str
+
+
+@dataclass(frozen=True)
+class FindingReviewed:
+ """Преподаватель подтвердил или снял отметку разбора."""
+ index: int
+ review: FindingReview
+ role: str
+
+
+@dataclass(frozen=True)
+class LessonResumed:
+ at: datetime
+ author: str
+ role: str
+ #: Длительность этой конкретной паузы — видно в аудите без пересчёта.
+ paused_ms: int
+
+
+@dataclass(frozen=True)
+class FindingAdded:
+ """Преподаватель добавил свою отметку (`source=instructor`)."""
+ finding: Finding
+ role: str
+
+
Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted
- | LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden)
+ | LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden
+ | 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:
@@ -200,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:
diff --git a/backend/app/session/timers.py b/backend/app/session/timers.py
index cfc31a2..c854a4e 100644
--- a/backend/app/session/timers.py
+++ b/backend/app/session/timers.py
@@ -87,6 +87,9 @@ class Timer(BaseModel):
elapsed_ms: int = 0
attempt: int = 1
stopped: bool = False
+ #: Пауза занятия — таймер заморожен, но не завершён: `start()` его не
+ #: считает новой попыткой, в отличие от `stopped` (lct-39).
+ paused: bool = False
@model_serializer(mode="wrap")
def _dump(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
@@ -104,13 +107,20 @@ class Timer(BaseModel):
data = dict(data)
elapsed = max(0, int(data.get("elapsed_ms", 0)))
stopped = bool(data.get("stopped"))
- downtime = 0 if stopped else (info.context or {}).get("downtime_ms", 0)
+ paused = bool(data.get("paused"))
+ # На паузе занятие не простаивало без присмотра — оно ждало
+ # преподавателя, и это время не досчитывается таймеру при перезапуске.
+ downtime = 0 if stopped or paused else (info.context or {}).get("downtime_ms", 0)
started = data.pop("started")
data["started_at"] = time.monotonic() - (elapsed + downtime) / 1000 if started else None
- data["elapsed_ms"] = elapsed if stopped else 0
+ data["elapsed_ms"] = elapsed if (stopped or paused) else 0
return data
def start(self, now: float) -> None:
+ if self.paused:
+ # Таймер уже идёт, просто заморожен: запуск с нуля потерял бы
+ # набранное время, а ход до `resume` посчитал бы паузу.
+ return
if self.stopped:
# Повторный запуск после остановки — это новая попытка (обратный дозвон).
self.attempt += 1
@@ -129,6 +139,19 @@ class Timer(BaseModel):
return self.elapsed_ms
return int((now - self.started_at) * 1000)
+ def pause(self, now: float) -> None:
+ """Зафиксировать прошедшее время и остановить ход часов до `resume`."""
+ if self.started_at is not None and not self.stopped:
+ self.elapsed_ms = self.current_ms(now)
+ self.started_at = None
+ self.paused = True
+
+ def resume(self, now: float) -> None:
+ """Продолжить с той же отметки — простой в счёт не идёт."""
+ if self.paused:
+ self.started_at = now - self.elapsed_ms / 1000
+ self.paused = False
+
class SessionTimers(BaseModel):
"""Набор таймеров одной сессии. `limits` приходит из конфига —
@@ -146,6 +169,16 @@ class SessionTimers(BaseModel):
for code in STOPS.get(event_type, ()):
self.timers.setdefault(code, Timer(code=code)).stop(now)
+ def pause(self, now: float | None = None) -> None:
+ now = time.monotonic() if now is None else now
+ for timer in self.timers.values():
+ timer.pause(now)
+
+ def resume(self, now: float | None = None) -> None:
+ now = time.monotonic() if now is None else now
+ for timer in self.timers.values():
+ timer.resume(now)
+
def snapshot(self, now: float | None = None) -> list[TimerSnapshot]:
"""Только запущенные таймеры: показывать нули по нормативам,
до которых занятие ещё не дошло, значит пугать курсанта зря."""
diff --git a/backend/app/voice/pipeline.py b/backend/app/voice/pipeline.py
index b8a060c..ea11cb8 100644
--- a/backend/app/voice/pipeline.py
+++ b/backend/app/voice/pipeline.py
@@ -86,6 +86,8 @@ class VoiceSession:
# ── вход: кадры микрофона ──
def feed(self, frame: bytes) -> None:
+ if self.state.paused:
+ return
for event in self._vad.push(frame):
if isinstance(event, SpeechStarted) and self.speaking:
self.barge_in()
@@ -104,6 +106,8 @@ class VoiceSession:
первая фраза «Алло! Помогите!» — ровно та, которую оператор перебивает
чаще всего, — не гасилась вовсе.
"""
+ if self.state.paused:
+ return asyncio.create_task(asyncio.sleep(0))
if self.speaking:
self._reply.cancel()
self._reply = asyncio.create_task(self.say(text, mood))
@@ -119,6 +123,17 @@ class VoiceSession:
self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="barge_in"))
log.info("сессия %s: перебивание", self.session_id)
+ def pause(self) -> None:
+ """Прервать текущую реплику и убрать речь, накопленную до паузы."""
+ if self.speaking:
+ self._reply.cancel()
+ if self._utterance_id is not None:
+ self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="director"))
+ self._utterance_id = None
+ while not self._queue.empty():
+ self._queue.get_nowait()
+ self._vad.reset()
+
async def close(self) -> None:
for task in (self._reply, self._worker):
if task is not None:
@@ -129,6 +144,8 @@ class VoiceSession:
async def _work(self) -> None:
while True:
audio, ended_at = await self._queue.get()
+ if self.state.paused:
+ continue
# Новая фраза оператора, пока звонящий ещё говорит, — тоже перебивание.
if self.speaking:
self.barge_in()
@@ -143,6 +160,8 @@ class VoiceSession:
timing = TurnTiming()
started = time.monotonic()
text = await self.models.transcribe(audio)
+ if self.state.paused:
+ return
timing.stt_ms = (time.monotonic() - started) * 1000
if not text:
return
@@ -155,6 +174,8 @@ class VoiceSession:
started = time.monotonic()
line = await self._caller_line(text)
+ if self.state.paused:
+ return
timing.caller_ms = (time.monotonic() - started) * 1000
await self.say(line.text, line.mood, ended_at=ended_at, timing=timing)
@@ -171,6 +192,8 @@ class VoiceSession:
self, text: str, mood: Mood, *, ended_at: float | None = None, timing: TurnTiming | None = None
) -> None:
"""Произнести реплику: событие с текстом, звук по предложениям, ожидание конца."""
+ if self.state.paused:
+ return
self._utterance_id = utterance_id = uuid4()
entry = self.state.append(Speaker.CALLER, text, mood)
self.send_event(CallerUtterance(utterance_id=utterance_id, text=text, at=entry.at, mood=mood))
@@ -186,6 +209,8 @@ class VoiceSession:
pcm = await self._first_sentence(sentence, mood, ended_at, timing)
else:
pcm = await self.synthesize(sentence)
+ if self.state.paused:
+ return
if index == 0 and timing is not None:
timing.tts_first_ms = (time.monotonic() - synth_started) * 1000
if not pcm:
@@ -205,6 +230,7 @@ class VoiceSession:
# Ждём, пока курсант дослушает: перебивание в это время отменит задачу.
await asyncio.sleep(max(0.0, playback_ends - time.monotonic()))
self.send_event(TtsEnd(utterance_id=utterance_id))
+ self._utterance_id = None
async def _first_sentence(
self, sentence: str, mood: Mood, ended_at: float, timing: TurnTiming | None
@@ -212,17 +238,25 @@ class VoiceSession:
"""Первое предложение с филлером: если к секунде после конца фразы звука
ещё нет, звонящий «переспрашивает», а ответ встанет в очередь за ним."""
synthesis = asyncio.ensure_future(self.synthesize(sentence))
- remaining = FILLER_AFTER_S - (time.monotonic() - ended_at)
- if remaining > 0:
- done, _ = await asyncio.wait({synthesis}, timeout=remaining)
- if done:
- return synthesis.result()
- filler = await self.synthesize(FILLERS.get(mood, FILLERS[Mood.PANIC]))
- if filler:
- self.send_audio(filler)
- if timing is not None:
- timing.filler = True
- return await synthesis
+ try:
+ remaining = FILLER_AFTER_S - (time.monotonic() - ended_at)
+ if remaining > 0:
+ done, _ = await asyncio.wait({synthesis}, timeout=remaining)
+ if done:
+ return synthesis.result()
+ filler = await self.synthesize(FILLERS.get(mood, FILLERS[Mood.PANIC]))
+ if filler and not self.state.paused:
+ self.send_audio(filler)
+ if timing is not None:
+ timing.filler = True
+ return await synthesis
+ finally:
+ if not synthesis.done():
+ synthesis.cancel()
+ try:
+ await synthesis
+ except asyncio.CancelledError:
+ pass
async def synthesize(self, text: str) -> bytes:
return await cached_synthesize(self.models, text)
diff --git a/backend/app/voice/vad.py b/backend/app/voice/vad.py
index a5561f1..17fcced 100644
--- a/backend/app/voice/vad.py
+++ b/backend/app/voice/vad.py
@@ -71,6 +71,17 @@ class StreamingVad:
def in_speech(self) -> bool:
return self._in_speech
+ def reset(self) -> None:
+ """Не склеивать фрагменты речи до и после паузы занятия."""
+ self._state.fill(0)
+ self._context.fill(0)
+ self._pending = np.zeros(0, dtype=np.float32)
+ self._preroll.clear()
+ self._speech.clear()
+ self._voiced_ms = 0
+ self._silence_ms = 0
+ self._in_speech = False
+
def _probability(self, window: np.ndarray) -> float:
frame = np.concatenate([self._context, window])[None, :]
output, self._state = self._session.run(
diff --git a/backend/tests/test_auth_hardening.py b/backend/tests/test_auth_hardening.py
index eec963a..f116913 100644
--- a/backend/tests/test_auth_hardening.py
+++ b/backend/tests/test_auth_hardening.py
@@ -135,6 +135,229 @@ def test_generation_sync_preserves_synthetic_dev_account(monkeypatch):
asyncio.run(run())
+@pytest.mark.asyncio
+async def test_stale_generation_snapshot_does_not_revoke_newly_resolved_login(monkeypatch):
+ snapshot_read = asyncio.Event()
+ release_snapshot = asyncio.Event()
+
+ class FakeResult:
+ def all(self):
+ return []
+
+ class FakeDb:
+ async def execute(self, _query):
+ snapshot_read.set()
+ await release_snapshot.wait()
+ return FakeResult()
+
+ async def scalar(self, _query):
+ return 0
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ auth.prime_generations({})
+ monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ sync = asyncio.create_task(auth.sync_generations())
+ await snapshot_read.wait()
+ assert await auth._resolve_unknown_login("just-created") == 0
+ release_snapshot.set()
+ await sync
+ assert auth._generations["just-created"] == 0
+ assert "just-created" not in auth._vanished
+
+
+@pytest.mark.asyncio
+async def test_stale_generation_snapshot_does_not_restore_revoked_cookie(monkeypatch):
+ snapshot_read = asyncio.Event()
+ release_snapshot = asyncio.Event()
+
+ raised = []
+
+ class FakeResult:
+ def all(self):
+ return [("revoked", 0)]
+
+ class FakeDb:
+ async def execute(self, query):
+ if query.is_update:
+ raised.append(query.compile().params)
+ return None
+ snapshot_read.set()
+ await release_snapshot.wait()
+ return FakeResult()
+
+ async def commit(self):
+ return None
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ auth.prime_generations({"revoked": 0})
+ monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ sync = asyncio.create_task(auth.sync_generations())
+ await snapshot_read.wait()
+ auth.invalidate_login("revoked", 1)
+ release_snapshot.set()
+ await sync
+ assert auth._generations["revoked"] == 1
+ # В БД версия уже 1 (отзыв записан до invalidate); UPDATE «только вверх»
+ # в реальной БД ничего не изменит, в снимке же он выглядит как отставание.
+ assert [params["auth_version"] for params in raised] == [1]
+
+
+def _versioned_users_db(versions: dict[str, int]):
+ """Поддельная users: сверка читает версии, UPDATE поднимает их только вверх."""
+ updates = []
+
+ class FakeResult:
+ def all(self):
+ return list(versions.items())
+
+ class FakeDb:
+ async def execute(self, query):
+ if query.is_update:
+ params = query.compile().params
+ login = next(v for k, v in params.items() if k.startswith("login"))
+ target = params["auth_version"]
+ updates.append((login, target))
+ if versions.get(login, target) < target:
+ versions[login] = target
+ return None
+ return FakeResult()
+
+ async def scalar(self, _query):
+ raise AssertionError("login is cached, no one-shot lookup expected")
+
+ async def commit(self):
+ return None
+
+ async def rollback(self):
+ return None
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ return updates, lambda: lambda: FakeSession()
+
+
+@pytest.mark.asyncio
+async def test_failed_logout_revocation_is_written_to_db_so_nodes_converge(monkeypatch):
+ """Неудачный logout поднял версию только на этом узле (B). Сверка не
+ откатывает отзыв, а записывает его в БД: соседний узел A догоняет, и вход
+ на A после этого выдаёт cookie, которую B принимает."""
+ login = "failed-logout-user"
+ versions = {login: 4}
+ updates, sessionmaker = _versioned_users_db(versions)
+ monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
+ monkeypatch.setattr(auth, "get_sessionmaker", sessionmaker)
+ auth.prime_generations({login: 4})
+ try:
+ auth.invalidate_login(login) # ветка except в logout: БД недоступна
+ assert auth._generations[login] == 5
+
+ await auth.sync_generations()
+ assert updates == [(login, 5)]
+ assert versions[login] == 5, "revocation must reach the shared DB"
+ assert auth._generations[login] == 5, "sync must not roll back a local revocation"
+
+ # Вход на A берёт версию из БД, и теперь она совпадает с версией B.
+ assert versions[login] == auth._generations[login]
+
+ await auth.sync_generations()
+ assert updates == [(login, 5)], "converged nodes must not write again"
+ finally:
+ auth._generations.pop(login, None)
+
+
+@pytest.mark.asyncio
+async def test_recreated_account_is_raised_to_the_node_revocation_version(monkeypatch):
+ """Учётку удалили (узел отозвал её, поколение +1) и создали заново с 0.
+ Без записи в БД узел остался бы впереди навсегда."""
+ login = "recreated-user"
+ versions = {}
+ updates, sessionmaker = _versioned_users_db(versions)
+ monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
+ monkeypatch.setattr(auth, "get_sessionmaker", sessionmaker)
+ auth.prime_generations({login: 2})
+ try:
+ await auth.sync_generations()
+ assert auth._generations[login] == 3
+ assert login in auth._vanished
+
+ versions[login] = 0 # оператор создал учётку заново
+ await auth.sync_generations()
+ assert updates == [(login, 3)]
+ assert versions[login] == 3
+ assert login not in auth._vanished
+ finally:
+ auth._generations.pop(login, None)
+ auth._vanished.discard(login)
+
+
+def test_login_raises_db_version_when_node_revocation_was_not_persisted(client, monkeypatch):
+ """Вход на узле, где отзыв не дошёл до БД: cookie получает версию узла,
+ а БД поднимается до неё. Сброс к версии БД вернул бы силу старой cookie."""
+ from app.config import get_settings
+
+ user = SimpleNamespace(
+ login="ahead-login", auth_provider="local", password_hash="hash",
+ blocked=False, role="instructor", full_name="Преподаватель",
+ service=None, trainee_id=None, auth_version=1,
+ )
+ updates = []
+
+ class FakeDb:
+ async def scalar(self, _statement):
+ return user
+
+ async def execute(self, query):
+ assert query.is_update
+ updates.append(query.compile().params["auth_version"])
+
+ async def commit(self):
+ return None
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ async def audit_ok(*_args, **_kwargs):
+ return True
+
+ settings = get_settings().model_copy(update={"demo_no_db": False, "ldap_enabled": False})
+ monkeypatch.setattr(auth, "get_settings", lambda: settings)
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ monkeypatch.setattr(auth, "verify_password", lambda *_args: True)
+ monkeypatch.setattr(auth, "audit", audit_ok)
+ auth._generations[user.login] = 2 # неудачный logout на этом узле
+ try:
+ response = client.post("/api/auth/login", json={"login": user.login, "password": "x"})
+ assert response.status_code == 200
+ assert updates == [2]
+ assert auth._generations[user.login] == 2, "local revocation must not be rolled back"
+ assert client.get("/api/auth/me").status_code == 200
+ finally:
+ auth._generations.pop(user.login, None)
+
+
def test_cross_origin_browser_websocket_is_rejected_before_handshake(client):
assert client.post("/api/auth/dev-token").status_code == 200
with pytest.raises(WebSocketDisconnect) as exc:
@@ -410,6 +633,401 @@ def test_auth_middleware_fails_closed_when_generation_cache_is_stale_but_allows_
asyncio.run(run())
+def test_middleware_resolves_and_caches_login_unknown_to_this_node(monkeypatch):
+ """Cluster handshake (lct-42): a login another node just authenticated is
+ fetched via a single SELECT rather than being treated as revoked, and the
+ result is cached so a second request for it does not query again."""
+ login = "peer-node-fresh-login"
+ calls = []
+
+ class FakeDb:
+ async def scalar(self, _query):
+ calls.append(1)
+ return 5
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ class InnerApp:
+ def __init__(self):
+ self.called = 0
+
+ async def __call__(self, _scope, _receive, _send):
+ self.called += 1
+
+ async def run():
+ auth.prime_generations({})
+
+ def make_scope():
+ return {
+ "type": "http", "path": "/api/admin/users",
+ "session": {
+ "principal": {"login": login},
+ "auth_instance": auth._INSTANCE,
+ "auth_generation": 5,
+ },
+ }
+
+ async def receive():
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ async def send(_message):
+ return None
+
+ first = InnerApp()
+ await auth.AuthVersionMiddleware(first)(make_scope(), receive, send)
+ assert first.called == 1, "a fresh, valid epoch must reach the route"
+ assert auth._generations[login] == 5
+ assert calls == [1]
+
+ second = InnerApp()
+ await auth.AuthVersionMiddleware(second)(make_scope(), receive, send)
+ assert second.called == 1
+ assert calls == [1], "cached epoch must not trigger a second SELECT"
+
+ settings = auth.get_settings().model_copy(update={"demo_no_db": False})
+ monkeypatch.setattr(auth, "get_settings", lambda: settings)
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ try:
+ asyncio.run(run())
+ finally:
+ auth._generations.pop(login, None)
+ auth._lookup_locks.pop(login, None)
+
+
+def test_middleware_rejects_login_missing_from_users_via_one_shot_query(monkeypatch):
+ login = "peer-node-deleted-login"
+
+ class FakeDb:
+ async def scalar(self, _query):
+ return None
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ class InnerApp:
+ def __init__(self):
+ self.called = False
+
+ async def __call__(self, _scope, _receive, _send):
+ self.called = True
+
+ async def run():
+ auth.prime_generations({})
+ cookie_session = {
+ "principal": {"login": login},
+ "auth_instance": auth._INSTANCE,
+ "auth_generation": 3,
+ }
+ scope = {"type": "http", "path": "/api/admin/users", "session": cookie_session}
+
+ async def receive():
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ async def send(_message):
+ return None
+
+ protected = InnerApp()
+ await auth.AuthVersionMiddleware(protected)(scope, receive, send)
+ assert protected.called, "route still runs but the session was cleared below"
+ assert cookie_session == {}
+ assert login not in auth._generations
+
+ settings = auth.get_settings().model_copy(update={"demo_no_db": False})
+ monkeypatch.setattr(auth, "get_settings", lambda: settings)
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ try:
+ asyncio.run(run())
+ finally:
+ auth._lookup_locks.pop(login, None)
+
+
+def test_unknown_login_lookup_does_not_clobber_a_newer_local_revocation(monkeypatch):
+ """A stale SELECT reply landing after a concurrent local revoke must not
+ resurrect the revoked cookie's epoch (regression: lock only deduplicates
+ concurrent lookups, it does not order a lookup against a write)."""
+ login = "peer-node-race-login"
+ started = asyncio.Event()
+ resume = asyncio.Event()
+
+ class FakeDb:
+ async def scalar(self, _query):
+ started.set()
+ await resume.wait()
+ return 0 # the epoch as it stood before the concurrent revoke below
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ async def run():
+ auth.prime_generations({})
+ task = asyncio.ensure_future(auth._resolve_unknown_login(login))
+ await started.wait() # the SELECT is in flight, holding the login's lock
+ auth.invalidate_login(login, 1) # a local logout/edit races the reply
+ resume.set()
+ result = await task
+ assert result == 1, "the newer local revocation must win over the stale SELECT reply"
+ assert auth._generations[login] == 1
+
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ try:
+ asyncio.run(run())
+ finally:
+ auth._generations.pop(login, None)
+ auth._lookup_locks.pop(login, None)
+
+
+def test_unknown_login_lookup_deduplicates_concurrent_requests_into_one_select(monkeypatch):
+ login = "peer-node-concurrent-login"
+ calls = []
+
+ class FakeDb:
+ async def scalar(self, _query):
+ calls.append(1)
+ await asyncio.sleep(0.01) # widen the window for a racing second caller
+ return 9
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ async def run():
+ auth.prime_generations({})
+ results = await asyncio.gather(
+ auth._resolve_unknown_login(login),
+ auth._resolve_unknown_login(login),
+ auth._resolve_unknown_login(login),
+ )
+ assert results == [9, 9, 9]
+ assert calls == [1], "concurrent lookups for one login must issue a single SELECT"
+ assert login not in auth._lookup_locks, "lock entry must not outlive its waiters"
+
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ try:
+ asyncio.run(run())
+ finally:
+ auth._generations.pop(login, None)
+ auth._lookup_locks.pop(login, None)
+
+
+def test_unknown_login_lookup_fails_closed_when_database_is_unreachable(monkeypatch):
+ login = "peer-node-db-outage-login"
+
+ class BrokenSession:
+ async def __aenter__(self):
+ raise OSError("database unavailable")
+
+ async def __aexit__(self, *_args):
+ return None
+
+ class InnerApp:
+ def __init__(self):
+ self.called = False
+
+ async def __call__(self, _scope, _receive, _send):
+ self.called = True
+
+ async def run():
+ auth.prime_generations({})
+ cookie_session = {
+ "principal": {"login": login},
+ "auth_instance": auth._INSTANCE,
+ "auth_generation": 0,
+ }
+
+ async def receive():
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ http_messages = []
+
+ async def http_send(message):
+ http_messages.append(message)
+
+ http_app = InnerApp()
+ await auth.AuthVersionMiddleware(http_app)(
+ {"type": "http", "path": "/api/admin/users", "session": dict(cookie_session)},
+ receive, http_send,
+ )
+ assert not http_app.called
+ assert http_messages[0]["status"] == 503
+
+ ws_messages = []
+
+ async def ws_send(message):
+ ws_messages.append(message)
+
+ ws_app = InnerApp()
+ await auth.AuthVersionMiddleware(ws_app)(
+ {"type": "websocket", "path": "/ws/control/x", "session": dict(cookie_session)},
+ receive, ws_send,
+ )
+ assert not ws_app.called
+ assert ws_messages[0] == {"type": "websocket.close", "code": 1013}
+
+ settings = auth.get_settings().model_copy(update={"demo_no_db": False})
+ monkeypatch.setattr(auth, "get_settings", lambda: settings)
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: BrokenSession())
+ try:
+ asyncio.run(run())
+ finally:
+ auth._lookup_locks.pop(login, None)
+
+
+def test_unknown_login_lookup_times_out_instead_of_hanging_on_partition(monkeypatch):
+ """При partition SELECT может висеть до таймаута TCP. Разовая проверка
+ ограничена тем же порогом, что сверка, и закрывает вход 503 / 1013 —
+ и для запроса, который ждёт lock за зависшим."""
+ login = "peer-node-hanging-db-login"
+ monkeypatch.setattr(auth, "AUTH_GENERATION_MAX_AGE_SECONDS", 0.3)
+
+ class HangingDb:
+ async def scalar(self, _query):
+ await asyncio.Event().wait()
+
+ class FakeSession:
+ async def __aenter__(self):
+ return HangingDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ class InnerApp:
+ called = False
+
+ async def __call__(self, _scope, _receive, _send):
+ self.called = True
+
+ async def run():
+ auth.prime_generations({})
+ cookie_session = {
+ "principal": {"login": login},
+ "auth_instance": auth._INSTANCE,
+ "auth_generation": 0,
+ }
+
+ async def receive():
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ http_messages, ws_messages = [], []
+
+ async def http_send(message):
+ http_messages.append(message)
+
+ async def ws_send(message):
+ ws_messages.append(message)
+
+ http_app, ws_app = InnerApp(), InnerApp()
+ started = asyncio.get_running_loop().time()
+ await asyncio.wait_for(asyncio.gather(
+ auth.AuthVersionMiddleware(http_app)(
+ {"type": "http", "path": "/api/admin/users", "session": dict(cookie_session)},
+ receive, http_send,
+ ),
+ auth.AuthVersionMiddleware(ws_app)(
+ {"type": "websocket", "path": "/ws/control/x", "session": dict(cookie_session)},
+ receive, ws_send,
+ ),
+ ), timeout=2)
+ elapsed = asyncio.get_running_loop().time() - started
+ assert elapsed < 1, "a queued handshake must not wait for a second timeout"
+ assert not http_app.called and not ws_app.called
+ assert http_messages[0]["status"] == 503
+ assert ws_messages[0] == {"type": "websocket.close", "code": 1013}
+ assert login not in auth._lookup_locks, "timed-out lookup must release its lock entry"
+
+ settings = auth.get_settings().model_copy(update={"demo_no_db": False})
+ monkeypatch.setattr(auth, "get_settings", lambda: settings)
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ asyncio.run(run())
+
+
+def test_one_shot_lookup_logs_login_marker_not_login(caplog, monkeypatch):
+ """Кластерный смоук ищет эту метку в журнале узла B: она доказывает, что
+ вход прошёл через разовую проверку, а не через обычную сверку."""
+ login = "peer-node-logged-login"
+
+ class FakeDb:
+ async def scalar(self, _query):
+ return 0
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ async def run():
+ auth.prime_generations({})
+ assert await auth._resolve_unknown_login(login) == 0
+
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ try:
+ with caplog.at_level("WARNING", logger="app.api.auth"):
+ asyncio.run(run())
+ finally:
+ auth._generations.pop(login, None)
+ assert auth.login_log_marker(login) in caplog.text
+ assert login not in caplog.text
+
+
+def test_missing_login_is_cached_until_next_sync_and_lock_entries_are_released(monkeypatch):
+ login = "peer-node-replayed-missing-login"
+ lookups = []
+
+ class FakeResult:
+ def all(self):
+ return []
+
+ class FakeDb:
+ async def scalar(self, _query):
+ lookups.append(1)
+ return None
+
+ async def execute(self, _query):
+ return FakeResult()
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ async def run():
+ auth.prime_generations({})
+ assert await auth._resolve_unknown_login(login) is None
+ assert await auth._resolve_unknown_login(login) is None
+ assert lookups == [1], "a replayed cookie must not query users on every request"
+ assert auth._lookup_locks == {}
+
+ await auth.sync_generations()
+ assert await auth._resolve_unknown_login(login) is None
+ assert lookups == [1, 1], "the negative result lives only until the next sync"
+
+ monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ try:
+ asyncio.run(run())
+ finally:
+ auth._missing_logins.discard(login)
+
+
def test_auth_middleware_uses_fresh_generation_cache_without_per_request_database_query(monkeypatch):
from app.config import get_settings
diff --git a/backend/tests/test_call_privacy.py b/backend/tests/test_call_privacy.py
index 4729d0e..b542c99 100644
--- a/backend/tests/test_call_privacy.py
+++ b/backend/tests/test_call_privacy.py
@@ -30,6 +30,7 @@ async def test_text_dialogue_provider_error_does_not_log_prompt_or_provider_body
monkeypatch.setattr(call.hub, "to_observers", lambda *_args: None)
state = SimpleNamespace(
ended=False,
+ paused=False,
exercise=Exercise.CARD,
dispatched_card=None,
caller=Caller(),
diff --git a/backend/tests/test_checkpoint_model.py b/backend/tests/test_checkpoint_model.py
index 144e06c..5032577 100644
--- a/backend/tests/test_checkpoint_model.py
+++ b/backend/tests/test_checkpoint_model.py
@@ -57,6 +57,7 @@ def timers() -> SessionTimers:
TimerCode.DDS_ACK: Timer(code=TimerCode.DDS_ACK, started_at=MONO - 12.0, attempt=2),
TimerCode.ANSWER: Timer(code=TimerCode.ANSWER, started_at=MONO - 7.0,
elapsed_ms=7_000, attempt=3, stopped=True),
+ TimerCode.CARD_FILL: Timer(code=TimerCode.CARD_FILL, elapsed_ms=5_000, paused=True),
})
@@ -159,6 +160,10 @@ def full_state() -> SessionState:
desk=desk,
resolved_outcome="справка",
resolve_comment="передано в другой регион",
+ paused=True,
+ paused_at=AT,
+ total_paused_ms=15_000,
+ pause_intervals=[(AT - timedelta(seconds=20), AT - timedelta(seconds=5))],
processed_station_commands=[str(uuid4())],
text_revealed_facts={"f_address": "улица Ленина, 14"},
consecutive_refusals=2,
diff --git a/backend/tests/test_dds_desk_apply.py b/backend/tests/test_dds_desk_apply.py
index fa6c561..86d7d0c 100644
--- a/backend/tests/test_dds_desk_apply.py
+++ b/backend/tests/test_dds_desk_apply.py
@@ -175,7 +175,8 @@ def test_bounce_is_refused_in_favour_of_112_control():
outcome = state.desk.apply(CardBounce(missing_fields=["floor"]), state)
assert outcome.error == (
- "ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112")
+ "Карточку не возвращают в 112 при принятии и не правят на АРМ ДДС. "
+ "Если ошибка вскрылась по докладу бригады с места — сообщите о ней в 112 по телефону")
def test_refusals_leave_desk_untouched():
diff --git a/backend/tests/test_dds_exercise.py b/backend/tests/test_dds_exercise.py
index 4588b4b..e8296df 100644
--- a/backend/tests/test_dds_exercise.py
+++ b/backend/tests/test_dds_exercise.py
@@ -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},
@@ -456,6 +457,8 @@ 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_enabled": False,
+ "dds_report_reaction_limit_seconds": 20,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
@@ -752,7 +755,7 @@ def test_late_first_record_has_e3_and_long_work_after_it_does_not(client):
def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
- session_id, control = start(client)
+ session_id, control = start(client, criteria={"dds_report_reaction_enabled": True})
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
@@ -802,6 +805,120 @@ 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_enabled": True,
+ "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 report["criteria"]["dds_report_reaction_enabled"] is True
+ 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, criteria={"dds_report_reaction_enabled": True})
+ 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)
diff --git a/backend/tests/test_dispatcher_scoring.py b/backend/tests/test_dispatcher_scoring.py
index 8bc5c43..d985463 100644
--- a/backend/tests/test_dispatcher_scoring.py
+++ b/backend/tests/test_dispatcher_scoring.py
@@ -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,177 @@ 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, pause_intervals=None):
+ 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,
+ report_reaction_enabled=True,
+ pause_intervals=pause_intervals,
+ )
+ metric = next((m for m in dispatcher_metrics(card, 30_000, services=SERVICES,
+ report_reaction_ms=limit_ms,
+ report_reaction_enabled=True,
+ pause_intervals=pause_intervals)
+ 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_pause_between_report_and_status_does_not_count_as_reaction_time():
+ marks = full_run(arrived_at=180)
+ reports = [crew_report("arrived", 100)]
+ pause = [(at_second(110), at_second(170))]
+
+ without_pause, late = reaction(marks, reports)
+ with_pause, active = reaction(marks, reports, pause_intervals=pause)
+
+ assert [finding.code for finding in without_pause] == [ErrorCode.D6]
+ assert not late.passed
+ assert with_pause == []
+ assert active.passed and active.credit == time_credit(20_000, 45_000)
+ assert "20 с" in active.fact
+
+
+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
diff --git a/backend/tests/test_ekp.py b/backend/tests/test_ekp.py
index e641fce..5ad08ad 100644
--- a/backend/tests/test_ekp.py
+++ b/backend/tests/test_ekp.py
@@ -4,6 +4,8 @@
производен и не выбирается руками, сценарий размечается признаками.
"""
+import hashlib
+import json
from pathlib import Path
import pytest
@@ -93,6 +95,31 @@ def test_all_customer_ticket_cards_are_complete_and_classified():
}
+def test_ticket_ekp_codes_and_notify_lists_match_current_baseline():
+ """Изменение правил оповещения не должно незаметно переписать 96 эталонов.
+
+ При намеренной смене версии ЕКП сначала сверяют каждый изменившийся список
+ с источником и методистом, затем обновляют контрольную сумму.
+ """
+ cards = [
+ scenario
+ for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
+ if scenario.ticket is not None
+ ]
+ assert len(cards) == 96
+ manifest = {
+ scenario.id: {
+ "code": scenario.ground_truth.incident_code,
+ "notify": scenario.ground_truth.notify,
+ }
+ for scenario in cards
+ }
+ encoded = json.dumps(manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+ assert hashlib.sha256(encoded.encode()).hexdigest() == (
+ "2e3d49cc1e4a47bf8813437207a4105799c5721cc54bddc795730267ebb44df4"
+ )
+
+
def test_ticket_two_preserves_moscow_and_does_not_invent_missing_victim_data():
root = Path(__file__).resolve().parents[2] / "scenarios"
cards = {
@@ -382,6 +409,43 @@ def test_gasified_object_brings_mosgaz():
assert "МОСГАЗ" in card.notify
+def test_explicit_offence_signs_add_police_and_removal_recalculates_notify():
+ from app.domain.kio import apply_patch, modifiers
+
+ signs = ["на улице", "мусор", "открытое пламя"]
+ base = derive_incident(KIO(signs=signs))
+ assert base.incident_code == "1010101"
+ assert "МВД" not in base.notify
+
+ stated = apply_patch(base, {"offence_signs": True})
+ assert "offence" in modifiers(stated)
+ assert set(stated.notify) == set(base.notify) | {"МВД"}
+
+ denied = apply_patch(stated, {"offence_signs": False})
+ assert denied.notify == base.notify
+ unknown = apply_patch(stated, {"offence_signs": None})
+ assert unknown.notify == base.notify
+
+
+def test_offence_kind_also_adds_police_until_both_sources_are_removed():
+ from app.domain.kio import apply_patch, modifiers
+
+ base = derive_incident(KIO(signs=["на улице", "мусор", "открытое пламя"]))
+ stated = apply_patch(base, {"police.offence_kind": "поджог"})
+ assert "offence" in modifiers(stated)
+ assert set(stated.notify) == set(base.notify) | {"МВД"}
+
+ denied_flag = apply_patch(stated, {"offence_signs": False})
+ assert "МВД" in denied_flag.notify, "явно указанный вид правонарушения остаётся источником"
+ with_both = apply_patch(stated, {"offence_signs": True})
+ assert modifiers(with_both).count("offence") == 1
+ without_kind = apply_patch(with_both, {"police.offence_kind": " "})
+ assert "МВД" in without_kind.notify
+ without_flag = apply_patch(without_kind, {"offence_signs": False})
+ assert without_flag.notify == base.notify
+ assert "offence" not in modifiers(without_flag)
+
+
def test_removed_modifier_recalculates_notify_without_stale_service():
from app.domain.kio import KIO, FireDetails, apply_patch, derive_incident
diff --git a/backend/tests/test_finding_review.py b/backend/tests/test_finding_review.py
new file mode 100644
index 0000000..c8477f6
--- /dev/null
+++ b/backend/tests/test_finding_review.py
@@ -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 "После разбора отметок: 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
diff --git a/backend/tests/test_report_export.py b/backend/tests/test_report_export.py
index 9a87484..1c56ea8 100644
--- a/backend/tests/test_report_export.py
+++ b/backend/tests/test_report_export.py
@@ -68,10 +68,21 @@ 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 ["Критерии", "", "Лимит реакции на доклад бригады, с", "не применяется", ""] in rows
assert _cell(" =cmd") == "' =cmd"
assert _cell("\tОбычный текст") == "'\tОбычный текст"
+def test_csv_shows_enabled_report_reaction_limit():
+ report = sample_report()
+ report.criteria.dds_report_reaction_enabled = True
+ report.criteria.dds_report_reaction_limit_seconds = 20
+
+ rows = list(csv.reader(io.StringIO(to_csv(report).decode("utf-8-sig"))))
+
+ assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "20", ""] in rows
+
+
def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
data = to_pdf(sample_report(long=True))
assert data.startswith(b"%PDF-")
@@ -89,6 +100,8 @@ def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
assert "Отчёт по учебному занятию" in extracted
assert "Адрес должен быть уточнён" in extracted
assert "Заявитель сообщает о дыме" in extracted
+ assert "Лимит реакции на доклад бригады" in extracted
+ assert "не применяется" in extracted
def test_certificate_pdf_contains_saved_result(tmp_path):
diff --git a/backend/tests/test_route_auth_contract.py b/backend/tests/test_route_auth_contract.py
index e4c7d9b..587e0d2 100644
--- a/backend/tests/test_route_auth_contract.py
+++ b/backend/tests/test_route_auth_contract.py
@@ -84,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"),),
diff --git a/backend/tests/test_session_checkpoint.py b/backend/tests/test_session_checkpoint.py
index f2830d9..a5edd47 100644
--- a/backend/tests/test_session_checkpoint.py
+++ b/backend/tests/test_session_checkpoint.py
@@ -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,65 @@ 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_enabled = True
+ 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.criteria.dds_report_reaction_enabled
+ 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 test_report_reaction_default_off_keeps_status_completeness_d6():
+ state = dds_state()
+ card = state.desk.active
+ service = state.card_services(card)[0]
+ card.set_service_status(service, ServiceStatus.RESPONDING,
+ "Основание: доклад.\nСведения: бригада выехала.")
+ without_report = state.model_copy(deep=True)
+ card.phone_reports.append(PhoneReportRecord(
+ service=service, crew=card.crew_selected, phase="dispatched",
+ text="Бригада выехала", at=card.status_log[-1].at - timedelta(seconds=70),
+ ))
+
+ result = score_dds_card(state, card)
+ baseline = score_dds_card(without_report, without_report.desk.active)
+
+ assert state.criteria.dds_report_reaction_enabled is False
+ assert result.score_auto == baseline.score_auto
+ assert [(item.code, item.metric_key, item.fact) for item in result.findings] == [
+ (item.code, item.metric_key, item.fact) for item in baseline.findings
+ ]
+ assert not any(item.key == "dds_report_reaction" for item in result.metrics)
+ assert not any(item.metric_key == "dds_report_reaction" for item in result.findings)
+ assert any(item.code is ErrorCode.D6 for item in result.findings)
+
+
def completed_card() -> DdsCardRecord:
return DdsCardRecord(
card_id=uuid4(),
diff --git a/backend/tests/test_session_pause.py b/backend/tests/test_session_pause.py
new file mode 100644
index 0000000..88f89a0
--- /dev/null
+++ b/backend/tests/test_session_pause.py
@@ -0,0 +1,686 @@
+"""Пауза занятия с пульта преподавателя (lct-39).
+
+Часы — управляемый монотонный счётчик: тест продвигает их сам, а не спит,
+проверяя ровно то, что требует карточка — пауза не считается в норматив,
+переживает чекпойнт и пишется в аудит.
+"""
+
+import asyncio
+import time
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from uuid import uuid4
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.api.http import sessions as sessions_http
+from app.api.ws import control as control_ws
+from app.api.ws import call as call_ws
+from app.api.ws import station as station_ws
+from app.domain.classifiers import DDSCode
+from app.domain.events import (
+ CallbackDial,
+ CallEnded,
+ CallEndReason,
+ CardAck,
+ DdsDispatch,
+ DirectorInject,
+ ErrorEvent,
+ Exercise,
+ HintRequest,
+ KioPatchIn,
+ LessonCriteria,
+ Principal,
+ SelfAssessmentSubmit,
+ SessionMode,
+ SessionPause,
+ SessionPaused,
+ SessionResume,
+ TtsCancel,
+ TtsEnd,
+)
+from app.domain.roles import Role
+from app.domain.statuses import PhoneReportRecord, ServiceStatus
+from app.domain.timers import TimerCode
+from app.main import app
+from app.scenarios.loader import load_file
+from app.scoring.dispatcher import dispatcher_metrics
+from app.session import timers as timers_module
+from app.session.checkpoint import dump_state, load_state
+from app.session.finish import end_session, score_dds_card
+from app.session.hub import hub
+from app.session.state import SessionState, now_utc
+from app.session.store import LessonPaused, LessonResumed, MemorySessionStore
+from app.session.timers import Timer
+
+LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
+TEACHER = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
+
+
+def _dds_state_with_active_card(clock, criteria_ms: int = 30_000):
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ state = SessionState(
+ session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
+ level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
+ scenario=scenario, owner_login="teacher",
+ )
+ state.timers.limits[TimerCode.DDS_ACK] = criteria_ms
+ from app.session.dds import prepare_queue
+
+ prepare_queue(state, [scenario.model_copy(deep=True)])
+ return state, state.desk.active
+
+
+class FakeClock:
+ """Управляемая замена `time.monotonic`: тест продвигает время явно."""
+
+ def __init__(self, start: float = 1_000.0) -> None:
+ self.now = start
+
+ def __call__(self) -> float:
+ return self.now
+
+ def advance(self, seconds: float) -> None:
+ self.now += seconds
+
+
+@pytest.fixture
+def clock(monkeypatch):
+ fake = FakeClock()
+ monkeypatch.setattr(timers_module.time, "monotonic", fake)
+ return fake
+
+
+def test_timer_pause_freezes_elapsed_until_resume(clock):
+ timer = Timer(code=TimerCode.DDS_ACK)
+ timer.start(clock.now)
+ clock.advance(10)
+
+ timer.pause(clock.now)
+ frozen = timer.current_ms(clock.now)
+ clock.advance(60) # долгая пауза — часы идут, таймер не должен
+
+ assert timer.current_ms(clock.now) == frozen == 10_000
+
+ timer.resume(clock.now)
+ clock.advance(15)
+
+ assert timer.current_ms(clock.now) == 25_000
+
+
+def test_session_pause_excludes_wait_from_dds_ack_deadline(clock):
+ """Карточка: 30 с норматива. Пауза 60 с внутри окна не должна дать D1 —
+ отметка приходит на 25-й секунде активного времени, а не 85-й реальной."""
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ state = SessionState(
+ session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
+ level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
+ scenario=scenario, criteria=LessonCriteria(decision_time_limit_seconds=30),
+ )
+ state.timers.limits[TimerCode.DDS_ACK] = 30_000
+ from app.session.dds import prepare_queue
+ prepare_queue(state, [scenario.model_copy(deep=True)])
+ card = state.desk.active
+
+ clock.advance(10)
+ state.pause()
+ clock.advance(60)
+ state.resume()
+ clock.advance(15)
+ card.on_event("card.ack")
+
+ measured = card.timers.measured_ms(TimerCode.DDS_ACK)
+ assert measured == 25_000
+ assert measured <= card.timers.limits[TimerCode.DDS_ACK]
+
+
+def test_pause_freezes_queue_arrival_and_resume_shifts_it(clock, monkeypatch):
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ second = scenario.model_copy(deep=True)
+ state = SessionState(
+ session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
+ level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
+ scenario=scenario,
+ )
+ from app.session.dds import prepare_queue
+ prepare_queue(state, [scenario, second], arrival_interval_seconds=30, max_waiting=3)
+
+ due_before = state.desk.next_arrival_at
+ assert due_before is not None
+
+ base = now_utc()
+ monkeypatch.setattr("app.session.state.now_utc", lambda: base)
+ state.pause()
+ idle = timedelta(minutes=1)
+ monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle)
+ state.resume()
+
+ # Следующая карточка не должна поступить раньше, чем простояла пауза.
+ assert state.desk.next_arrival_at == due_before + idle
+ assert state.desk.deliver_due(due_before + timedelta(seconds=1)) == 0
+
+
+def test_checkpoint_round_trip_keeps_pause_without_adding_downtime(clock):
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ state = SessionState(
+ session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
+ level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
+ scenario=scenario,
+ )
+ from app.session.dds import prepare_queue
+ prepare_queue(state, [scenario.model_copy(deep=True)])
+ card = state.desk.active
+
+ clock.advance(5)
+ state.pause()
+ saved_at = now_utc()
+
+ payload = dump_state(state)
+ # Процесс лежал минуту — по часам сервера, не по monotonic этого теста.
+ restored_at = saved_at + timedelta(minutes=1)
+ restored = load_state(payload, restored_at)
+
+ assert restored.paused is True
+ restored_card = restored.desk.active
+ restored_timer = restored_card.timers.timers[TimerCode.DDS_ACK]
+ # Простоя в счёт не пошло: элапсед остался тем же, что был на паузе.
+ assert restored_timer.elapsed_ms == card.timers.timers[TimerCode.DDS_ACK].elapsed_ms
+ assert restored_timer.started_at is None
+
+ # После takeover пауза всё ещё действует; лишь явное возобновление
+ # продолжает норматив с пяти секунд до прерывания.
+ restored.resume()
+ clock.advance(10)
+ assert restored_timer.current_ms(clock.now) == 15_000
+
+
+def test_pause_and_resume_are_recorded_for_audit(monkeypatch):
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ state = SessionState(
+ session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
+ level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
+ scenario=scenario, owner_login="teacher",
+ )
+ from app.session.dds import prepare_queue
+ prepare_queue(state, [scenario.model_copy(deep=True)])
+
+ store = MemorySessionStore()
+ monkeypatch.setitem(hub._sessions, state.session_id, state)
+ monkeypatch.setattr(hub, "store", store)
+
+ async def run():
+ async with hub.operation(state.session_id):
+ await control_ws._command(state.session_id, SessionPause(), TEACHER)
+ async with hub.operation(state.session_id):
+ await control_ws._command(state.session_id, SessionResume(), TEACHER)
+
+ asyncio.run(run())
+
+ records = [record for _sid, batch in store.commits for record in batch]
+ assert any(isinstance(record, LessonPaused) for record in records)
+ assert any(isinstance(record, LessonResumed) for record in records)
+ assert state.paused is False
+
+
+def test_dispatcher_metric_dds_ack_excludes_pause_from_elapsed(clock):
+ """Регрессия: `dispatcher_metrics` считала `primary.at - dispatched_at` —
+ календарную разницу, которую пауза не трогает. Метрика должна брать то
+ же замороженное время, что и таймер на экране."""
+ state, card = _dds_state_with_active_card(clock)
+ services = state.card_services(card)
+ service = services[0]
+
+ clock.advance(10)
+ state.pause()
+ clock.advance(60)
+ state.resume()
+ clock.advance(15)
+ card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято")
+ card.on_event("card.ack")
+
+ metric = next(m for m in dispatcher_metrics(card, 30_000, services=services) if m.key == "dds_ack")
+ assert metric.passed is True
+ assert "25.0" in metric.fact
+
+
+def test_ending_session_while_paused_finalizes_pause_and_unblocks_report(clock, monkeypatch):
+ """Регрессия: занятие, завершённое прямо на паузе, оставляло `paused=True`
+ навсегда — самооценка и отчёт отклонялись баннером «Пауза», а простой,
+ не закрытый явным `session.resume`, не попадал в `total_paused_ms`."""
+ from app.session import finish as finish_module
+
+ async def no_coach(_metrics):
+ class NoCoaching:
+ def model_dump(self, **_kwargs):
+ return {}
+ return NoCoaching()
+
+ monkeypatch.setattr(finish_module, "coach", no_coach)
+
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ state = SessionState(
+ session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
+ level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.CALL,
+ scenario=scenario, owner_login="teacher",
+ )
+ store = MemorySessionStore()
+ monkeypatch.setattr(hub, "store", store)
+ monkeypatch.setitem(hub._sessions, state.session_id, state)
+
+ base = now_utc()
+ monkeypatch.setattr("app.session.state.now_utc", lambda: base)
+ clock.advance(5)
+ state.pause()
+ clock.advance(30) # преподаватель завершает занятие, не возобновив его
+ idle = timedelta(seconds=45)
+ monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle)
+
+ events = []
+
+ async def run():
+ with hub.trainee(state.session_id) as queue:
+ async with hub.operation(state.session_id):
+ await end_session(state.session_id, state, CallEndReason.INSTRUCTOR)
+ async with hub.operation(state.session_id):
+ await call_ws._handle(
+ state.session_id, state,
+ SelfAssessmentSubmit(missed=[], comment="Проверил"),
+ )
+ while not queue.empty():
+ events.append(queue.get_nowait())
+
+ asyncio.run(run())
+
+ assert state.ended
+ assert state.paused is False
+ assert state.total_paused_ms >= 30_000
+ assert state.self_assessed
+ assert state.score is not None
+ assert state.score["full_report"]["total_paused_ms"] >= 30_000
+ assert any(isinstance(event, SessionPaused) and not event.paused for event in events)
+ # Снятие паузы приходит после `call.ended`: иначе клиент дошлёт отложенные
+ # на паузе правки в уже закрытое занятие.
+ kinds = [type(event) for event in events]
+ assert kinds.index(CallEnded) < kinds.index(SessionPaused)
+ assert any(isinstance(record, LessonResumed) for _sid, batch in store.commits for record in batch)
+
+
+def test_dds_work_overdue_survives_pause():
+ """Регрессия: `dds_work_overdue_cards` требовал `started_at is not None`,
+ который пауза обнуляет — уже случившееся нарушение исчезало из реестра."""
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ from app.session.dds import build_card
+ from app.session.timers import SessionTimers
+
+ limits = {**SessionTimers().limits, TimerCode.DDS_WORK: 1_000}
+ card = build_card(scenario, 0, limits)
+ timer = card.timers.timers.setdefault(TimerCode.DDS_WORK, Timer(code=TimerCode.DDS_WORK))
+ timer.start(time.monotonic() - 5.0) # уже превысил норматив в 1 с
+ timer.pause(time.monotonic())
+
+ overdue = (
+ timer.started_at is not None or timer.paused
+ ) and not timer.stopped and timer.current_ms(time.monotonic()) > limits[TimerCode.DDS_WORK]
+ assert overdue is True
+
+
+def test_active_registry_keeps_existing_overdue_during_pause(http_client, clock):
+ app.dependency_overrides[sessions_http.optional_session] = lambda: None
+ state, card = _dds_state_with_active_card(clock)
+ state.owner_login = "dev"
+ timer = card.timers.timers.setdefault(TimerCode.DDS_WORK, Timer(code=TimerCode.DDS_WORK))
+ card.timers.limits[TimerCode.DDS_WORK] = 1_000
+ timer.start(clock.now)
+ clock.advance(5)
+ state.pause()
+ hub._sessions[state.session_id] = state
+ try:
+ response = http_client.get("/api/sessions/active")
+ assert response.status_code == 200
+ row = next(item for item in response.json() if item["session_id"] == str(state.session_id))
+ assert row["dds_work_overdue_cards"] == 1
+ assert row["paused"] is True
+ finally:
+ hub._sessions.pop(state.session_id, None)
+ app.dependency_overrides.pop(sessions_http.optional_session, None)
+
+
+@pytest.fixture
+def http_client(monkeypatch):
+ async def audit_in_memory(*_args, **_kwargs):
+ return None
+
+ monkeypatch.setattr(sessions_http, "audit_required", audit_in_memory)
+ with TestClient(app) as test_client:
+ test_client.post("/api/auth/dev-token")
+ hub.store = MemorySessionStore()
+ yield test_client
+
+
+def _wait(predicate, timeout: float = 3.0):
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ value = predicate()
+ if value:
+ return value
+ time.sleep(0.02)
+ raise AssertionError("не дождались")
+
+
+def test_paused_socket_drops_audio_instead_of_feeding_voice(http_client):
+ """Регрессия: пауза блокировала только JSON-команды — бинарные аудиокадры
+ /ws/call всё равно доходили до счётчика и голосового контура."""
+ session_id = uuid4()
+ with http_client.websocket_connect(f"/ws/control/{session_id}") as control:
+ control.send_json({
+ "type": "scenario.start", "scenario_id": "fire-apartment-l2",
+ "trainee": "Иванов И.И.", "mode": "training",
+ })
+ _wait(lambda: hub.get(session_id))
+ state = hub.get(session_id)
+
+ with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
+ trainee.send_bytes(b"\x00\x00" * 320)
+ _wait(lambda: state.audio_frames == 1)
+
+ control.send_json({"type": "session.pause"})
+ _wait(lambda: state.paused)
+
+ trainee.send_bytes(b"\x00\x00" * 320)
+ trainee.send_bytes(b"\x00\x00" * 320)
+ time.sleep(0.1) # кадры паузы не должны просочиться в счётчик
+
+ assert state.audio_frames == 1
+
+
+def test_reconnect_to_call_replays_paused_banner(http_client):
+ """Регрессия: переподключение к /ws/call не сообщало о текущей паузе —
+ баннер появлялся только после следующего `session.pause`."""
+ session_id = uuid4()
+ with http_client.websocket_connect(f"/ws/control/{session_id}") as control:
+ control.send_json({
+ "type": "scenario.start", "scenario_id": "fire-apartment-l2",
+ "trainee": "Иванов И.И.", "mode": "training",
+ })
+ _wait(lambda: hub.get(session_id))
+ control.send_json({"type": "session.pause"})
+ _wait(lambda: hub.get(session_id).paused)
+
+ with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
+ messages = [trainee.receive_json() for _ in range(2)]
+ assert any(
+ message["type"] == "session.paused" and message["paused"]
+ for message in messages
+ )
+
+ control.send_json({"type": "session.resume"})
+ _wait(lambda: not hub.get(session_id).paused)
+ with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
+ message = trainee.receive_json()
+ assert message == {"type": "session.paused", "paused": False}
+
+
+@pytest.mark.asyncio
+async def test_pause_cancels_inflight_voice_and_clears_buffer(monkeypatch):
+ from app.voice import pipeline
+
+ class FakeVad:
+ def __init__(self, *_args, **_kwargs):
+ self.reset_count = 0
+
+ def reset(self):
+ self.reset_count += 1
+
+ def push(self, _frame):
+ return []
+
+ monkeypatch.setattr(pipeline, "StreamingVad", FakeVad)
+ state, _card = _dds_state_with_active_card(lambda: 1_000.0)
+ events = []
+ audio = []
+ started = asyncio.Event()
+ release = asyncio.Event()
+
+ class Models:
+ vad_path = None
+
+ voice = pipeline.VoiceSession(
+ state.session_id, state, Models(), events.append, lambda _event: None, audio.append,
+ )
+ state.voice = voice
+
+ async def slow_synthesis(_text):
+ started.set()
+ await release.wait()
+ return b"\x00\x00" * 2_400
+
+ monkeypatch.setattr(voice, "synthesize", slow_synthesis)
+ try:
+ speaking = voice.speak("Алло, помогите!", state.persona.mood if state.persona else pipeline.Mood.PANIC)
+ await asyncio.wait_for(started.wait(), timeout=1)
+ voice._queue.put_nowait((b"old", time.monotonic()))
+ state.pause()
+ release.set()
+ with pytest.raises(asyncio.CancelledError):
+ await speaking
+ await asyncio.sleep(0)
+ assert any(isinstance(event, TtsCancel) and event.reason == "director" for event in events)
+ assert not any(isinstance(event, TtsEnd) for event in events)
+ assert audio == []
+ assert voice._queue.empty()
+ assert voice._vad.reset_count == 1
+ finally:
+ await voice.close()
+
+
+def _call_state():
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ return SessionState(
+ session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
+ level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.CALL,
+ scenario=scenario, owner_login="teacher",
+ )
+
+
+def _drain(queue) -> list:
+ events = []
+ while not queue.empty():
+ events.append(queue.get_nowait())
+ return events
+
+
+def test_timer_start_on_pause_keeps_frozen_elapsed(clock):
+ """Повторное событие запуска на паузе не должно пустить часы с нуля."""
+ timer = Timer(code=TimerCode.CALLBACK)
+ timer.start(clock.now)
+ clock.advance(4)
+ timer.pause(clock.now)
+
+ timer.start(clock.now)
+ clock.advance(120)
+
+ assert timer.paused is True
+ assert timer.current_ms(clock.now) == 4_000
+
+
+def test_director_inject_rejected_while_paused(clock, monkeypatch):
+ """Регрессия: обрыв связи на паузе запускал норматив обратного дозвона,
+ и весь простой уходил в него; курсант получал `call.ended` под баннером."""
+ state = _call_state()
+ monkeypatch.setattr(hub, "store", MemorySessionStore())
+ monkeypatch.setitem(hub._sessions, state.session_id, state)
+ state.on_event("call.incoming")
+ state.on_event("call.answer")
+ state.pause()
+
+ async def run():
+ with hub.observer(state.session_id) as observer, hub.trainee(state.session_id) as trainee:
+ async with hub.operation(state.session_id):
+ await control_ws._command(
+ state.session_id, DirectorInject(directive="line_dropped"), TEACHER,
+ )
+ return _drain(observer), _drain(trainee)
+
+ observed, to_trainee = asyncio.run(run())
+ clock.advance(120)
+
+ assert any(isinstance(event, ErrorEvent) for event in observed)
+ assert to_trainee == []
+ assert state.directives == []
+ assert TimerCode.CALLBACK not in state.timers.timers
+ assert state.timers.timers[TimerCode.INTERVIEW].paused is True
+
+
+@pytest.mark.parametrize("event", [
+ KioPatchIn(fields={"address": "ул. Ленина, 1"}),
+ HintRequest(),
+ DdsDispatch(service=DDSCode.FIRE),
+ CallbackDial(),
+], ids=lambda event: event.type)
+def test_call_channel_rejects_trainee_actions_while_paused(clock, monkeypatch, event):
+ """Истина — сервер: кнопки на фронте выключены, но команда с паузы
+ не должна ни менять карточку, ни останавливать норматив опроса."""
+ state = _call_state()
+ monkeypatch.setattr(hub, "store", MemorySessionStore())
+ monkeypatch.setitem(hub._sessions, state.session_id, state)
+ state.on_event("call.incoming")
+ state.on_event("call.answer")
+ clock.advance(5)
+ state.pause()
+ kio_before = state.kio.model_dump()
+
+ async def run():
+ with hub.trainee(state.session_id) as queue:
+ async with hub.operation(state.session_id):
+ await call_ws._handle(state.session_id, state, event)
+ return _drain(queue)
+
+ events = asyncio.run(run())
+
+ assert [type(item) for item in events] == [ErrorEvent]
+ assert state.kio.model_dump() == kio_before
+ interview = state.timers.timers[TimerCode.INTERVIEW]
+ assert interview.paused and not interview.stopped
+ assert TimerCode.DDS_ACK not in state.timers.timers
+ assert TimerCode.CALLBACK not in state.timers.timers
+ assert state.hints_shown == []
+
+
+def test_station_channel_rejects_card_ack_while_paused(clock, monkeypatch):
+ state, card = _dds_state_with_active_card(clock)
+ monkeypatch.setattr(hub, "store", MemorySessionStore())
+ monkeypatch.setitem(hub._sessions, state.session_id, state)
+ clock.advance(5)
+ state.pause()
+ card_before = card.model_dump()
+
+ async def run():
+ with hub.station(state.session_id) as queue:
+ async with hub.operation(state.session_id):
+ await station_ws._handle(state.session_id, state, CardAck(comment="Принято"))
+ return _drain(queue)
+
+ events = asyncio.run(run())
+
+ assert [type(item) for item in events] == [ErrorEvent]
+ assert card.model_dump() == card_before
+ ack = card.timers.timers[TimerCode.DDS_ACK]
+ assert ack.paused and not ack.stopped
+ assert ack.current_ms(clock.now) == 5_000
+
+
+def test_repeated_pause_and_resume_are_idempotent(clock, monkeypatch):
+ state, card = _dds_state_with_active_card(clock)
+ store = MemorySessionStore()
+ monkeypatch.setattr(hub, "store", store)
+ monkeypatch.setitem(hub._sessions, state.session_id, state)
+ base = now_utc()
+ monkeypatch.setattr("app.session.state.now_utc", lambda: base)
+
+ async def command(event):
+ async with hub.operation(state.session_id):
+ await control_ws._command(state.session_id, event, TEACHER)
+
+ async def run():
+ with hub.station(state.session_id) as queue:
+ clock.advance(5)
+ await command(SessionPause())
+ clock.advance(30)
+ await command(SessionPause()) # второе нажатие не сдвигает начало паузы
+ monkeypatch.setattr("app.session.state.now_utc", lambda: base + timedelta(seconds=60))
+ await command(SessionResume())
+ await command(SessionResume())
+ return _drain(queue)
+
+ events = asyncio.run(run())
+ clock.advance(10)
+
+ records = [record for _sid, batch in store.commits for record in batch]
+ assert sum(isinstance(record, LessonPaused) for record in records) == 1
+ assert sum(isinstance(record, LessonResumed) for record in records) == 1
+ assert [event.paused for event in events if isinstance(event, SessionPaused)] == [True, False]
+ assert state.total_paused_ms == 60_000
+ assert state.pause_intervals == [(base, base + timedelta(seconds=60))]
+ assert card.timers.timers[TimerCode.DDS_ACK].current_ms(clock.now) == 15_000
+
+
+def test_report_reaction_excludes_pause_after_control_and_checkpoint(clock, monkeypatch):
+ base = datetime(2026, 9, 27, 10, tzinfo=UTC)
+ state, card = _dds_state_with_active_card(clock)
+ state.criteria.dds_report_reaction_enabled = True
+ service = state.card_services(card)[0]
+ monkeypatch.setattr("app.session.dds.now_utc", lambda: base)
+ card.set_service_status(service, ServiceStatus.ACCEPTED, "Карточка принята")
+ card.phone_reports.append(PhoneReportRecord(
+ service=service, crew="Бригада 12", phase="dispatched",
+ text="Бригада выехала", at=base,
+ ))
+ store = MemorySessionStore()
+ monkeypatch.setattr(hub, "store", store)
+ monkeypatch.setitem(hub._sessions, state.session_id, state)
+
+ async def command(event):
+ async with hub.operation(state.session_id):
+ await control_ws._command(state.session_id, event, TEACHER)
+
+ monkeypatch.setattr("app.session.state.now_utc", lambda: base + timedelta(seconds=10))
+ asyncio.run(command(SessionPause()))
+ restored = load_state(dump_state(state), base + timedelta(seconds=70))
+ assert restored.paused and restored.pause_intervals == []
+ monkeypatch.setitem(hub._sessions, state.session_id, restored)
+ monkeypatch.setattr("app.session.state.now_utc", lambda: base + timedelta(seconds=70))
+ asyncio.run(command(SessionResume()))
+ monkeypatch.setattr("app.session.dds.now_utc", lambda: base + timedelta(seconds=80))
+ restored.desk.active.set_service_status(
+ service, ServiceStatus.RESPONDING, "Основание: доклад. Сведения: бригада выехала.",
+ )
+
+ result = score_dds_card(restored, restored.desk.active)
+ reaction = next(item for item in result.metrics if item.key == "dds_report_reaction")
+ assert restored.pause_intervals == [
+ (base + timedelta(seconds=10), base + timedelta(seconds=70)),
+ ]
+ assert reaction.passed and "20 с" in reaction.fact
+ assert not any(item.metric_key == "dds_report_reaction" for item in result.findings)
+
+
+def test_pause_is_ignored_in_ended_session(clock, monkeypatch):
+ state, _card = _dds_state_with_active_card(clock)
+ store = MemorySessionStore()
+ monkeypatch.setattr(hub, "store", store)
+ monkeypatch.setitem(hub._sessions, state.session_id, state)
+ state.end(CallEndReason.INSTRUCTOR)
+
+ async def run():
+ with hub.station(state.session_id) as queue:
+ async with hub.operation(state.session_id):
+ await control_ws._command(state.session_id, SessionPause(), TEACHER)
+ return _drain(queue)
+
+ events = asyncio.run(run())
+
+ assert state.paused is False
+ assert state.paused_at is None
+ assert events == []
+ assert not any(isinstance(record, LessonPaused) for _sid, batch in store.commits for record in batch)
diff --git a/backend/tests/test_station.py b/backend/tests/test_station.py
index e029f76..d367941 100644
--- a/backend/tests/test_station.py
+++ b/backend/tests/test_station.py
@@ -123,7 +123,7 @@ def test_dds_cannot_return_card_for_112_quality_control(client):
station.send_json({"type": "card.bounce", "missing_fields": ["floor", "victims_count"],
"comment": "куда ехать без этажа"})
error = read_until(station, "error")
- assert "служба контроля 112" in error["message"]
+ assert "сообщите о ней в 112 по телефону" in error["message"]
assert state.bounced_fields == []
trainee.send_json({"type": "call.hangup"})
wait_for(lambda: state.score is not None)
diff --git a/docker-compose.sip.yml b/docker-compose.sip.yml
index 25f5160..79d23e6 100644
--- a/docker-compose.sip.yml
+++ b/docker-compose.sip.yml
@@ -9,6 +9,7 @@ services:
SIP_6003_PASSWORD: ${SIP_6003_PASSWORD:-}
SIP_6101_PASSWORD: ${SIP_6101_PASSWORD:-}
SIP_6102_PASSWORD: ${SIP_6102_PASSWORD:-}
+ SIP_6103_PASSWORD: ${SIP_6103_PASSWORD:-}
SIP_EXTERNAL_MEDIA_ADDRESS: ${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1}
SIP_RTP_START: ${SIP_RTP_START:-10000}
SIP_RTP_END: ${SIP_RTP_END:-10099}
diff --git a/frontend/src/features/call/useCall.ts b/frontend/src/features/call/useCall.ts
index dac8097..be4a2e9 100644
--- a/frontend/src/features/call/useCall.ts
+++ b/frontend/src/features/call/useCall.ts
@@ -50,6 +50,7 @@ export function useCall(sessionId: string | null) {
const [scoreReady, setScoreReady] = useState(false);
const { report, error: reportError } = useSessionReport(sessionId, scoreReady);
const [error, setError] = useState Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов. Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id} Ответственная служба: {card.managed_service} Ответ диспетчера: {card.reply_text || "не внесён"}
+ Снятая отметка не штрафует свою метрику, добавленная — штрафует. Балл пересчитывается
+ по тем же критериям и весам; автоматическая оценка ({report.score_auto.toFixed(1)}) сохраняется рядом.
+ {report.score_reviewed != null && ` После разбора: ${report.score_reviewed.toFixed(1)}.`}
+ {error}Служебные сведения
Карточки занятия ({report.card_results!.length})
{report.card_results!.map((card, index) => Карточка {index + 1}: {card.title || card.scenario_id} · {card.score_auto.toFixed(0)} / 100
+
Карточка {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}` : ""}
{card.managed_service &&
{finding.summary}
))}
diff --git a/frontend/src/features/debrief/FindingReview.tsx b/frontend/src/features/debrief/FindingReview.tsx
new file mode 100644
index 0000000..ef47f23
--- /dev/null
+++ b/frontend/src/features/debrief/FindingReview.tsx
@@ -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Разбор по отметкам
+
+
+ {report.findings.map((finding, index) => (
+
+
+
+
+ ))}
+ {report.findings.length === 0 && {finding.code}
+
+ {finding.summary}
+
+ }
+
+ Автоматических отметок нет. Добавить отметку
+
На втором рабочем месте откройте телефон старшего группы и зарегистрируйте 6102. Пароли 6101/6102 выдаёт локальный администратор командой make sip-credentials.
На втором рабочем месте откройте телефон старшего группы (6102); преподаватель в роли вышестоящего начальника — телефон 6103. Пароли 6101–6103 выдаёт локальный администратор командой make sip-credentials.
Связь используется при необходимости. Разговор и учебный доклад сохраняются в журнале, но не блокируют изменение статуса.
@@ -669,7 +680,7 @@ export function Dds() { onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: true }); }}>Наша -ДДС не исправляет и не возвращает заполненную карточку: её качество контролируют оператор и служба контроля 112.
+Карточку не правят на АРМ ДДС и не возвращают в 112 при принятии. Если бригада с места доложит о расхождении с карточкой — сообщите об ошибке в 112 по телефону.