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(null); + const [paused, setPaused] = useState(false); const channel = useRef | null>(null); const capture = useRef(null); @@ -58,6 +59,8 @@ export function useCall(sessionId: string | null) { const outbox = useRef>({}); const patchTimer = useRef | null>(null); const textMode = useRef(false); + const pausedRef = useRef(false); + const endedRef = useRef(false); const append = useCallback((line: Line) => { setLines((prev) => { @@ -68,22 +71,14 @@ export function useCall(sessionId: string | null) { useEffect(() => { if (!sessionId) return; + endedRef.current = false; const ch = callChannel(sessionId, { onStatus: (next) => { setStatus(next); - if (next === "open") { - // Браузер не подтверждает доставку WebSocket-кадра. Всё, что ещё - // отмечено pending и не пришло эхом от сервера, повторяем после - // reconnect: kio.patch идемпотентен и не создаёт двойных действий. - queueMicrotask(() => setCard((current) => { - if (Object.keys(current.pending).length) { - channel.current?.send({ type: "kio.patch", fields: current.pending }); - } - return current; - })); - } }, - onBinary: (frame) => audio.current?.playback.enqueue(frame), + onBinary: (frame) => { + if (!pausedRef.current) audio.current?.playback.enqueue(frame); + }, onEvent: (event: ServerToTrainee) => { if (event.type === "text.turn.accepted") { append({ speaker: "operator", text: event.text }); @@ -140,6 +135,7 @@ export function useCall(sessionId: string | null) { setTimers(event.timers); break; case "call.ended": + endedRef.current = true; setPhase("ended"); audio.current?.ambience.stop(); if (textMode.current) { setCardSubmitted(true); break; } @@ -153,6 +149,33 @@ export function useCall(sessionId: string | null) { case "score.ready": setScoreReady(true); break; + case "session.paused": + pausedRef.current = event.paused; + setPaused(event.paused); + if (event.paused) { + audio.current?.playback.flush(); + setCallerSpeaking(false); + if (patchTimer.current) clearTimeout(patchTimer.current); + patchTimer.current = null; + } else if (endedRef.current) { + // Занятие завершили прямо на паузе: сервер снимает её после + // `call.ended`, и отложенные правки уже не войдут в оценку. + outbox.current = {}; + } else { + // При переподключении сервер первым присылает состояние паузы. + // Только после этого повторяем правки, ожидающие подтверждения. + queueMicrotask(() => setCard((current) => { + if (Object.keys(current.pending).length) { + channel.current?.send({ type: "kio.patch", fields: current.pending }); + } + if (Object.keys(outbox.current).length) { + channel.current?.send({ type: "kio.patch", fields: outbox.current }); + outbox.current = {}; + } + return current; + })); + } + break; case "error": setError(event.message); break; @@ -170,13 +193,15 @@ export function useCall(sessionId: string | null) { }, [sessionId, append]); const answer = useCallback(async () => { + if (pausedRef.current) return; try { setError(null); // Контекст создаётся по нажатию: браузер не даёт играть звук без действия человека. const context = new AudioContext({ sampleRate: CALLER_RATE }); audio.current = { context, playback: new Playback(context), ambience: new Ambience(context) }; - capture.current = await startCapture((frame) => { + const startedCapture = await startCapture((frame) => { + if (pausedRef.current) return; const playback = audio.current?.playback; // Перебивание: фронт гасит звук мгновенно, сервер подтвердит `tts.cancel`. if (gate.current.push(frame) && playback?.speaking) { @@ -185,6 +210,13 @@ export function useCall(sessionId: string | null) { } channel.current?.sendBinary(frame); }); + if (pausedRef.current) { + await startedCapture.stop(); + await context.close(); + audio.current = null; + return; + } + capture.current = startedCapture; setMicOn(true); channel.current?.send({ type: "call.answer" }); } catch (err) { @@ -193,36 +225,45 @@ export function useCall(sessionId: string | null) { }, []); const hangup = useCallback(() => { + if (pausedRef.current) return; channel.current?.send({ type: "call.hangup" }); void capture.current?.stop(); capture.current = null; setMicOn(false); }, []); - const hint = useCallback(() => channel.current?.send({ type: "hint.request" }), []); + const hint = useCallback(() => { + if (!pausedRef.current) channel.current?.send({ type: "hint.request" }); + }, []); const patchKio = useCallback((path: string, value: unknown) => { + if (pausedRef.current) return; setCard((prev) => edit(prev, path, value)); outbox.current[path] = value; if (patchTimer.current) clearTimeout(patchTimer.current); patchTimer.current = setTimeout(() => { const fields = outbox.current; outbox.current = {}; - if (Object.keys(fields).length) channel.current?.send({ type: "kio.patch", fields }); + if (pausedRef.current) { + outbox.current = { ...fields, ...outbox.current }; + } else if (Object.keys(fields).length) channel.current?.send({ type: "kio.patch", fields }); }, PATCH_DEBOUNCE_MS); }, []); const dispatch = useCallback((service?: DDSCode) => { + if (pausedRef.current) return; channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" }); }, []); const sendText = useCallback((text: string) => { + if (pausedRef.current) return false; const clean = text.trim(); if (!clean || clean.length > 1000) return false; return Boolean(channel.current?.send({ type: "text.turn", text: clean })); }, []); const submitCard = useCallback(() => { + if (pausedRef.current) return; if (patchTimer.current) clearTimeout(patchTimer.current); patchTimer.current = null; const fields = outbox.current; @@ -243,17 +284,19 @@ export function useCall(sessionId: string | null) { * Отдельное действие, а не «положил трубку»: система должна отличить * осознанное решение от брошенного вызова (docs/spec/TICKETS.md). */ const resolve = useCallback((outcome: Outcome, comment: string) => { + if (pausedRef.current) return; channel.current?.send({ type: "call.resolve", outcome, comment }); }, []); const submitSelfAssessment = useCallback((missed: string[], comment: string) => { + if (pausedRef.current) return; channel.current?.send({ type: "self_assessment.submit", missed, comment }); setSelfAssessed(true); }, []); return { resolve, - status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, card, + status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, paused, card, checklist, selfAssessed, scoreReady, report, reportError, answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, sendText, }; diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx index 9b65237..58ad4dc 100644 --- a/frontend/src/features/debrief/Debrief.tsx +++ b/frontend/src/features/debrief/Debrief.tsx @@ -20,6 +20,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3); const metricTitles = new Map(report.metrics.map((metric) => [metric.key, metric.title])); const coaching = report.ai_coaching?.status === "ready" ? report.ai_coaching.recommendations ?? [] : []; + // Вверх: короткая пауза не должна выглядеть как «0 с», а без пауз строки нет. + const pausedSeconds = Math.ceil((report.total_paused_ms ?? 0) / 1000); return (
@@ -37,12 +39,14 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
Автоматическая оценка{report.score_auto.toFixed(0)} из 100 {report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"} + {report.score_reviewed != null && после разбора отметок: {report.score_reviewed.toFixed(0)}}
Упражнение{report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"} {report.attempt ? `Попытка ${report.attempt}` : "Завершённое упражнение"}
+ {pausedSeconds > 0 &&

Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.

}
Служебные сведения

Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id}

@@ -77,6 +81,11 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: {` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}} {report.exercise === "card" && <>Норматив заполнения КИО — {` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}} + {/* 112 → ДДС приходит как exercise "card": работу ДДС выдают карточки пульта. */} + {(report.exercise === "dds" || Boolean(report.card_results?.length)) && <>; статус по докладу бригады — + {report.criteria.dds_report_reaction_enabled + ? ` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с` + : " не применяется"}} {report.exercise && report.exercise !== "dds" && <> ; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку }. @@ -89,7 +98,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: {Boolean(report.card_results?.length) &&

Карточки занятия ({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 &&

Ответственная служба: {card.managed_service}

}

Ответ диспетчера: {card.reply_text || "не внесён"}

@@ -144,6 +154,10 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: {finding.summary}
{finding.fact}{finding.norm && ` · ${finding.norm}`}
+ {finding.source === "instructor" &&
отметка преподавателя {finding.author ?? ""}
} + {(finding.reviews ?? []).map((review, reviewIndex) =>
+ {review.decision === "dismissed" ? "снята" : "подтверждена"} преподавателем {review.author}: {review.reason} +
)} ))} 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 { + const response = await fetch(url, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await response.json().catch(() => null); + if (!response.ok) { + const detail = data && typeof data === "object" && "detail" in data ? data.detail : response.statusText; + throw new Error(typeof detail === "string" ? detail : "Не удалось сохранить решение."); + } + return data as SessionReport; +} + +export function FindingReview({ report, onChange }: { + report: SessionReport; + onChange: (report: SessionReport) => void; +}) { + const cards = report.card_results ?? []; + const codes = report.exercise === "dds" ? DDS_CODES + : cards.length ? [...CALL_CODES, ...DDS_CODES] : CALL_CODES; + const [reasons, setReasons] = useState>({}); + const [code, setCode] = useState(codes[0]); + // В ДДС все метрики принадлежат карточкам очереди. В связке 112 → ДДС + // E-коды относятся к занятию, а D-код без карточки — к живой карточке звонка. + const [card, setCard] = useState(report.exercise === "dds" && cards.length ? "1" : ""); + const cardChoice = cards.length > 0 && (report.exercise === "dds" || code.startsWith("D")); + // Один ключ на отметку, пока она не сохранена: повтор после сбоя сети + // не добавит её второй раз. + const pendingId = useRef(null); + const [fact, setFact] = useState(""); + const [norm, setNorm] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const base = `/api/sessions/${report.session_id}/findings`; + + async function run(action: () => Promise) { + setBusy(true); + setError(""); + try { + onChange(await action()); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Не удалось сохранить решение."); + } finally { + setBusy(false); + } + } + + function decide(index: number, decision: FindingDecision) { + const reason = (reasons[index] ?? "").trim(); + if (!reason) { + setError("Укажите причину решения по отметке."); + return; + } + void run(async () => { + const updated = await send(`${base}/${index}/review`, { decision, reason }); + setReasons((current) => ({ ...current, [index]: "" })); + return updated; + }); + } + + function addFinding() { + if (!fact.trim() || !norm.trim()) { + setError("Для отметки нужны факт и норма."); + return; + } + pendingId.current ??= crypto.randomUUID(); + const clientId = pendingId.current; + void run(async () => { + const updated = await send(base, { + code, fact: fact.trim(), norm: norm.trim(), card: cardChoice && card ? Number(card) : null, + client_id: clientId, + }); + pendingId.current = null; + setFact(""); + setNorm(""); + return updated; + }); + } + + return ( +
+

Разбор по отметкам

+

+ Снятая отметка не штрафует свою метрику, добавленная — штрафует. Балл пересчитывается + по тем же критериям и весам; автоматическая оценка ({report.score_auto.toFixed(1)}) сохраняется рядом. + {report.score_reviewed != null && ` После разбора: ${report.score_reviewed.toFixed(1)}.`} +

+ + + {report.findings.map((finding, index) => ( + + + + + ))} + {report.findings.length === 0 && } + +
{finding.code} + {finding.summary} +
{finding.fact}{finding.norm && ` · ${finding.norm}`}
+ {(finding.reviews ?? []).map((review, reviewIndex, all) =>
+ {review.decision === "dismissed" ? "Снята" : "Подтверждена"} ({review.author},{" "} + {new Date(review.at).toLocaleString("ru-RU")}): {review.reason} +
)} +
+ setReasons((current) => ({ ...current, [index]: event.target.value }))} /> + + +
+
Автоматических отметок нет.
+ +

Добавить отметку

+
+ + {cardChoice && } + + + +
+ {error &&

{error}

} +
+ ); +} diff --git a/frontend/src/features/instructor/ScoreWeightsEditor.tsx b/frontend/src/features/instructor/ScoreWeightsEditor.tsx index 5a7dcd3..69e0122 100644 --- a/frontend/src/features/instructor/ScoreWeightsEditor.tsx +++ b/frontend/src/features/instructor/ScoreWeightsEditor.tsx @@ -32,6 +32,7 @@ const DDS_METRICS: MetricOption[] = [ { key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." }, { key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." }, { key: "dds_work_time", label: "Первая запись", hint: "Первый статус с текстом в пределах лимита после открытия карточки." }, + { key: "dds_report_reaction", label: "Реакция на доклад бригады", hint: "Статус по докладу бригады не позже лимита занятия." }, ]; const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [ @@ -39,13 +40,19 @@ const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [ "required_fields", "description_grammar", "card_fill_time", ].includes(key)); const CALL_METRICS = OPERATOR_METRICS.filter(({ key }) => key !== "card_fill_time"); +// Метрика появляется, только когда ручной отметке преподавателя нечего провалить. +const REVIEW_METRICS: MetricOption[] = [ + { key: "instructor_finding", label: "Отметка преподавателя", hint: "Ручная отметка на разборе без связанной метрики (например, D4)." }, +]; export function ScoreWeightsEditor({ exercise, + reportReactionEnabled, values, onChange, }: { exercise: LessonExercise; + reportReactionEnabled: boolean; values: Record; onChange: (weights: Record) => void; }) { @@ -57,6 +64,12 @@ export function ScoreWeightsEditor({ : exercise === "card_dds" ? [["Карточка КИО", CARD_METRICS], ["Реагирование ДДС", DDS_METRICS]] : [["Опрос 112", CALL_METRICS], ["Реагирование ДДС", DDS_METRICS]]; + groups.push(["Разбор", REVIEW_METRICS]); + if (!reportReactionEnabled) { + for (const group of groups) { + group[1] = group[1].filter(({ key }) => key !== "dds_report_reaction"); + } + } function setWeight(key: string, raw: string) { const next = { ...values }; diff --git a/frontend/src/features/instructor/useControl.ts b/frontend/src/features/instructor/useControl.ts index 02b16d3..64f8efc 100644 --- a/frontend/src/features/instructor/useControl.ts +++ b/frontend/src/features/instructor/useControl.ts @@ -63,7 +63,9 @@ export function useControl(sessionId: string | null) { ); const stop = useCallback(() => channel.current?.socket.send({ type: "session.stop" }), []); + const pause = useCallback(() => channel.current?.socket.send({ type: "session.pause" }), []); + const resume = useCallback(() => channel.current?.socket.send({ type: "session.resume" }), []); const playReference = useCallback(() => channel.current?.socket.send({ type: "reference.play" }), []); - return { status, openSessionId, start, note, inject, stop, playReference }; + return { status, openSessionId, start, note, inject, stop, pause, resume, playReference }; } diff --git a/frontend/src/features/kio-card/fields.ts b/frontend/src/features/kio-card/fields.ts index 26f73e3..a5c6848 100644 --- a/frontend/src/features/kio-card/fields.ts +++ b/frontend/src/features/kio-card/fields.ts @@ -97,6 +97,7 @@ export const GROUPS: FieldGroup[] = [ { path: "incident_type", label: "Тип происшествия", kind: "select", options: INCIDENT }, { path: "victims_count", label: "Пострадавших", kind: "number" }, { path: "is_emergency", label: "Признак ЧС", kind: "bool" }, + { path: "offence_signs", label: "Признаки правонарушения", kind: "bool" }, { path: "life_threat", label: "Угроза жизни", kind: "bool" }, { path: "evacuation_needed", label: "Нужна эвакуация", kind: "bool" }, ], diff --git a/frontend/src/features/observe/useObserve.ts b/frontend/src/features/observe/useObserve.ts index 7505865..697dad0 100644 --- a/frontend/src/features/observe/useObserve.ts +++ b/frontend/src/features/observe/useObserve.ts @@ -29,6 +29,7 @@ export interface ObservedSession { hintsUsed: number; notes: Map; ended: boolean; + paused: boolean; report: SessionReport | null; error: string | null; } @@ -45,6 +46,7 @@ export function useObserve(sessionId: string | null): ObservedSession { const [hintsUsed, setHintsUsed] = useState(0); const [notes, setNotes] = useState>(new Map()); const [ended, setEnded] = useState(false); + const [paused, setPaused] = useState(false); // Сессия, для которой отчёт уже стоит просить. Привязка к id, а не флаг: // при переключении истории старый флаг не должен дёрнуть запрос новой сессии. const [reportFor, setReportFor] = useState(null); @@ -65,6 +67,7 @@ export function useObserve(sessionId: string | null): ObservedSession { setHintsUsed(0); setNotes(new Map()); setEnded(false); + setPaused(false); setError(null); if (!sessionId) return; let active = true; @@ -88,6 +91,7 @@ export function useObserve(sessionId: string | null): ObservedSession { setTimers(event.timers); setHintsUsed(event.hints_used ?? 0); setEnded(event.ended ?? false); + setPaused(event.paused ?? false); if (event.ended) loadReport(); break; case "kio.state": @@ -112,6 +116,9 @@ export function useObserve(sessionId: string | null): ObservedSession { setEnded(true); loadReport(); break; + case "session.paused": + setPaused(event.paused); + break; case "error": setError(event.message); if (event.code === "session_not_found") loadReport(); @@ -127,5 +134,5 @@ export function useObserve(sessionId: string | null): ObservedSession { return () => { active = false; channel.close(); }; }, [sessionId, queries]); - return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, report, error }; + return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, paused, report, error }; } diff --git a/frontend/src/features/sip/SipPhone.tsx b/frontend/src/features/sip/SipPhone.tsx index 74e506b..4e68a1d 100644 --- a/frontend/src/features/sip/SipPhone.tsx +++ b/frontend/src/features/sip/SipPhone.tsx @@ -12,12 +12,19 @@ const LABEL: Record = { error: "ошибка", }; +/** Абонент быстрого набора: пульт ДДС звонит бригаде и начальнику одной кнопкой. */ +export interface SipContact { + label: string; + number: string; +} + export function SipPhone({ - defaultExtension = "6101", defaultTarget = "6102", title = "Локальный SIP/WebRTC", + defaultExtension = "6101", defaultTarget = "6102", title = "Локальный SIP/WebRTC", contacts = [], }: { defaultExtension?: string; defaultTarget?: string; title?: string; + contacts?: SipContact[]; }) { const phone = useSipPhone(); const [extension, setExtension] = useState(defaultExtension); @@ -46,6 +53,10 @@ export function SipPhone({ {phone.status === "registered" && } + {phone.status === "registered" && contacts.map((contact) => )} {phone.status === "incoming" && } {inCall && phone.status !== "incoming" && {phone.peer || target}} {inCall && } diff --git a/frontend/src/pages/dds/Dds.tsx b/frontend/src/pages/dds/Dds.tsx index e3bb3c7..cc5e921 100644 --- a/frontend/src/pages/dds/Dds.tsx +++ b/frontend/src/pages/dds/Dds.tsx @@ -238,6 +238,7 @@ export function Dds() { const [checkText, setCheckText] = useState(""); const [error, setError] = useState(""); const [ended, setEnded] = useState(false); + const [paused, setPaused] = useState(false); // Разбор просим только после `score.ready`; новая карточка снимает флаг. const [scoreReady, setScoreReady] = useState(false); const { report, loading: reportLoading, error: reportError, refetch: refetchReport } = @@ -257,6 +258,7 @@ export function Dds() { if (event.type === "station.state") { const snapshot = event.snapshot as DdsSnapshot; setStation(snapshot); + setPaused(snapshot.paused ?? false); setZone(snapshot.zone_decision ?? null); if (snapshot.card_id == null && (snapshot.pending_cards_count ?? 0) > 0) { activeCardId.current = null; @@ -303,6 +305,7 @@ export function Dds() { } else if (event.type === "timer.tick") setTimers(event.timers); else if (event.type === "error") { setError(event.message); setDialing(false); setAdvancePending(false); } else if (event.type === "session.ended") { setEnded(true); setDialing(false); setAdvancePending(false); setPane(null); setView("registry"); } + else if (event.type === "session.paused") setPaused(event.paused); else if (event.type === "score.ready") setScoreReady(true); else if (event.type === "phone.report") { setLastReport(event); @@ -323,7 +326,7 @@ export function Dds() { return () => clearInterval(timer); }, []); - const send = (command: StationToServer): boolean => channel.current?.send(command) ?? false; + const send = (command: StationToServer): boolean => !paused && (channel.current?.send(command) ?? false); if (!sessionId) return ; const reports = station?.phone_reports ?? []; @@ -333,6 +336,8 @@ export function Dds() { const phoneCall = station?.phone_pending ?? null; const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew); const isConnected = status === "open"; + // Вверх: короткая пауза не должна выглядеть как «0 с», а без пауз строки нет. + const pausedSeconds = Math.ceil((report?.total_paused_ms ?? 0) / 1000); const cardId = card ? String(readPath(card.server, "card_id") ?? "") : ""; const cardIndex = station?.card_index ?? 1; const cardTotal = station?.card_total ?? 1; @@ -373,18 +378,21 @@ export function Dds() { АРМ {role}: {isConnected ? "на связи" : status} {error &&
Действие не принято: {error}
} + {paused && !ended &&
Пауза, ждите преподавателя
} {ended &&
Занятие завершено. Оценка появится после расчёта.
} {(ended || scoreReady) &&

Итог упражнения

{report ?

Итоговая оценка: {report.score_final.toFixed(0)} из 100 {report.score_final !== report.score_auto && ` · автооценка ${report.score_auto.toFixed(0)}`}

+ {pausedSeconds > 0 &&

Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.

}

Скачать отчёт: CSV {" · "}PDF

{Boolean(report.card_results?.length) && <>

Результаты по карточкам

{report.card_results?.map((item, index) =>
- Карточка {index + 1} · {item.scenario_id} · {item.score_auto.toFixed(0)} / 100 + Карточка {index + 1} · {item.scenario_id} · {(item.score_reviewed ?? item.score_auto).toFixed(0)} / 100 + {item.score_reviewed != null && ` после разбора (автооценка ${item.score_auto.toFixed(0)})`}

Ответ диспетчера: {item.reply_text || "не внесён"}

{item.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) =>

@@ -414,6 +422,7 @@ export function Dds() {

}
} +
{view === "registry" && <>
@@ -604,9 +613,11 @@ export function Dds() {
; } diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx index e36b43c..cf972b0 100644 --- a/frontend/src/pages/instructor/Instructor.tsx +++ b/frontend/src/pages/instructor/Instructor.tsx @@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from "react"; import { Debrief } from "@/features/debrief/Debrief"; +import { FindingReview } from "@/features/debrief/FindingReview"; import { Director } from "@/features/instructor/Director"; import { ScoreWeightsEditor } from "@/features/instructor/ScoreWeightsEditor"; import { ScenarioEditor } from "@/features/instructor/ScenarioEditor"; @@ -92,6 +93,8 @@ export function Instructor() { const [decisionTimeLimit, setDecisionTimeLimit] = useState(30); const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180); const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180); + const [ddsReportReactionEnabled, setDdsReportReactionEnabled] = useState(false); + const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45); const [allowedErrors, setAllowedErrors] = useState(0); const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true); const [lessonScoreWeights, setLessonScoreWeights] = useState>({}); @@ -290,6 +293,7 @@ export function Instructor() { && !(exercise === "dds" && wholeTicket) ? visibleScenarios.filter((item) => item.outcome === "card").map((item) => item.id) : undefined; + const reportReactionEnabled = (exercise === "dds" || exercise === "card_dds") && ddsReportReactionEnabled; const sent = control.start(sessionId, { scenarioId: primaryScenarioId, mode, @@ -306,14 +310,18 @@ export function Instructor() { decision_time_limit_seconds: decisionTimeLimit, card_fill_time_limit_seconds: cardFillTimeLimit, dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit, + dds_report_reaction_enabled: reportReactionEnabled, + dds_report_reaction_limit_seconds: ddsReportReactionLimit, allowed_errors: allowedErrors, require_correct_grammar: requireCorrectGrammar, - score_weights: lessonScoreWeights, + score_weights: reportReactionEnabled + ? lessonScoreWeights + : Object.fromEntries(Object.entries(lessonScoreWeights).filter(([key]) => key !== "dds_report_reaction")), }, }); if (sent) toStart.current = null; }, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data, - exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios, + exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionEnabled, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios, ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data, randomScenario, visibleScenarios]); @@ -661,6 +669,20 @@ export function Instructor() { onCommit={setDdsCardWorkTimeLimit} /> По умолчанию 180 секунд от открытия до первого статуса с текстом; дальше работы по времени не нормируются. } + {(exercise === "dds" || exercise === "card_dds") && <> + + {ddsReportReactionEnabled && } + }
@@ -708,12 +731,20 @@ export function Instructor() { {sessionId && session.title && (

{session.ended ? "занятие завершено" : ( - + <> + {" "} + {session.paused ? ( + + ) : ( + + )} + {session.paused && Занятие на паузе — время не идёт} + )}

)} {sessionId && session.title && !session.ended && ( - exercise === "call" && + exercise === "call" && )} {links && startPendingId && (

Создаём занятие. Ссылки появятся автоматически после подключения.

@@ -814,6 +845,8 @@ export function Instructor() { {session.report && <> +

Ручная корректировка оценки

Автоматическая оценка: {session.report.score_auto.toFixed(1)} из 100. Меняйте её только при необходимости: причина сохранится в журнале вместе с вашим логином.

diff --git a/frontend/src/pages/phone/Phone.tsx b/frontend/src/pages/phone/Phone.tsx index bf767b0..cff22a4 100644 --- a/frontend/src/pages/phone/Phone.tsx +++ b/frontend/src/pages/phone/Phone.tsx @@ -1,9 +1,22 @@ import { SipPhone } from "@/features/sip/SipPhone"; +// Второе рабочее место голосовой связи ДДС. Старший группы — по умолчанию; +// вышестоящего начальника (?role=superior) играет преподаватель: это +// обязательный маршрут по ответу заказчика (П.7). +const ROLES = { + crew: { title: "Телефон старшего группы", extension: "6102", heading: "IP-телефон старшего группы" }, + superior: { title: "Телефон вышестоящего начальника", extension: "6103", heading: "IP-телефон вышестоящего начальника" }, +} as const; + export function Phone() { + const role = new URLSearchParams(window.location.search).get("role") === "superior" ? "superior" : "crew"; + const { title, extension, heading } = ROLES[role]; return
-

IP-телефон старшего группы

-

Откройте этот экран на втором рабочем месте, зарегистрируйте номер 6102 и примите вызов ДДС 6101.

- +

{heading}

+

Откройте этот экран на втором рабочем месте, зарегистрируйте номер {extension} и примите вызов ДДС 6101.

+ +

{role === "crew" + ? <>Роль вышестоящего начальника — телефон 6103. + : <>Роль старшего группы — телефон 6102.}

; } diff --git a/frontend/src/pages/trainee/Call.tsx b/frontend/src/pages/trainee/Call.tsx index 8b68057..2bbf514 100644 --- a/frontend/src/pages/trainee/Call.tsx +++ b/frontend/src/pages/trainee/Call.tsx @@ -122,15 +122,16 @@ export function Call() { event.preventDefault(); if (call.sendText(textTurn)) setTextTurn(""); }}> - setTextTurn(event.target.value)} /> - + + {call.paused &&

Пауза, ждите преподавателя

} {call.error &&

{call.error}

}
+ readOnly={call.paused || call.cardSubmitted} referenceArm onChange={call.patchKio} />
@@ -142,7 +143,7 @@ export function Call() { ? notifiedServices.map((service) => {service}) : появятся после выбора признаков} - {!call.cardSubmitted && } {call.cardSubmitted && {call.briefing.handoff_to_dds ? "Карточка передана ДДС" : "Карточка отправлена"}} @@ -174,11 +175,11 @@ export function Call() {
- {(call.phase === "incoming" || call.phase === "talking") && !call.micOn && } {call.micOn && ● Разговор идёт} - {call.micOn && hintsAllowed(call.incoming?.mode) && } + {call.micOn && hintsAllowed(call.incoming?.mode) && } {call.phase === "ended" ? "Вызов завершён" : !call.micOn && call.phase === "incoming" @@ -190,13 +191,14 @@ export function Call() { : "Ожидайте входящего вызова"}
+ {call.paused &&

Пауза, ждите преподавателя

} {call.error &&

{call.error}

}
@@ -207,12 +209,12 @@ export function Call() { - {call.micOn && } + {call.micOn && }
@@ -225,6 +227,7 @@ export function Call() { type="text" aria-label="Комментарий при закрытии вызова без карточки" value={resolveComment} + disabled={call.paused} placeholder="что сказали заявителю, куда передали" onChange={(event) => setResolveComment(event.target.value)} /> @@ -232,7 +235,7 @@ export function Call() {

{" "}