Merge remote-tracking branch 'origin/main' into feat/decline-scenarios

# Conflicts:
#	docs/spec/GAP.md
This commit is contained in:
gglamer 2026-09-27 20:56:41 +00:00
commit b7445a60fe
66 changed files with 4250 additions and 333 deletions

View file

@ -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

View file

@ -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")

View file

@ -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(

View file

@ -23,14 +23,32 @@ from app.db import repo
from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee
from app.domain.events import Exercise, SessionMode, SessionReport
from app.domain.taxonomy import (
ERRORS,
ErrorCode,
Finding,
FindingDecision,
FindingReview,
FindingSource,
)
from app.domain.roles import Role
from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current
from app.domain.statuses import (
SERVICE_STATUS_LABELS,
DdsQueueCard,
ServiceStatus,
StationSnapshot,
current,
)
from app.domain.timers import TimerCode, TimerState
from app.scoring.export import to_csv, to_pdf
from app.scoring.report import build as build_report
from app.scoring.review import has_finding
from app.scoring.taxonomy import METRIC_MAP
from app.session.access import can_access
from app.session.checkpoint import load_state
from app.session.finish import override_score
from app.session.finish import change_findings, override_score
from app.session.store import FindingAdded, FindingReviewed
from app.session.timers import now_utc
from app.session.score import scoring_scenario
from app.session.hub import hub
from app.session.state import SessionState
@ -111,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
@ -212,10 +231,12 @@ def _signals(
"""
settings = get_settings()
signals: list[Signal] = []
# Необработанная — без первичного статуса: он останавливает норматив
# DDS_ACK. Принятая или отклонённая карточка висит в очереди до
# «Следующей», но реакции курсанта уже не ждёт.
pending = sum(not card.timer_stopped for card in queue)
# Необработанная — без первичного статуса. Норматив DDS_ACK теперь
# останавливается открытием карточки, поэтому считаем по статусу службы:
# открытая, но не принятая и не отклонённая карточка реакции ещё ждёт.
pending = sum(
card.service_status in {ServiceStatus.ADDED, ServiceStatus.RECEIVED} for card in queue
)
if pending >= settings.signal_backlog_threshold:
signals.append(Signal(
kind="backlog", severity=TimerState.WARN,
@ -290,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 []
@ -318,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,
@ -649,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,

View file

@ -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:

View file

@ -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,

View file

@ -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)

View file

@ -23,7 +23,7 @@ from app.domain.roles import Role
from app.domain.statuses import ServiceStatus, StationSnapshot
from app.domain.taxonomy import Finding
from app.domain.timers import TimerSnapshot
from app.scoring.taxonomy import METRIC_MAP
from app.scoring.taxonomy import INSTRUCTOR_METRIC, METRIC_MAP
class SessionMode(StrEnum):
@ -53,6 +53,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):

View file

@ -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

View file

@ -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

View file

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

View file

@ -59,10 +59,11 @@ NORMATIVES: dict[TimerCode, Normative] = {
TimerCode.DDS_ACK: Normative(
# 30 секунд — ПП РФ № 1931 и памятка АРМ-112, а не ГОСТ: до получения
# датасета здесь стояло 4 секунды, взятые по догадке (docs/spec/DATASET.md).
# Отсчёт — от появления карточки до её открытия (ответ заказчика, П.5).
code=TimerCode.DDS_ACK,
title="Подтверждение получения карточки",
title="Открытие карточки ДДС",
limit_ms=30_000,
ref="ПП РФ № 1931",
ref="ПП РФ № 1931; ответ заказчика, П.5",
),
TimerCode.CARD_FILL: Normative(
code=TimerCode.CARD_FILL,
@ -72,9 +73,9 @@ NORMATIVES: dict[TimerCode, Normative] = {
),
TimerCode.DDS_WORK: Normative(
code=TimerCode.DDS_WORK,
title="Отработка карточки ДДС",
title="Первая запись по карточке ДДС",
limit_ms=180_000,
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
ref="ТЗ задачи № 9; ответ заказчика, П.5",
),
TimerCode.ZONE_CHECK: Normative(
code=TimerCode.ZONE_CHECK, title="Проверка зоны ответственности", limit_ms=30_000

View file

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

View file

@ -11,31 +11,138 @@
привязывает статус к звонку или 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,
)
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.events import Metric
from app.domain.timers import GOST_REF
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=(
@ -161,43 +272,43 @@ def evaluate_dispatcher(
),
))
# D1 — первичного статуса нет либо он проставлен позже норматива.
# D1 — карточка открыта позже норматива или не открыта вовсе. 30 с идут
# от появления карточки в строке сообщений до её открытия (ответ
# заказчика, П.5); `elapsed_ms` — зафиксированное время открытия.
if elapsed_ms is None or elapsed_ms > deadline_ms:
findings.append(
_finding(
service, ErrorCode.D1,
(f"{service}: карточка открыта с нарушением срока"
if elapsed_ms is not None else f"{service}: карточка не открыта"),
fact=(
f"открыта через {elapsed_ms // 1000} с"
if elapsed_ms is not None
else "карточка не открыта до конца занятия"
),
norm=f"открыть карточку в течение {deadline_ms // 1000} с",
)
)
# D1 — первичного статуса нет: карточка ушла в «Не оповещено».
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
if primary is None:
findings.append(
_finding(
ErrorCode.D1,
service, ErrorCode.D1,
f"{service}: статус реагирования не проставлен",
fact=(
f"прошло {elapsed_ms // 1000} с"
if elapsed_ms is not None
else "карточка осталась без отметки"
),
norm=f"первичный статус в течение {deadline_ms // 1000} с",
fact="карточка осталась без первичного статуса",
norm="проставить «Принята» или «Не принята» с комментарием",
)
)
continue
if elapsed_ms is None or elapsed_ms > deadline_ms:
findings.append(
_finding(
ErrorCode.D1,
f"{service}: первичный статус проставлен с нарушением срока",
fact=(
f"прошло {elapsed_ms // 1000} с"
if elapsed_ms is not None
else "время первичной отметки не зафиксировано"
),
norm=f"первичный статус в течение {deadline_ms // 1000} с",
)
)
for mark in marks:
# D4 — отказ без комментария.
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,28 +418,36 @@ 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)
elapsed = (int((primary.at - state.dispatched_at).total_seconds() * 1000)
if primary and state.dispatched_at else None)
add("dds_primary", "решение по карточке", primary is not None,
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с",
add("dds_ack", f"открытие карточки за {deadline_ms // 1000} с",
elapsed is not None and elapsed <= deadline_ms,
f"{elapsed / 1000:.1f} с" if elapsed is not None else "не отмечено",
f"{elapsed / 1000:.1f} с" if elapsed is not None else "карточка не открыта",
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 "Не принята"
@ -326,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)

View file

@ -11,6 +11,7 @@ from html import escape
from pathlib import Path
from app.domain.events import SessionReport
from app.domain.taxonomy import Finding, FindingDecision, FindingSource
def _cell(value: object) -> str:
@ -18,12 +19,27 @@ def _cell(value: object) -> str:
if value is None:
return ""
result = str(value)
probe = result.lstrip(" \t\r\n\ufeff\u200b")
probe = result.lstrip(" \t\r\n\N{ZERO WIDTH NO-BREAK SPACE}\N{ZERO WIDTH SPACE}")
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
return "'" + result
return result
def _finding_origin(item: Finding) -> str:
if item.source is FindingSource.INSTRUCTOR:
return f"; добавлена преподавателем {item.author or ''}".rstrip()
return ""
def _finding_decision(item: Finding) -> str:
"""Решения по отметке рядом с ней, все по порядку: действует последнее."""
return "".join(
f"; {'снята' if review.decision is FindingDecision.DISMISSED else 'подтверждена'} "
f"преподавателем {review.author}: {review.reason}"
for review in item.reviews
)
def to_csv(report: SessionReport) -> bytes:
"""UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки."""
output = io.StringIO(newline="")
@ -37,13 +53,17 @@ def to_csv(report: SessionReport) -> bytes:
row("Занятие", "", "Сценарий", report.scenario_id)
row("Занятие", "", "Режим", report.mode.value)
row("Занятие", "", "Попытка", report.attempt)
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
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]:
@ -150,7 +172,10 @@ def to_pdf(report: SessionReport) -> bytes:
("Сценарий", report.scenario_id),
("Режим", report.mode.value),
("Попытка", report.attempt),
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
("Лимит открытия карточки ДДС", 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"<b>{escape(label)}:</b> {escape(str(value))}", base))
# Когда итог правили вручную, без этой строки не понять, какой балл дал разбор.
if report.score_reviewed is not None:
story.append(Paragraph(f"<b>После разбора отметок:</b> {report.score_reviewed}", base))
if report.overridden_by:
story.append(Paragraph(f"<b>Оценка изменена:</b> {escape(report.overridden_by)}", base))
if report.override_comment:
@ -183,7 +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:

View file

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

View file

@ -106,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,
)

View file

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

View file

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

View file

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

View file

@ -241,12 +241,23 @@ def _refused(message: str) -> DeskOutcome:
def _start_work_timer(card: DdsLiveCard) -> None:
"""Три минуты на работу идут один раз — с открытия карточки."""
"""Открытие карточки: останавливает 30 с и запускает 3 мин — один раз.
Автоматическая активация первой карточки очереди открытием не считается:
карточка стоит в строке сообщений, пока диспетчер её не откроет. Первое
действие по карточке без явного открытия тоже доказывает, что она открыта.
"""
timer = card.timers.timers.get(TimerCode.DDS_WORK)
if timer is None or timer.started_at is None:
card.on_event("dds.open")
def _first_record(card: DdsLiveCard, comment: str) -> None:
"""Первая запись — ручной статус с текстом; она закрывает 3 минуты (П.5)."""
if comment.strip():
card.on_event("dds.record")
def _line(card: DdsLiveCard, speaker: str, text: str) -> PhoneLine | None:
call = card.phone_pending
if call is None:
@ -436,17 +447,17 @@ class DdsDesk(BaseModel):
return _refused("Для подтверждения приёма добавьте комментарий с основанием")
if any(action == "card.ack" for action, _at, _detail in card.dds_log):
return DeskOutcome()
card.on_event("card.ack")
card.dds_log.append(("card.ack", now_utc(), None))
_start_work_timer(card)
services = session.card_services(card)
if services:
_start_work_timer(card)
try:
card.set_service_status(
services[0], ServiceStatus.ACCEPTED, command.comment,
author="диспетчер",
)
session.consecutive_refusals = 0
_first_record(card, command.comment)
except StatusError:
pass # статус уже стоит: повторное нажатие ничего не меняет
case "card.status":
@ -460,9 +471,8 @@ class DdsDesk(BaseModel):
card.set_service_status(
command.service, command.status, command.comment, author="диспетчер"
)
# Первичный статус останавливает норматив 30 секунд.
_first_record(card, command.comment)
if command.status in PRIMARY:
card.on_event("card.ack")
# Сигнал реестра "повторные отказы" считает подряд идущие
# отказы, а не сумму за занятие.
session.consecutive_refusals = (
@ -470,10 +480,6 @@ class DdsDesk(BaseModel):
if command.status is ServiceStatus.DECLINED
else 0
)
if command.status in {
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
}:
card.on_event("dds.complete")
case "crew.select":
if command.crew not in session.crew_options(card):
return _refused("Выберите бригаду из списка доступных")
@ -591,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(

View file

@ -12,8 +12,7 @@ import logging
import time
from uuid import UUID
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded
from app.domain.statuses import ServiceStatus, current
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
@ -26,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,
)
@ -45,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,
@ -54,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]
@ -65,16 +75,11 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
work_elapsed_ms = card.timers.measured_ms(TimerCode.DDS_WORK)
if work_elapsed_ms is None and work_timer is not None and work_timer.started_at is not None:
work_elapsed_ms = work_timer.current_ms(time.monotonic())
terminal = bool(services) and all(
current(card.status_log, service) in {
ServiceStatus.COMPLETED,
ServiceStatus.DECLINED,
ServiceStatus.REFUSED,
}
for service in services
)
# 3 минуты — на открытие и первую запись (статус с текстом), а не на
# закрытие: работы по карточке могут идти часы и дни (ответ заказчика, П.5).
recorded = any(mark.comment.strip() for mark in card.status_log if mark.service in services)
work_metric, work_finding = time_metric(
DDS_WORK_TIME, work_elapsed_ms, work_limit_ms, completed=terminal
DDS_WORK_TIME, work_elapsed_ms, work_limit_ms, completed=recorded
)
weighted.metrics.append(work_metric)
if work_finding is not None:
@ -100,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)),
@ -181,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")
# Норматив отработки фиксируется событием, а не текущим значением часов.
@ -200,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))
@ -234,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))

View file

@ -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())

View file

@ -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

View file

@ -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,
))
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
# занятия. Веса первого сценария поверх них испортили бы остальные.

View file

@ -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,
)

View file

@ -16,6 +16,8 @@ from typing import Protocol
from uuid import UUID
from app.domain.events import TranscriptEntry
from app.domain.taxonomy import Finding, FindingReview
from app.scoring.review import add_finding, review_finding
from app.session.checkpoint import dump_state
from app.session.state import SessionState
@ -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:

View file

@ -41,6 +41,8 @@ STARTS: dict[str, tuple[TimerCode, ...]] = {
"call.incoming": (TimerCode.ANSWER,),
"call.answer": (TimerCode.INTERVIEW,),
"dds.dispatch": (TimerCode.DDS_ACK, TimerCode.CLOSE),
# Ответ заказчика (П.5): 30 с — от появления карточки в строке сообщений
# до её открытия, 3 мин — на открытие и первую запись.
"dds.open": (TimerCode.DDS_WORK,),
"card.start": (TimerCode.CARD_FILL,),
"card.received": (TimerCode.ZONE_CHECK,),
@ -55,10 +57,12 @@ STOPS: dict[str, tuple[TimerCode, ...]] = {
# Справка и передача в другой регион заканчивают опрос так же, как передача
# карточки: норматив опроса не должен тикать после решения (lct-36).
"call.resolve": (TimerCode.INTERVIEW,),
"card.ack": (TimerCode.DDS_ACK,),
"card.submit": (TimerCode.CARD_FILL,),
"card.end": (TimerCode.CARD_FILL,),
"dds.complete": (TimerCode.DDS_WORK,),
"dds.open": (TimerCode.DDS_ACK,),
# Первая запись — первый ручной статус с текстом. Дальше работы могут идти
# часы и дни: остальные статусы по времени не нормируются (П.5).
"dds.record": (TimerCode.DDS_WORK,),
"dds.finish": (TimerCode.DDS_WORK,),
"zone.decision": (TimerCode.ZONE_CHECK,),
"crew.arrived": (TimerCode.CLOSE,),
@ -83,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]:
@ -100,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
@ -125,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` приходит из конфига —
@ -142,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]:
"""Только запущенные таймеры: показывать нули по нормативам,
до которых занятие ещё не дошло, значит пугать курсанта зря."""

View file

@ -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)

View file

@ -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(

View file

@ -1,6 +1,6 @@
{
"dds_several_cards": {
"score_auto": 53.3,
"score_auto": 65.3,
"card_status": "processed",
"metrics": [
[
@ -13,7 +13,7 @@
],
[
"dds_ack",
"Карточка 1: Служба 101: первичная отметка за 30 с",
"Карточка 1: Служба 101: открытие карточки за 30 с",
"0.0 с",
true,
1.0,
@ -61,11 +61,11 @@
],
[
"dds_work_time",
"Карточка 1: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
"Карточка 1: Первая запись по карточке ДДС",
"0 с (на 180 с быстрее норматива)",
true,
1.5,
0.0
1.0
],
[
"dds_primary",
@ -77,7 +77,7 @@
],
[
"dds_ack",
"Карточка 2: СМП: первичная отметка за 30 с",
"Карточка 2: СМП: открытие карточки за 30 с",
"0.0 с",
true,
1.0,
@ -125,11 +125,11 @@
],
[
"dds_work_time",
"Карточка 2: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
"Карточка 2: Первая запись по карточке ДДС",
"0 с (на 180 с быстрее норматива)",
true,
1.5,
0.0
1.0
],
[
"dds_primary",
@ -141,7 +141,7 @@
],
[
"dds_ack",
"Карточка 3: Служба 101: первичная отметка за 30 с",
"Карточка 3: Служба 101: открытие карточки за 30 с",
"0.0 с",
true,
1.0,
@ -189,11 +189,11 @@
],
[
"dds_work_time",
"Карточка 3: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
"Карточка 3: Первая запись по карточке ДДС",
"0 с (на 180 с быстрее норматива)",
true,
1.5,
0.0
1.0
]
],
"findings": [
@ -207,11 +207,6 @@
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
"не отмечены: Прибытие, Проведение работ"
],
[
"E3",
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
],
[
"D2",
"Карточка 2: СМП: «Принята» без назначения бригады и хода реагирования",
@ -222,11 +217,6 @@
"Карточка 2: СМП: ход реагирования не доведён до конца",
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
],
[
"E3",
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
],
[
"D5",
"Карточка 3: Служба 101: комментарии к статусам не разделяют основание и сведения",
@ -236,17 +226,12 @@
"D6",
"Карточка 3: Служба 101: ход реагирования не доведён до конца",
"не отмечены: Прибытие, Проведение работ"
],
[
"E3",
"Карточка 3: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
]
],
"cards": [
[
"fire-apartment-l2",
56.0,
68.0,
"Принято, бригада направлена",
[
"card.ack",
@ -259,7 +244,7 @@
],
[
"t20-2-stroke",
48.0,
60.0,
"",
[
"card.ack",
@ -268,7 +253,7 @@
],
[
"t01-1-fire-container",
56.0,
68.0,
"",
[
"card.ack",
@ -281,7 +266,7 @@
]
},
"dds_early_finish": {
"score_auto": 35.3,
"score_auto": 44.1,
"card_status": "processed",
"metrics": [
[
@ -294,7 +279,7 @@
],
[
"dds_ack",
"Карточка 1: СМП: первичная отметка за 30 с",
"Карточка 1: СМП: открытие карточки за 30 с",
"0.0 с",
true,
1.0,
@ -342,11 +327,11 @@
],
[
"dds_work_time",
"Карточка 1: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
"Карточка 1: Первая запись по карточке ДДС",
"0 с (на 180 с быстрее норматива)",
true,
1.5,
0.0
1.0
],
[
"dds_primary",
@ -358,16 +343,16 @@
],
[
"dds_ack",
"Карточка 2: Служба 101: первичная отметка за 30 с",
"не отмечено",
"Карточка 2: Служба 101: открытие карточки за 30 с",
"карточка не открыта",
false,
1.0,
null
],
[
"dds_work_time",
"Карточка 2: Отработка карточки ДДС",
"карточка не завершена; время обработки не зафиксировано",
"Карточка 2: Первая запись по карточке ДДС",
"первая запись не добавлена; карточка не открыта",
false,
1.5,
0.0
@ -385,25 +370,25 @@
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
],
[
"E3",
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
"D1",
"Карточка 2: Служба 101: карточка не открыта",
"карточка не открыта до конца занятия"
],
[
"D1",
"Карточка 2: Служба 101: статус реагирования не проставлен",
"карточка осталась без отметки"
"карточка осталась без первичного статуса"
],
[
"E3",
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; время обработки не зафиксировано"
"Карточка 2: ДДС: первая запись по карточке позже норматива",
"первая запись не добавлена; карточка не открыта"
]
],
"cards": [
[
"t20-2-stroke",
48.0,
60.0,
"",
[
"card.ack",
@ -419,7 +404,7 @@
]
},
"handoff_112_to_dds": {
"score_auto": 47.8,
"score_auto": 56.5,
"card_status": "processed",
"metrics": [
[
@ -480,7 +465,7 @@
],
[
"dds_ack",
"Карточка 1: Служба 101: первичная отметка за 30 с",
"Карточка 1: Служба 101: открытие карточки за 30 с",
"0.0 с",
true,
1.0,
@ -528,11 +513,11 @@
],
[
"dds_work_time",
"Карточка 1: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
"Карточка 1: Первая запись по карточке ДДС",
"0 с (на 180 с быстрее норматива)",
true,
1.5,
0.0
1.0
],
[
"dds_primary",
@ -544,7 +529,7 @@
],
[
"dds_ack",
"Карточка 2: Служба 101: первичная отметка за 30 с",
"Карточка 2: Служба 101: открытие карточки за 30 с",
"0.0 с",
true,
1.0,
@ -592,11 +577,11 @@
],
[
"dds_work_time",
"Карточка 2: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
"Карточка 2: Первая запись по карточке ДДС",
"0 с (на 180 с быстрее норматива)",
true,
1.5,
0.0
1.0
]
],
"findings": [
@ -630,11 +615,6 @@
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
"не отмечены: Прибытие, Проведение работ"
],
[
"E3",
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
],
[
"D2",
"Карточка 2: Служба 101: «Принята» без назначения бригады и хода реагирования",
@ -644,17 +624,12 @@
"D6",
"Карточка 2: Служба 101: ход реагирования не доведён до конца",
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
],
[
"E3",
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
]
],
"cards": [
[
"fire-apartment-l2",
56.0,
68.0,
"",
[
"card.ack",
@ -666,7 +641,7 @@
],
[
"t01-1-fire-container",
48.0,
60.0,
"",
[
"card.ack",
@ -745,7 +720,7 @@
],
[
"dds_ack",
"Служба 101: первичная отметка за 30 с",
"Служба 101: открытие карточки за 30 с",
"0.0 с",
true,
1.0,

View file

@ -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

View file

@ -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(),

View file

@ -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,

View file

@ -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():

View file

@ -56,8 +56,9 @@ def fire():
def accept_with_crew(state: SessionState, card) -> None:
service = state.card_services(card)[0]
card.on_event("dds.open") # карточка открыта: 30 с остановлены
card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу")
card.on_event("card.ack")
card.on_event("dds.record")
card.crew_selected = state.crew_options(card)[0]
card.crew_assignments[service] = card.crew_selected
card.dds_log.append(("crew.select", now_utc(), card.crew_selected))

View file

@ -2,7 +2,7 @@
import asyncio
import time
from datetime import datetime
from datetime import datetime, timedelta
from uuid import uuid4
import pytest
@ -396,6 +396,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
client,
criteria={
"decision_time_limit_seconds": 45,
"dds_report_reaction_limit_seconds": 20,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
@ -443,17 +444,21 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
reply_metric = next(item for item in score["metrics"] if item["key"] == "dds_reply")
assert reply_metric["passed"]
assert not any(item["key"] == "dds_grammar" for item in score["metrics"])
# 3 минуты — до первой записи, а она сделана сразу: незавершённая
# работа по времени не штрафуется (ответ заказчика, П.5).
assert (
next(item for item in score["metrics"] if item["key"] == "dds_work_time")[
"passed"
]
is False
is True
)
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["criteria"] == {
"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},
@ -500,9 +505,8 @@ def test_dds_statuses_do_not_require_phone_reports(client):
finding["code"] == "D2" and "бригады" in finding["summary"]
for finding in score["findings"]
)
assert any(
finding["code"] == "E3" and "времени отработки" in finding["summary"]
for finding in score["findings"]
assert not any(finding["code"] == "E3" for finding in score["findings"]), (
"первая запись сделана сразу: дальше работы по времени не нормируются"
)
finding_codes = {finding["code"] for finding in score["findings"]}
penalty_codes = {
@ -703,22 +707,26 @@ def test_complete_dds_workflow_scores_100_with_assignment_without_call(client):
control.__exit__(None, None, None)
def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client):
def test_late_first_record_has_e3_and_long_work_after_it_does_not(client):
"""П.5: 3 минуты — на открытие и первую запись; дальше работы идут часы."""
session_id, control = start(
client, criteria={"dds_card_work_time_limit_seconds": 60}
)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
received = read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
station.send_json({"type": "card.open", "card_id": received["card"]["card_id"]})
read_until(station, "station.state")
assert state.desk.active.timers.measured_ms(TimerCode.DDS_ACK) is not None
state.desk.active.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61
station.send_json(
{"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
)
snapshot = read_until(station, "station.state")["snapshot"]
state.desk.active.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61
crew = next(
option
for option in snapshot["crew_options"]
@ -740,14 +748,14 @@ def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client)
)
assert not metric["passed"]
assert "61 с" in metric["fact"] and "+1 с" in metric["fact"]
finding = next(item for item in score["findings"] if item["code"] == "E3")
assert "времени отработки карточки" in finding["summary"]
findings = [item for item in score["findings"] if item["code"] == "E3"]
assert len(findings) == 1 and "первая запись" in findings[0]["summary"]
finally:
control.__exit__(None, None, None)
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:
@ -797,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)
@ -916,7 +1038,9 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
item["key"] == "dds_grammar"
for item in state.score["metrics"]
)
assert any(item["code"] == "E3" for item in state.score["findings"])
assert not any(item["code"] == "E3" for item in state.score["findings"]), (
"обе карточки получили первую запись сразу"
)
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["scenario_id"] == "fire-apartment-l2"
assert len(report["card_results"]) == 2

View file

@ -1,13 +1,14 @@
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from pydantic import ValidationError
from app.domain.statuses import ServiceStatus, StatusEntry
from app.domain.taxonomy import ErrorCode
from app.domain.statuses import PhoneReportRecord, ServiceStatus, StatusEntry
from app.domain.taxonomy import Competency, ErrorCode, FindingSource
from app.scenarios.loader import load_file
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
from app.scoring.timing import time_credit
from app.session.dds import DdsLiveCard, build_card
from app.session.timers import SessionTimers
@ -294,3 +295,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

View file

@ -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

View file

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

View file

@ -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):

View file

@ -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"),),

View file

@ -12,12 +12,13 @@ from app.domain.events import (
SessionMode,
)
from app.domain.kio import KIO
from app.domain.statuses import PhoneCallPending, ServiceStatus
from app.domain.statuses import PhoneCallPending, PhoneReportRecord, ServiceStatus
from app.domain.taxonomy import ErrorCode, Finding, FindingSource
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
from app.session.checkpoint import dump_state, load_state
from app.session.dds import prepare_handoff_queue, prepare_queue
from app.session.finish import score_dds_card
from app.session.state import DdsCardRecord, SessionState, now_utc
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@ -85,6 +86,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(),
@ -143,8 +203,9 @@ def test_concurrent_dds_queue_round_trips_with_each_timer_and_status():
first_card = state.desk.active
first_id = first_card.card_id
first_service = state.card_services(first_card)[0]
first_card.on_event("dds.open") # карточка открыта: 30 с остановлены
first_card.set_service_status(first_service, ServiceStatus.ACCEPTED, "Принято в работу")
first_card.on_event("card.ack")
first_card.on_event("dds.record")
second_id = state.desk.ordered()[1].card_id
assert state.desk.open(second_id)

View file

@ -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)

View file

@ -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)

View file

@ -62,19 +62,21 @@ def test_card_fill_time_metric_texts(elapsed_ms, completed, fact, passed):
(605_000, True, "605 с (+425 с сверх норматива)", False),
(25_000, True, "25 с (на 155 с быстрее норматива)", True),
(180_000, True, "180 с (точно в норматив)", True),
(None, False, "карточка не завершена; время обработки не зафиксировано", False),
(25_000, False, "карточка не завершена; 25 с (на 155 с быстрее норматива)", False),
(None, False, "первая запись не добавлена; карточка не открыта", False),
(25_000, False, "первая запись не добавлена; 25 с (на 155 с быстрее норматива)", False),
],
)
def test_dds_work_time_metric_texts(elapsed_ms, completed, fact, passed):
metric, finding = time_metric(DDS_WORK_TIME, elapsed_ms, 180_000, completed=completed)
assert metric.key == "dds_work_time"
assert metric.title == "Отработка карточки ДДС"
assert metric.title == "Первая запись по карточке ДДС"
assert metric.fact == fact
assert metric.norm == "завершить за 180 с"
assert metric.norm == "открыть карточку и добавить первую запись (статус и текст) за 180 с"
assert metric.passed is passed
assert (finding is None) is passed
if finding is not None:
assert finding.summary == "ДДС: норматив времени отработки карточки не выполнен"
assert (finding.fact, finding.norm) == (fact, "завершить за 180 с")
assert finding.summary == "ДДС: первая запись по карточке позже норматива"
assert (finding.fact, finding.norm) == (
fact, "открыть карточку и добавить первую запись (статус и текст) за 180 с"
)

View file

@ -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}

View file

@ -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<string | null>(null);
const [paused, setPaused] = useState(false);
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
const capture = useRef<Capture | null>(null);
@ -58,6 +59,8 @@ export function useCall(sessionId: string | null) {
const outbox = useRef<Record<string, unknown>>({});
const patchTimer = useRef<ReturnType<typeof setTimeout> | 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,
};

View file

@ -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 (
<section className={big ? "debrief debrief-big" : "debrief"}>
@ -37,12 +39,14 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
<article className="debrief-stat">
<span>Автоматическая оценка</span><strong>{report.score_auto.toFixed(0)}<small> из 100</small></strong>
<small>{report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"}</small>
{report.score_reviewed != null && <small>после разбора отметок: {report.score_reviewed.toFixed(0)}</small>}
</article>
<article className="debrief-stat">
<span>Упражнение</span><strong>{report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"}</strong>
<small>{report.attempt ? `Попытка ${report.attempt}` : "Завершённое упражнение"}</small>
</article>
</div>
{pausedSeconds > 0 && <p className="ref">Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.</p>}
<details className="debrief-tech-details">
<summary>Служебные сведения</summary>
<p>Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id}</p>
@ -71,12 +75,17 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>документ (PDF)</a>
</p>
<p className={report.passed ? "state-ok" : "state-violated"}>
{report.passed ? "Зачёт" : "Не зачтено"}: на разборе {report.failed_metrics} пунктов; допустимо пропустить {report.criteria.allowed_errors}. {report.exercise === "dds" && <>На первичное решение ДДС отводится
{report.passed ? "Зачёт" : "Не зачтено"}: на разборе {report.failed_metrics} пунктов; допустимо пропустить {report.criteria.allowed_errors}. {report.exercise === "dds" && <>На открытие карточки ДДС отводится
{` ${report.criteria.decision_time_limit_seconds} с`}</>}
{report.exercise === "dds" && <>; норматив отработки карточки ДДС —
{report.exercise === "dds" && <>; на первую запись по карточке —
{` ${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) && <section className="debrief-card-results">
<h3>Карточки занятия ({report.card_results!.length})</h3>
{report.card_results!.map((card, index) => <details key={card.card_id}>
<summary>Карточка {index + 1}: {card.title || card.scenario_id} · {card.score_auto.toFixed(0)} / 100
<summary>Карточка {index + 1}: {card.title || card.scenario_id} · {(card.score_reviewed ?? card.score_auto).toFixed(0)} / 100
{card.score_reviewed != null && ` после разбора (автооценка ${card.score_auto.toFixed(0)})`}
{card.address ? ` · ${card.address}` : ""}</summary>
{card.managed_service && <p><strong>Ответственная служба:</strong> {card.managed_service}</p>}
<p><strong>Ответ диспетчера:</strong> {card.reply_text || "не внесён"}</p>
@ -144,6 +154,10 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
<td>
{finding.summary}
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
{finding.source === "instructor" && <div className="ref">отметка преподавателя {finding.author ?? ""}</div>}
{(finding.reviews ?? []).map((review, reviewIndex) => <div className="ref" key={reviewIndex}>
{review.decision === "dismissed" ? "снята" : "подтверждена"} преподавателем {review.author}: {review.reason}
</div>)}
</td>
</tr>
))}

View file

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

View file

@ -25,13 +25,14 @@ const OPERATOR_METRICS: MetricOption[] = [
const DDS_METRICS: MetricOption[] = [
{ key: "dds_primary", label: "Первичное решение", hint: "Зафиксирован ли статус «Принята» или «Не принята»." },
{ key: "dds_ack", label: "Срок первичного решения", hint: "Соблюдение установленного лимита реакции." },
{ key: "dds_ack", label: "Открытие карточки", hint: "Карточка открыта в пределах лимита после появления в строке сообщений." },
{ key: "dds_decision", label: "Решение по эталону", hint: "Соответствие принятию или обоснованному отказу." },
{ key: "dds_crew", label: "Назначение бригады", hint: "Выбор необходимой бригады ДДС." },
{ key: "dds_progress", label: "Ход реагирования", hint: "Статусы по фактическому ходу работ или отказу." },
{ key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." },
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
{ key: "dds_work_time", 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<string, number>;
onChange: (weights: Record<string, number>) => 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 };

View file

@ -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 };
}

View file

@ -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" },
],

View file

@ -29,6 +29,7 @@ export interface ObservedSession {
hintsUsed: number;
notes: Map<string, string>;
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<Map<string, string>>(new Map());
const [ended, setEnded] = useState(false);
const [paused, setPaused] = useState(false);
// Сессия, для которой отчёт уже стоит просить. Привязка к id, а не флаг:
// при переключении истории старый флаг не должен дёрнуть запрос новой сессии.
const [reportFor, setReportFor] = useState<string | null>(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 };
}

View file

@ -12,12 +12,19 @@ const LABEL: Record<SipStatus, string> = {
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({
<label>Номер собеседника<input aria-label="Номер SIP собеседника" inputMode="numeric" value={target}
disabled={inCall} onChange={(event) => setTarget(event.target.value)} /></label>
{phone.status === "registered" && <button type="button" onClick={() => phone.dial(target)}>Позвонить</button>}
{phone.status === "registered" && contacts.map((contact) => <button type="button" key={contact.number}
onClick={() => { setTarget(contact.number); phone.dial(contact.number); }}>
Позвонить: {contact.label} · {contact.number}
</button>)}
{phone.status === "incoming" && <button type="button" className="sip-answer" onClick={phone.answer}>Ответить · {phone.peer}</button>}
{inCall && phone.status !== "incoming" && <span>{phone.peer || target}</span>}
{inCall && <button type="button" className="sip-hangup" onClick={phone.hangup}>Завершить</button>}

View file

@ -12,8 +12,8 @@ const TITLES: Record<string, string> = {
answer: "Ответ на звонок",
interview: "Опрос",
dds_notify: "Оповещение ДДС",
dds_ack: "Подтверждение ДДС",
dds_work: "Отработка карточки ДДС",
dds_ack: "Открытие карточки ДДС",
dds_work: "Первая запись ДДС",
card_fill: "Заполнение карточки КИО",
zone_check: "Зона ответственности",
callback: "Обратный дозвон",

View file

@ -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 <DdsHistoryOnly cards={ddsHistory.cards} error={ddsHistory.error} />;
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}
</span>
{error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
{paused && !ended && <div className="dds-alert dds-paused">Пауза, ждите преподавателя</div>}
{ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>}
{(ended || scoreReady) && <section className="dds-panel" aria-live="polite">
<div className="dds-panel-title"><h2>Итог упражнения</h2></div>
{report ? <div style={{ padding: 12 }}>
<p><strong>Итоговая оценка: {report.score_final.toFixed(0)} из 100</strong>
{report.score_final !== report.score_auto && ` · автооценка ${report.score_auto.toFixed(0)}`}</p>
{pausedSeconds > 0 && <p className="ref">Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.</p>}
<p className="ref">Скачать отчёт: <a href={`/api/sessions/${sessionId}/report.csv`}>CSV</a>
{" · "}<a href={`/api/sessions/${sessionId}/report.pdf`}>PDF</a></p>
{Boolean(report.card_results?.length) && <>
<h3>Результаты по карточкам</h3>
{report.card_results?.map((item, index) => <details className="dds-details" key={item.card_id}>
<summary>Карточка {index + 1} · {item.scenario_id} · {item.score_auto.toFixed(0)} / 100</summary>
<summary>Карточка {index + 1} · {item.scenario_id} · {(item.score_reviewed ?? item.score_auto).toFixed(0)} / 100
{item.score_reviewed != null && ` после разбора (автооценка ${item.score_auto.toFixed(0)})`}</summary>
<p><b>Ответ диспетчера:</b> {item.reply_text || "не внесён"}</p>
{item.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) =>
<p key={metric.key} className={metric.passed ? "ref" : "state-violated"}>
@ -414,6 +422,7 @@ export function Dds() {
</div>}
</section>}
<fieldset className="dds-pause-controls" disabled={paused && !ended}>
{view === "registry" && <>
<section className="dds-search">
<div className="dds-search-main">
@ -489,7 +498,7 @@ export function Dds() {
<td data-label="Статус службы"><span className="dds-cell-value">
<span>{item.managed_service || "ДДС"}: {SERVICE_STATUS_LABELS[item.service_status]}</span>
<small className={!item.timer_stopped && item.elapsed_ms > item.limit_ms ? "state-violated" : ""}>
{item.timer_stopped ? "решение за" : "до первичного решения"} {Math.ceil(item.elapsed_ms / 1000)} с
{item.timer_stopped ? "открыта за" : "не открыта"} {Math.ceil(item.elapsed_ms / 1000)} с
</small>
</span></td>
</tr>})}
@ -604,9 +613,11 @@ export function Dds() {
</div><aside className="dds-right">
{pane === "phone" && <section className="dds-panel dds-phone"><div className="dds-panel-title"><h2>IP-телефон</h2><span>SIP/WebRTC + учебный протокол</span></div>
<Suspense fallback={<p className="dds-hint">Загружаем IP-телефон…</p>}>
<SipPhone defaultExtension="6101" defaultTarget="6102" title="Реальный локальный канал ДДС" />
<SipPhone defaultExtension="6101" defaultTarget="6102" title="Реальный локальный канал ДДС"
contacts={[{ label: "старший группы", number: "6102" },
{ label: "вышестоящий начальник", number: "6103" }]} />
</Suspense>
<p className="dds-hint">На втором рабочем месте откройте <a href="/phone" target="_blank" rel="noreferrer">телефон старшего группы</a> и зарегистрируйте 6102. Пароли 6101/6102 выдаёт локальный администратор командой <code>make sip-credentials</code>.</p>
<p className="dds-hint">На втором рабочем месте откройте <a href="/phone" target="_blank" rel="noreferrer">телефон старшего группы</a> (6102); преподаватель в роли вышестоящего начальника — <a href="/phone?role=superior" target="_blank" rel="noreferrer">телефон 6103</a>. Пароли 6101–6103 выдаёт локальный администратор командой <code>make sip-credentials</code>.</p>
<h3 className="dds-protocol-title">Учебный протокол доклада</h3>
<p className="dds-hint">Связь используется при необходимости. Разговор и учебный доклад сохраняются в журнале, но не блокируют изменение статуса.</p>
<label htmlFor="dds-crew">Дежурная бригада</label>
@ -669,7 +680,7 @@ export function Dds() {
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: true }); }}>Наша</button>
<button type="button" disabled={zone !== null || !isConnected || ended}
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: false }); }}>Не наша</button></div>
<p className="dds-hint">ДДС не исправляет и не возвращает заполненную карточку: её качество контролируют оператор и служба контроля 112.</p>
<p className="dds-hint">Карточку не правят на АРМ ДДС и не возвращают в 112 при принятии. Если бригада с места доложит о расхождении с карточкой — сообщите об ошибке в 112 по телефону.</p>
<button type="button" className="dds-finish" disabled={!cardId || !isConnected || ended || advancePending}
onClick={() => {
if (reply.dirty && !send({ type: "card.reply", card_id: cardId, text: reply.text })) return;
@ -709,6 +720,7 @@ export function Dds() {
</div>
</nav>
</div>}
</fieldset>
</main>;
}

View file

@ -7,6 +7,7 @@
import { useEffect, useRef, useState } from "react";
import { Debrief } from "@/features/debrief/Debrief";
import { FindingReview } from "@/features/debrief/FindingReview";
import { Director } from "@/features/instructor/Director";
import { ScoreWeightsEditor } from "@/features/instructor/ScoreWeightsEditor";
import { ScenarioEditor } from "@/features/instructor/ScenarioEditor";
@ -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<Record<string, number>>({});
@ -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]);
@ -364,9 +372,9 @@ export function Instructor() {
<strong>{card.active ? "Сейчас в работе: " : "Ожидает: "}{card.title}</strong>
{card.address && <span>{card.address}</span>}
<small>{card.managed_service ? `${card.managed_service} · ` : ""}{SERVICE_STATUS_LABELS[card.service_status]} · {CARD_STATUS_LABELS[card.card_status]}</small>
<small>{card.timer_stopped ? `Первичная реакция за ${Math.ceil(card.elapsed_ms / 1000)} с` :
card.elapsed_ms > card.limit_ms ? `Просрочена первичная реакция · ${Math.ceil((card.elapsed_ms - card.limit_ms) / 1000)} с` :
`На первичное решение осталось ${Math.ceil((card.limit_ms - card.elapsed_ms) / 1000)} с`}</small>
<small>{card.timer_stopped ? `Открыта за ${Math.ceil(card.elapsed_ms / 1000)} с` :
card.elapsed_ms > card.limit_ms ? `Просрочено открытие · ${Math.ceil((card.elapsed_ms - card.limit_ms) / 1000)} с` :
`До открытия осталось ${Math.ceil((card.limit_ms - card.elapsed_ms) / 1000)} с`}</small>
</article>)}
{item.dds_snapshot.pending_cards_count ? <p className="ref">Ещё поступят по сценарию: {item.dds_snapshot.pending_cards_count}</p> : null}
{item.dds_snapshot.log.length > 0 && <div className="active-session-log">
@ -387,7 +395,7 @@ export function Instructor() {
<td>{item.exercise === "dds" ? `Открытых карточек: ${item.dds_open_cards}` : "—"}</td>
<td className={item.dds_overdue_cards ? "state-violated" : "state-ok"}>
{item.dds_overdue_cards || item.dds_work_overdue_cards
? `Просрочено: первичная реакция ${item.dds_overdue_cards}; отработка карточки ${item.dds_work_overdue_cards}`
? `Просрочено: открытие ${item.dds_overdue_cards}; первая запись ${item.dds_work_overdue_cards}`
: "Нормативы не нарушены"}
</td>
<td className={worstSignalSeverity(item.signals) ? `state-${worstSignalSeverity(item.signals)}` : item.presence_known ? "state-ok" : undefined}>
@ -623,9 +631,9 @@ export function Instructor() {
<summary>Настроить нормативы и критерии оценки</summary>
<p className="ref">
{exercise === "dds"
? `По умолчанию: решение за ${decisionTimeLimit} с, отработка карточки за ${ddsCardWorkTimeLimit} с; новые карточки поступают каждые ${ddsArrivalIntervalSeconds} с.`
? `По умолчанию: открытие карточки за ${decisionTimeLimit} с, первая запись за ${ddsCardWorkTimeLimit} с; новые карточки поступают каждые ${ddsArrivalIntervalSeconds} с.`
: exercise === "card_dds"
? `По умолчанию: заполнение КИО за ${cardFillTimeLimit} с, отработка карточки ДДС за ${ddsCardWorkTimeLimit} с.`
? `По умолчанию: заполнение КИО за ${cardFillTimeLimit} с, первая запись ДДС за ${ddsCardWorkTimeLimit} с.`
: exercise === "card"
? `По умолчанию: заполнение КИО за ${cardFillTimeLimit} с.`
: "Для звонка действуют нормативы приёма вызова по ГОСТ."}
@ -644,8 +652,8 @@ export function Instructor() {
</label>
</>}
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
Решение ДДС, секунд
<LessonNumberInput label="Лимит решения ДДС, секунд" value={decisionTimeLimit}
Открытие карточки ДДС, секунд
<LessonNumberInput label="Лимит открытия карточки ДДС, секунд" value={decisionTimeLimit}
min={5} max={300} fallback={30} onCommit={setDecisionTimeLimit} />
</label>}
{(exercise === "card" || exercise === "card_dds") && <label className="lesson-criterion">
@ -655,12 +663,26 @@ export function Instructor() {
<small className="ref">По умолчанию 180 секунд; в отчёте будут факт и отклонение от норматива.</small>
</label>}
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
Норматив отработки карточки ДДС, секунд
<LessonNumberInput label="Норматив отработки карточки ДДС, секунд"
Первая запись по карточке ДДС, секунд
<LessonNumberInput label="Норматив первой записи по карточке ДДС, секунд"
value={ddsCardWorkTimeLimit} min={30} max={1800} fallback={180}
onCommit={setDdsCardWorkTimeLimit} />
<small className="ref">По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.</small>
<small className="ref">По умолчанию 180 секунд от открытия до первого статуса с текстом; дальше работы по времени не нормируются.</small>
</label>}
{(exercise === "dds" || exercise === "card_dds") && <>
<label className="mode-choice">
<input type="checkbox" checked={ddsReportReactionEnabled}
onChange={(event) => setDdsReportReactionEnabled(event.target.checked)} />
Оценивать срок отметки статуса после доклада бригады
</label>
{ddsReportReactionEnabled && <label className="lesson-criterion">
Лимит реакции на доклад бригады, секунд
<LessonNumberInput label="Лимит реакции на доклад бригады, секунд"
value={ddsReportReactionLimit} min={5} max={600} fallback={45}
onCommit={setDdsReportReactionLimit} />
<small className="ref">Учебный лимит при включённом критерии; исходное значение — 45 секунд.</small>
</label>}
</>}
<label className="lesson-criterion">
Допустимо нарушенных метрик
<LessonNumberInput label="Допустимое число ошибок" value={allowedErrors}
@ -672,6 +694,7 @@ export function Instructor() {
Грамматика карточки оператора 112 входит в оценку
</label>}
<ScoreWeightsEditor exercise={exercise === "card_dds" ? "card_dds" : exercise}
reportReactionEnabled={(exercise === "dds" || exercise === "card_dds") && ddsReportReactionEnabled}
values={lessonScoreWeights} onChange={setLessonScoreWeights} />
<small className="ref">Нормативы ГОСТ для приёма вызова не изменяются.</small>
</details>
@ -708,12 +731,20 @@ export function Instructor() {
{sessionId && session.title && (
<p>
{session.ended ? "занятие завершено" : (
<button type="button" onClick={control.stop}>Завершить занятие</button>
<>
<button type="button" onClick={control.stop}>Завершить занятие</button>{" "}
{session.paused ? (
<button type="button" onClick={control.resume}>Возобновить</button>
) : (
<button type="button" onClick={control.pause}>Пауза</button>
)}
{session.paused && <small className="ref" role="status">Занятие на паузе — время не идёт</small>}
</>
)}
</p>
)}
{sessionId && session.title && !session.ended && (
exercise === "call" && <Director onInject={control.inject} disabled={control.status !== "open"} error={session.error} />
exercise === "call" && <Director onInject={control.inject} disabled={control.status !== "open" || session.paused} error={session.error} />
)}
{links && startPendingId && (
<p className="ref" role="status">Создаём занятие. Ссылки появятся автоматически после подключения.</p>
@ -814,6 +845,8 @@ export function Instructor() {
{session.report && <>
<Debrief report={overrideReport?.session_id === session.report.session_id
? overrideReport : session.report} />
<FindingReview report={overrideReport?.session_id === session.report.session_id
? overrideReport : session.report} onChange={setOverrideReport} />
<section className="debrief debrief-score-override">
<h2>Ручная корректировка оценки</h2>
<p className="ref">Автоматическая оценка: {session.report.score_auto.toFixed(1)} из 100. Меняйте её только при необходимости: причина сохранится в журнале вместе с вашим логином.</p>

View file

@ -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 <main className="page phone-page">
<h1>IP-телефон старшего группы</h1>
<p>Откройте этот экран на втором рабочем месте, зарегистрируйте номер 6102 и примите вызов ДДС 6101.</p>
<SipPhone defaultExtension="6102" defaultTarget="6101" title="Телефон старшего группы" />
<h1>{heading}</h1>
<p>Откройте этот экран на втором рабочем месте, зарегистрируйте номер {extension} и примите вызов ДДС 6101.</p>
<SipPhone key={role} defaultExtension={extension} defaultTarget="6101" title={title} />
<p className="ref">{role === "crew"
? <>Роль вышестоящего начальника — <a href="/phone?role=superior">телефон 6103</a>.</>
: <>Роль старшего группы — <a href="/phone">телефон 6102</a>.</>}</p>
</main>;
}

View file

@ -122,15 +122,16 @@ export function Call() {
event.preventDefault();
if (call.sendText(textTurn)) setTextTurn("");
}}>
<input aria-label="Ваша реплика заявителю" value={textTurn} maxLength={1000}
<input aria-label="Ваша реплика заявителю" value={textTurn} maxLength={1000} disabled={call.paused}
placeholder="Ваша реплика…" onChange={(event) => setTextTurn(event.target.value)} />
<button type="submit" disabled={call.status !== "open" || !textTurn.trim() || call.cardSubmitted}>Отправить</button>
<button type="submit" disabled={call.paused || call.status !== "open" || !textTurn.trim() || call.cardSubmitted}>Отправить</button>
</form>
{call.paused && <p role="status" className="state-warn">Пауза, ждите преподавателя</p>}
{call.error && <p role="alert" className="state-violated">{call.error}</p>}
</section>
<div className="arm-card-wrap">
<KioCard state={call.card} required={call.briefing.required_fields}
readOnly={call.cardSubmitted} referenceArm onChange={call.patchKio} />
readOnly={call.paused || call.cardSubmitted} referenceArm onChange={call.patchKio} />
</div>
</div>
<section className="arm-support" aria-label="Норматив заполнения карточки">
@ -142,7 +143,7 @@ export function Call() {
? notifiedServices.map((service) => <b key={service}>{service}</b>)
: <em>появятся после выбора признаков</em>}
</span>
{!call.cardSubmitted && <button type="button" className="arm-primary" disabled={call.status !== "open"}
{!call.cardSubmitted && <button type="button" className="arm-primary" disabled={call.paused || call.status !== "open"}
onClick={call.submitCard}>Сохранить и проверить</button>}
{call.cardSubmitted && <strong>{call.briefing.handoff_to_dds ? "Карточка передана ДДС" : "Карточка отправлена"}</strong>}
</footer>
@ -174,11 +175,11 @@ export function Call() {
</header>
<div className="arm-call-actions">
{(call.phase === "incoming" || call.phase === "talking") && !call.micOn && <button type="button" className="arm-answer" onClick={() => void call.answer()}>
{(call.phase === "incoming" || call.phase === "talking") && !call.micOn && <button type="button" className="arm-answer" disabled={call.paused} onClick={() => void call.answer()}>
{call.phase === "talking" ? "☎ Продолжить разговор" : "☎ Ответить на вызов"}
</button>}
{call.micOn && <span className="arm-live">● Разговор идёт</span>}
{call.micOn && hintsAllowed(call.incoming?.mode) && <button type="button" onClick={call.hint}>Подсказка</button>}
{call.micOn && hintsAllowed(call.incoming?.mode) && <button type="button" disabled={call.paused} onClick={call.hint}>Подсказка</button>}
<span className="arm-call-actions-note">{call.phase === "ended"
? "Вызов завершён"
: !call.micOn && call.phase === "incoming"
@ -190,13 +191,14 @@ export function Call() {
: "Ожидайте входящего вызова"}</span>
</div>
{call.paused && <p className="state-warn arm-message" role="status">Пауза, ждите преподавателя</p>}
{call.error && <p className="violated arm-message" role="alert">{call.error}</p>}
<div className="arm-card-wrap">
<KioCard
state={call.card}
required={call.incoming?.required_fields ?? []}
readOnly={!call.micOn}
readOnly={call.paused || !call.micOn}
referenceArm
onChange={call.patchKio}
/>
@ -207,12 +209,12 @@ export function Call() {
<button
type="button"
className="arm-primary"
disabled={!call.micOn || (!service && !autoRoute)}
disabled={call.paused || !call.micOn || (!service && !autoRoute)}
onClick={() => call.dispatch(service)}
>
Передать в ДДС
</button>
{call.micOn && <button type="button" onClick={call.hangup}>Завершить вызов</button>}
{call.micOn && <button type="button" disabled={call.paused} onClick={call.hangup}>Завершить вызов</button>}
</div>
</footer>
<div className="arm-secondary">
@ -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() {
<p>
<button
type="button"
disabled={!call.micOn || resolved !== null}
disabled={call.paused || !call.micOn || resolved !== null}
onClick={() => {
call.resolve("consultation", resolveComment);
setResolved("consultation");
@ -242,7 +245,7 @@ export function Call() {
</button>{" "}
<button
type="button"
disabled={!call.micOn || resolved !== null}
disabled={call.paused || !call.micOn || resolved !== null}
onClick={() => {
call.resolve("transfer_region", resolveComment);
setResolved("transfer_region");

View file

@ -177,6 +177,7 @@ export interface DdsCardReport {
card_id: string;
scenario_id: string;
score_auto: number;
score_reviewed?: number | null;
reply_text: string;
metrics: Array<Metric>;
findings: Array<Finding>;
@ -267,6 +268,23 @@ export interface Finding {
competency?: Competency | null;
transcript_ref?: string | null;
at?: string | null;
metric_key?: string | null;
card?: number | null;
service?: string | null;
author?: string | null;
client_id?: string | null;
reviews?: Array<FindingReview>;
}
/** Решение преподавателя по отметке на разборе. */
export type FindingDecision = "confirmed" | "dismissed";
/** Кто, когда и почему подтвердил или снял отметку. Сама отметка остаётся. */
export interface FindingReview {
decision: FindingDecision;
reason: string;
author: string;
at: string;
}
/** Источник объяснимой отметки; `judge` оставлен для старых отчётов. */
@ -342,6 +360,7 @@ export interface KIO {
description?: string | null;
victims_count?: number | null;
is_emergency?: boolean;
offence_signs?: boolean | null;
life_threat?: boolean;
evacuation_needed?: boolean;
/** Список оповещения */
@ -382,6 +401,8 @@ export interface LessonCriteria {
decision_time_limit_seconds?: number;
card_fill_time_limit_seconds?: number;
dds_card_work_time_limit_seconds?: number;
dds_report_reaction_enabled?: boolean;
dds_report_reaction_limit_seconds?: number;
allowed_errors?: number;
require_correct_grammar?: boolean;
score_weights?: Record<string, number>;
@ -409,6 +430,10 @@ export interface Metric {
passed: boolean;
weight?: number;
credit?: number | null;
card?: number | null;
service?: string | null;
passed_auto?: boolean | null;
credit_auto?: number | null;
}
export interface ModeSet {
@ -587,6 +612,17 @@ export interface SessionEnded {
/** Режим сессии. Меняет доступность подсказок и протоколирование, но не поведение звонящего (docs/product/MODES.md). */
export type SessionMode = "training" | "exam" | "self";
/** Остановить время занятия; карточку курсанта команда не меняет (lct-39). */
export interface SessionPause {
type: "session.pause";
}
/** Пульт остановил или возобновил время занятия (lct-39). */
export interface SessionPaused {
type: "session.paused";
paused: boolean;
}
/** Единица истории: из отчётов складываются профиль, дельта попыток, аналитика. */
export interface SessionReport {
session_id: string;
@ -610,9 +646,15 @@ export interface SessionReport {
self_assessment_diff?: SelfAssessmentDiff | null;
notes?: Array<InstructorNoteShown>;
score_auto: number;
score_reviewed?: number | null;
score_final: number;
overridden_by?: string | null;
override_comment?: string | null;
total_paused_ms?: number;
}
export interface SessionResume {
type: "session.resume";
}
/** Обязательно при подключении: монитор в классе включают посреди занятия. */
@ -633,6 +675,7 @@ export interface SessionSnapshot {
timers: Array<TimerSnapshot>;
hints_used?: number;
ended?: boolean;
paused?: boolean;
}
export interface SessionStop {
@ -669,6 +712,7 @@ export interface StationSnapshot {
pending_cards_count?: number;
next_arrival_in_seconds?: number | null;
max_waiting_cards?: number;
paused?: boolean;
}
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
@ -777,15 +821,15 @@ export interface ZoneDecision {
// Союзы по каналам — смотри docs/arch/CONTRACT.md#каналы
export type ServerToTrainee = CallIncoming | CardBriefing | TextTurnAccepted | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent;
export type ServerToTrainee = CallIncoming | CardBriefing | TextTurnAccepted | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | SessionPaused | ErrorEvent;
export type TraineeToServer = CallAnswer | CardSubmit | TextTurn | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | ErrorEvent;
export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | SessionPaused | ErrorEvent;
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | SessionStop;
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | SessionStop | SessionPause | SessionResume;
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | ErrorEvent;
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | SessionPaused | ErrorEvent;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
@ -837,6 +881,9 @@ export type EventName =
| "score.ready"
| "self_assessment.submit"
| "session.ended"
| "session.pause"
| "session.paused"
| "session.resume"
| "session.snapshot"
| "session.stop"
| "station.finish"

View file

@ -197,6 +197,9 @@ button:disabled { opacity: 0.5; cursor: default; }
.debrief-stat > strong { font-size: 24px; line-height: 1.15; overflow-wrap: anywhere; }
.debrief-stat > strong small { font-size: 14px; font-weight: 500; }
.debrief-stat > small { color: #5a6068; font-size: 12px; }
.finding-review-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.finding-review-actions input { flex: 1 1 200px; min-width: 0; }
.finding-review-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; align-items: end; }
.debrief-stat.is-passed { border-left-color: var(--ok); }
.debrief-stat.is-failed { border-left-color: var(--violated); }
.debrief-next-step { margin: 12px 0 18px; padding: 12px 15px; border: 1px solid var(--line); border-left: 4px solid #6b7780; border-radius: 5px; background: #fff; }
@ -316,6 +319,7 @@ button:disabled { opacity: 0.5; cursor: default; }
/* АРМ ДДС: реестр остаётся плотной таблицей, карточка и связь — рядом. */
.dds-workspace { max-width: 1800px; margin: 0 auto; }
.dds-pause-controls { display: contents; border: 0; margin: 0; padding: 0; min-width: 0; }
.dds-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 8px 0 16px; }
.dds-header h1 { margin: 2px 0 0; }
.dds-header h1 span { color: #576273; font-size: 15px; font-weight: 400; }
@ -349,6 +353,7 @@ button:disabled { opacity: 0.5; cursor: default; }
.dds-status-action button { padding: 4px 8px; }
.dds-alert { padding: 10px 12px; margin: 0 0 12px; color: var(--violated); background: #fde8e8; border: 1px solid var(--violated); }
.dds-done { color: var(--ok); background: #e7f4e9; border-color: var(--ok); }
.dds-paused { color: var(--warn); background: #fdf3d8; border-color: var(--warn); }
.dds-phone > label { display: block; margin: 14px 12px 5px; font-size: 13px; font-weight: 600; }
.dds-phone > select { display: block; width: calc(100% - 24px); margin: 0 12px; padding: 7px; box-sizing: border-box; }
.dds-dial { display: block; margin: 12px; width: calc(100% - 24px); text-align: center; background: #23577e; border-color: #23577e; color: white; font-weight: 600; }

View file

@ -72,7 +72,7 @@ def login(base: str, login_name: str, password: str) -> str:
return cookie
async def send_control(ws_base: str, session_id: UUID, cookie: str, payload: dict) -> None:
async def send_control(ws_base: str, session_id: UUID, cookie: str, payload: dict) -> float:
async with websockets.connect(
f"{ws_base}/ws/control/{session_id}",
additional_headers={"Cookie": cookie},
@ -81,7 +81,9 @@ async def send_control(ws_base: str, session_id: UUID, cookie: str, payload: dic
ping_interval=None,
) as socket:
await socket.send(json.dumps(payload, ensure_ascii=False))
sent_at = time.monotonic()
await asyncio.sleep(0.15)
return sent_at
async def probe_ws(ws_base: str, path: str, cookie: str) -> dict:
@ -238,7 +240,7 @@ async def main(args: argparse.Namespace) -> int:
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.api.auth import hash_password
from app.api.auth import hash_password, login_log_marker
from app.db.models import AuditLog, Session, Trainee, User
database_url = (
@ -251,7 +253,7 @@ async def main(args: argparse.Namespace) -> int:
factory = async_sessionmaker(engine, expire_on_commit=False)
observer_engine = create_async_engine(database_url, poolclass=NullPool)
run_id = uuid4().hex[:12]
session_id = uuid4()
session_id = args.session_id or uuid4()
trainee = Trainee(name=f"Failover smoke {run_id}")
instructor_login = f"fo-i-{run_id}"
trainee_login = f"fo-t-{run_id}"
@ -263,6 +265,7 @@ async def main(args: argparse.Namespace) -> int:
fault_stopped = False
result: dict = {"session_id": str(session_id), "checks": {}}
backend = f"http://127.0.0.1:{args.backend_port}"
result["login_node"] = "backend-a"
ws_base = args.frontend_url.replace("https://", "wss://").replace("http://", "ws://")
instructor_cookie = trainee_cookie = None
try:
@ -278,21 +281,32 @@ async def main(args: argparse.Namespace) -> int:
trainee_id=trainee.id, blocked=False),
])
await db.commit()
users_committed_at = time.monotonic()
instructor_cookie = login(backend, instructor_login, instructor_password)
trainee_cookie = login(backend, trainee_login, trainee_password)
# Учётки созданы прямо в БД, вход — на узле A, а сокет занятия Nginx
# может отдать узлу B. B узнаёт новый логин при очередной сверке
# поколений (раз в секунду); до неё middleware считает cookie отозванной.
for port in (args.backend_port, args.backend_b_port):
for cookie in (instructor_cookie, trainee_cookie):
if not await wait_auth_fresh(f"http://127.0.0.1:{port}", cookie):
raise RuntimeError(f"backend :{port} не узнал новый логин за 10 с")
await send_control(ws_base, session_id, instructor_cookie, {
# может отдать узлу B до его сверки поколений. Ждать сверку не нужно:
# B проверяет неизвестный логин разовым SELECT (lct-42). Ожидание здесь
# лишило бы доказательности проверку ниже: опрос B сам вызвал бы разовую
# проверку, а за секунду B узнал бы логин сверкой.
control_sent_at = await send_control(ws_base, session_id, instructor_cookie, {
"type": "scenario.start", "scenario_id": args.scenario,
"trainee": trainee.name, "trainee_id": str(trainee.id),
"mode": "training", "exercise": "dds",
})
result["users_commit_to_control_seconds"] = round(control_sent_at - users_committed_at, 4)
if args.expect_initial_owner == "backend-b":
result["checks"]["control_sent_within_first_second"] = (
result["users_commit_to_control_seconds"] < 1
)
# Сверка B идёт раз в секунду в случайной фазе, и время само по себе
# не доказывает, что B не знал логин. Доказательство — запись B о
# разовой проверке именно этого логина.
result["checks"]["backend_b_resolved_login_one_shot"] = (
login_log_marker(instructor_login)
in compose(args, "logs", "--no-color", "backend-b")
)
async def session_row():
async with factory() as db:
@ -327,6 +341,12 @@ async def main(args: argparse.Namespace) -> int:
old_epoch = row.backend_fencing_epoch
result["initial_owner"] = row.backend_node_id
result["initial_epoch"] = old_epoch
if args.expect_initial_owner and row.backend_node_id != args.expect_initial_owner:
raise RuntimeError(
f"session routed to {row.backend_node_id}, expected {args.expect_initial_owner}"
)
if args.expect_initial_owner == "backend-b":
result["checks"]["login_a_initial_control_b"] = True
# Commit real DDS work before the fault. These actions must survive the
# checkpoint handoff and remain part of the final scored report.
@ -735,4 +755,6 @@ if __name__ == "__main__":
parser.add_argument("--reconnect-attempts", type=int, default=5)
parser.add_argument("--rest-timeout", type=float, default=60)
parser.add_argument("--failure-mode", choices=("kill", "partition"), default="kill")
parser.add_argument("--expect-initial-owner", choices=("backend-a", "backend-b"))
parser.add_argument("--session-id", type=UUID)
raise SystemExit(asyncio.run(main(parser.parse_args())))

View file

@ -10,6 +10,7 @@ override_6002=${SIP_6002_PASSWORD:-}
override_6003=${SIP_6003_PASSWORD:-}
override_6101=${SIP_6101_PASSWORD:-}
override_6102=${SIP_6102_PASSWORD:-}
override_6103=${SIP_6103_PASSWORD:-}
if [ -s "$credentials" ]; then
# Файл содержит только сгенерированные hex-значения без shell-конструкций.
. "$credentials"
@ -19,6 +20,7 @@ SIP_6002_PASSWORD=${override_6002:-${SIP_6002_PASSWORD:-$(openssl rand -hex 16)}
SIP_6003_PASSWORD=${override_6003:-${SIP_6003_PASSWORD:-$(openssl rand -hex 16)}}
SIP_6101_PASSWORD=${override_6101:-${SIP_6101_PASSWORD:-$(openssl rand -hex 16)}}
SIP_6102_PASSWORD=${override_6102:-${SIP_6102_PASSWORD:-$(openssl rand -hex 16)}}
SIP_6103_PASSWORD=${override_6103:-${SIP_6103_PASSWORD:-$(openssl rand -hex 16)}}
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}
@ -36,12 +38,13 @@ SIP_6002_PASSWORD=$SIP_6002_PASSWORD
SIP_6003_PASSWORD=$SIP_6003_PASSWORD
SIP_6101_PASSWORD=$SIP_6101_PASSWORD
SIP_6102_PASSWORD=$SIP_6102_PASSWORD
SIP_6103_PASSWORD=$SIP_6103_PASSWORD
EOF
chmod 600 "$credentials"
export SIP_6001_PASSWORD SIP_6002_PASSWORD SIP_6003_PASSWORD SIP_6101_PASSWORD SIP_6102_PASSWORD SIP_EXTERNAL_MEDIA_ADDRESS
export SIP_6001_PASSWORD SIP_6002_PASSWORD SIP_6003_PASSWORD SIP_6101_PASSWORD SIP_6102_PASSWORD SIP_6103_PASSWORD SIP_EXTERNAL_MEDIA_ADDRESS
export SIP_RTP_START SIP_RTP_END
envsubst '${SIP_6001_PASSWORD} ${SIP_6002_PASSWORD} ${SIP_6003_PASSWORD} ${SIP_6101_PASSWORD} ${SIP_6102_PASSWORD} ${SIP_EXTERNAL_MEDIA_ADDRESS}' \
envsubst '${SIP_6001_PASSWORD} ${SIP_6002_PASSWORD} ${SIP_6003_PASSWORD} ${SIP_6101_PASSWORD} ${SIP_6102_PASSWORD} ${SIP_6103_PASSWORD} ${SIP_EXTERNAL_MEDIA_ADDRESS}' \
< /opt/lct-sip/pjsip.conf.template > /etc/asterisk/pjsip.conf
chmod 640 /etc/asterisk/pjsip.conf
chown root:asterisk /etc/asterisk/pjsip.conf

View file

@ -12,9 +12,10 @@ exten => _600[1-3],1,NoOp(LCT training call ${CALLERID(num)} -> ${EXTEN})
same => n,StopMixMonitor()
same => n,Hangup()
; 6101/6102 — браузерные WebRTC-абоненты. Тот же учебный контекст и запись,
; но отдельные endpoints не ломают обычные UDP/TCP-софтфоны 6001–6003.
exten => _610[1-2],1,NoOp(LCT WebRTC training call ${CALLERID(num)} -> ${EXTEN})
; 6101–6103 — браузерные WebRTC-абоненты: ДДС, старший группы, вышестоящий
; начальник. Тот же учебный контекст и запись, но отдельные endpoints не ломают
; обычные UDP/TCP-софтфоны 6001–6003.
exten => _610[1-3],1,NoOp(LCT WebRTC training call ${CALLERID(num)} -> ${EXTEN})
same => n,Set(RECORDING_FILE=${STRFTIME(${EPOCH},UTC,%Y%m%d-%H%M%S)}-${CALLERID(num)}-${EXTEN}-${UNIQUEID})
same => n,MixMonitor(/recordings/${RECORDING_FILE}.wav,b)
same => n,Dial(PJSIP/${EXTEN},30)

View file

@ -157,3 +157,22 @@ type=aor
max_contacts=2
remove_existing=yes
qualify_frequency=0
; Вышестоящий начальник ДДС: роль играет преподаватель со страницы
; /phone?role=superior. Обязательный голосовой маршрут по ответу заказчика (П.7).
[6103](webrtc-endpoint)
auth=6103-auth
aors=6103
callerid=Веб вышестоящий начальник <6103>
[6103-auth]
type=auth
auth_type=userpass
username=6103
password=${SIP_6103_PASSWORD}
[6103]
type=aor
max_contacts=2
remove_existing=yes
qualify_frequency=0