diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py index 74a7f5d..640bf29 100644 --- a/backend/app/api/http/sessions.py +++ b/backend/app/api/http/sessions.py @@ -228,11 +228,13 @@ async def active( if state.started_at else 0) station = state.station_snapshot() if state.exercise is Exercise.DDS else None queue = station.queue_cards if station else [] + card = state.desk.active + status_log = card.status_log if card is not None else [] managed_services = state.managed_services() latest_statuses = { - service: SERVICE_STATUS_LABELS[current(state.status_log, service)] + service: SERVICE_STATUS_LABELS[current(status_log, service)] for service in managed_services - if (state.status_log or state.exercise is Exercise.DDS) + if (status_log or state.exercise is Exercise.DDS) } result.append(ActiveSessionOut( session_id=state.session_id, @@ -243,7 +245,7 @@ async def active( exercise=state.exercise, started_at=state.started_at, elapsed_seconds=elapsed, - dds_card_total=len(state.dds_scenarios), + dds_card_total=len(state.desk.scenarios), dds_open_cards=len(queue), dds_overdue_cards=sum( not card.timer_stopped and card.elapsed_ms > card.limit_ms for card in queue @@ -253,7 +255,7 @@ async def active( and timer.started_at is not None and not timer.stopped and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK] - for card in state.dds_live_cards + for card in state.desk.cards.values() ), dds_statuses=latest_statuses, dds_snapshot=station, diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py index 121a28b..f3e3fbf 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -244,8 +244,8 @@ async def _handle(session_id: UUID, state, event) -> None: prepare_handoff_queue( state, state.pending_dds_scenarios, - arrival_interval_seconds=state.dds_arrival_interval_seconds, - max_waiting=state.dds_max_waiting, + arrival_interval_seconds=state.desk.arrival_interval_seconds, + max_waiting=state.desk.max_waiting, ) state.pending_dds_scenarios = [] else: diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py index 5c073a8..9cad775 100644 --- a/backend/app/api/ws/control.py +++ b/backend/app/api/ws/control.py @@ -272,14 +272,13 @@ async def _start(session_id: UUID, event, who=None) -> None: use_pregenerated=scenario.tree.pregenerated, ) elif event.exercise is Exercise.DDS: - state.dds_scenarios = [item.model_copy(deep=True) for item in scenarios] prepare_queue( state, - state.dds_scenarios, + [item.model_copy(deep=True) for item in scenarios], arrival_interval_seconds=event.dds_arrival_interval_seconds, max_waiting=event.dds_max_waiting, ) - state.started_at = state.dispatched_at + state.started_at = state.desk.active.dispatched_at if state.desk.active else None else: state.started_at = now_utc() if event.exercise is Exercise.CARD: @@ -293,8 +292,8 @@ async def _start(session_id: UUID, event, who=None) -> None: state.on_event("card.start") if event.handoff_to_dds: state.pending_dds_scenarios = [item.model_copy(deep=True) for item in scenarios[1:]] - state.dds_arrival_interval_seconds = event.dds_arrival_interval_seconds - state.dds_max_waiting = event.dds_max_waiting + state.desk.arrival_interval_seconds = event.dds_arrival_interval_seconds + state.desk.max_waiting = event.dds_max_waiting hub.register(state) if event.exercise is not Exercise.CALL and hub.journal and state.started_at is not None: await hub.journal.session_started(session_id, state.started_at) diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py index 3a843f0..65b1287 100644 --- a/backend/app/api/ws/station.py +++ b/backend/app/api/ws/station.py @@ -43,8 +43,8 @@ from app.domain.statuses import ( ) from app.domain.timers import TimerCode from app.scoring.address import address_matches -from app.session.dds import deliver_due_cards -from app.session.finish import finish, score_current_dds +from app.session.dds import DdsLiveCard +from app.session.finish import finish, score_dds_card from app.session.hub import LEASE_FENCED_MESSAGE, hub from app.session.state import now_utc @@ -54,11 +54,15 @@ router = APIRouter() _adapter = TypeAdapter(StationToServer) -def _start_dds_work_timer(state) -> None: +def _start_dds_work_timer(card: DdsLiveCard) -> None: """Start the three-minute work clock once, when the card is opened.""" - timer = state.timers.timers.get(TimerCode.DDS_WORK) + timer = card.timers.timers.get(TimerCode.DDS_WORK) if timer is None or timer.started_at is None: - state.on_event("dds.open") + card.on_event("dds.open") + + +#: Команды, которым не нужна активная карточка пульта. +CARDLESS_EVENTS = {"card.open", "station.finish", "card.bounce"} REPORT_PHASES = ("dispatched", "arrived", "working", "completed") REQUIRED_STATUS = { @@ -78,14 +82,14 @@ def _error(session_id: UUID, message: str) -> None: hub.to_station(session_id, ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=message)) -def _line(session_id: UUID, state, speaker: str, text: str) -> None: - call = state.phone_pending +def _line(session_id: UUID, card: DdsLiveCard, speaker: str, text: str) -> None: + call = card.phone_pending if call is None: return line = PhoneLineRecord(service=call.service, crew=call.crew, speaker=speaker, text=text, at=now_utc()) - state.phone_lines.append(line) - state.dds_log.append((f"phone.line.{speaker}", line.at, f"{call.crew}: {text}")) + card.phone_lines.append(line) + card.dds_log.append((f"phone.line.{speaker}", line.at, f"{call.crew}: {text}")) hub.to_station(session_id, PhoneLine(**line.model_dump())) @@ -94,11 +98,11 @@ def _address_matches(expected: str | None, supplied: str) -> bool: return address_matches(expected, supplied) -def _incident_matches(state, supplied: str) -> bool: +def _incident_matches(card: DdsLiveCard, supplied: str) -> bool: text = supplied.casefold() # Описание часто начинается с адреса: его нельзя считать совпадением # характера происшествия. Берём только название сценария и признаки ЕКП. - source = " ".join((state.scenario_title, " ".join(state.kio.signs))) + source = " ".join((card.scenario.title, " ".join(card.kio.signs))) anchors = {word[:4] for word in re.findall(r"[а-яё]{5,}", source.casefold())} return len(supplied.strip()) >= 8 and any(anchor in text for anchor in anchors) @@ -120,24 +124,23 @@ def _report_text(phase: str, crew: str, address: str) -> str: return f"{crew}: работы завершены. Дальнейшая помощь от нашей бригады не требуется." -def _finish_phone_call(session_id: UUID, state) -> None: - call = state.phone_pending +def _finish_phone_call(session_id: UUID, card: DdsLiveCard) -> None: + call = card.phone_pending assert call is not None - text = _report_text(call.phase, call.crew, state.dispatched_card.address or "из карточки") - _line(session_id, state, "crew", text) + text = _report_text(call.phase, call.crew, card.dispatched_card.address or "из карточки") + _line(session_id, card, "crew", text) report = PhoneReportRecord(service=call.service, crew=call.crew, phase=call.phase, text=text, at=now_utc()) - state.phone_reports.append(report) - state.dds_log.append(("phone.report", now_utc(), f"{call.crew}: {call.phase}")) - state.phone_pending = None + card.phone_reports.append(report) + card.dds_log.append(("phone.report", now_utc(), f"{call.crew}: {call.phase}")) + card.phone_pending = None hub.to_station(session_id, PhoneReport(**report.model_dump())) async def _finish_dds(session_id: UUID, state) -> None: state.ended_at = now_utc() state.end_reason = CallEndReason.COMPLETE - state.capture_active_dds() - for card in state.dds_live_cards: + for card in state.desk.cards.values(): timer = card.timers.timers.get(TimerCode.DDS_WORK) if timer is not None and timer.started_at is not None: card.timers.on_event("dds.finish") @@ -154,6 +157,10 @@ async def _handle(session_id: UUID, state, event) -> None: if state.ended: _error(session_id, "Занятие уже завершено") return + card = state.desk.active + if card is None and event.type not in CARDLESS_EVENTS: + _error(session_id, "Нет активной карточки") + return match event.type: case "card.ack": # Подтверждение приёма — это статус «Принята» у главной службы. @@ -162,26 +169,26 @@ async def _handle(session_id: UUID, state, event) -> None: if not event.comment.strip(): _error(session_id, "Для подтверждения приёма добавьте комментарий с основанием") return - if any(action == "card.ack" for action, _at, _detail in state.dds_log): + if any(action == "card.ack" for action, _at, _detail in card.dds_log): return - state.on_event("card.ack") - state.dds_log.append(("card.ack", now_utc(), None)) - services = state.managed_services() + card.on_event("card.ack") + card.dds_log.append(("card.ack", now_utc(), None)) + services = state.card_services(card) if services: - _start_dds_work_timer(state) + _start_dds_work_timer(card) try: - state.set_service_status( + card.set_service_status( services[0], ServiceStatus.ACCEPTED, event.comment, author="диспетчер" ) except StatusError: pass # статус уже стоит: повторное нажатие ничего не меняет case "card.status": - if event.service not in state.managed_services(): + if event.service not in state.card_services(card): _error(session_id, "Можно менять статусы только своей ДДС") return - _start_dds_work_timer(state) + _start_dds_work_timer(card) try: - state.set_service_status( + card.set_service_status( event.service, event.status, event.comment, author="диспетчер" ) except StatusError as exc: @@ -192,75 +199,75 @@ async def _handle(session_id: UUID, state, event) -> None: return # Первичный статус останавливает норматив 30 секунд. if event.status in PRIMARY: - state.on_event("card.ack") + card.on_event("card.ack") if event.status in { ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED, }: - state.on_event("dds.complete") + card.on_event("dds.complete") case "crew.select": - if event.crew not in state.crew_options(): + if event.crew not in state.crew_options(card): _error(session_id, "Выберите бригаду из списка доступных") return - service = state.crew_service(event.crew) - assigned = state.crew_assignments.get(service) + service = state.crew_service(card, event.crew) + assigned = card.crew_assignments.get(service) if assigned and assigned != event.crew and any( - report.service == service for report in state.phone_reports + report.service == service for report in card.phone_reports ): _error(session_id, "После первого доклада бригаду этой службы менять нельзя") return - if state.phone_pending is not None: + if card.phone_pending is not None: _error(session_id, "Завершите текущий разговор перед сменой бригады") return - if state.crew_selected == event.crew and assigned == event.crew: + if card.crew_selected == event.crew and assigned == event.crew: return - state.crew_selected = event.crew - state.crew_assignments[service] = event.crew - state.dds_log.append(("crew.select", now_utc(), event.crew)) + card.crew_selected = event.crew + card.crew_assignments[service] = event.crew + card.dds_log.append(("crew.select", now_utc(), event.crew)) case "phone.dial": - if state.phone_pending is not None: + if card.phone_pending is not None: _error(session_id, "Разговор уже идёт: передайте сведения или завершите звонок") return - crew = state.crew_selected - service = state.crew_service(crew) if crew else None + crew = card.crew_selected + service = state.crew_service(card, crew) if crew else None if service is None: _error(session_id, "Сначала выберите бригаду") return - previous = [report for report in state.phone_reports if report.service == service] + previous = [report for report in card.phone_reports if report.service == service] if len(previous) >= len(REPORT_PHASES): _error(session_id, "Все доклады этой бригады уже получены") return phase = REPORT_PHASES[len(previous)] required = REQUIRED_STATUS[phase] - if current(state.status_log, service) not in STATUS_AT_OR_AFTER[phase]: + if current(card.status_log, service) not in STATUS_AT_OR_AFTER[phase]: _error(session_id, f"Перед звонком нужен статус «{SERVICE_STATUS_LABELS[required]}» этой службы") return - state.phone_pending = PhoneCallPending(service=service, crew=crew, phase=phase) - state.dds_log.append(("phone.dial", now_utc(), crew)) + card.phone_pending = PhoneCallPending(service=service, crew=crew, phase=phase) + card.dds_log.append(("phone.dial", now_utc(), crew)) greeting = (f"{crew}, старший группы на связи. Назовите адрес, характер происшествия " "и что требуется от бригады." if phase == "dispatched" else f"{crew}, старший группы на связи. Слушаю ваш запрос по карточке.") - _line(session_id, state, "crew", greeting) + _line(session_id, card, "crew", greeting) case "phone.brief": - call = state.phone_pending + call = card.phone_pending if call is None or call.phase != "dispatched": _error(session_id, "Сначала соединитесь со старшим группы для передачи вызова") return - if not _address_matches(state.dispatched_card.address, event.address): + if not _address_matches(card.dispatched_card.address, event.address): _error(session_id, "Проверьте адрес: улица и номер дома должны совпадать с карточкой") return - if not _incident_matches(state, event.incident): + if not _incident_matches(card, event.incident): _error(session_id, "Уточните характер происшествия по данным карточки") return if not _has_purpose(event.request, ("выезд", "выех", "направ", "прибыт", "реагир", "помощ", "подтверд", "долож")): _error(session_id, "Сформулируйте задачу: выезд, помощь или доклад бригады") return - _line(session_id, state, "dispatcher", f"Адрес: {event.address.strip()}. " + _line(session_id, card, "dispatcher", f"Адрес: {event.address.strip()}. " f"Происшествие: {event.incident.strip()}. {event.request.strip()}") - _finish_phone_call(session_id, state) + _finish_phone_call(session_id, card) case "phone.check": - call = state.phone_pending + call = card.phone_pending if call is None or call.phase == "dispatched": _error(session_id, "Сначала передайте вызов, затем запросите обстановку") return @@ -269,17 +276,17 @@ async def _handle(session_id: UUID, state, event) -> None: "ход", "сообщ", "долож", "уточн")): _error(session_id, "Спросите обстановку, прибытие или ход работ по карточке") return - _line(session_id, state, "dispatcher", event.text.strip()) - _finish_phone_call(session_id, state) + _line(session_id, card, "dispatcher", event.text.strip()) + _finish_phone_call(session_id, card) case "phone.hangup": - if state.phone_pending is None: + if card.phone_pending is None: _error(session_id, "Нет активного разговора") return - state.dds_log.append(("phone.hangup", now_utc(), state.phone_pending.crew)) - state.phone_pending = None + card.dds_log.append(("phone.hangup", now_utc(), card.phone_pending.crew)) + card.phone_pending = None case "card.reply": - if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.dispatched_card or ( - event.card_id != state.dispatched_card.card_id + if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or ( + event.card_id != card.card_id ): _error(session_id, "Ответ относится не к текущей карточке") return @@ -287,15 +294,15 @@ async def _handle(session_id: UUID, state, event) -> None: # committed this replace-style value. Reconnect retries are safe: # don't create another journal row # when the current card already contains exactly this text. - if state.reply_text == event.text: + if card.reply_text == event.text: return - state.reply_text = event.text - state.reply_log.append((now_utc(), event.text)) + card.reply_text = event.text + card.reply_log.append((now_utc(), event.text)) case "card.open": - if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.activate_dds_card(event.card_id): + if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.desk.open(event.card_id): _error(session_id, "Карточка отсутствует в текущей очереди") return - _start_dds_work_timer(state) + _start_dds_work_timer(state.desk.active) hub.to_station(session_id, state.card_received_event()) # CardReceived carries the contents, while StationState carries # the status journal and current queue. Send both on every switch @@ -303,41 +310,25 @@ async def _handle(session_id: UUID, state, event) -> None: # card's status snapshot until the next periodic tick. hub.to_station(session_id, StationState(snapshot=state.station_snapshot())) case "card.next": - if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.dispatched_card or ( - event.card_id != state.dispatched_card.card_id + if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or ( + event.card_id != card.card_id ): _error(session_id, "Следующая карточка недоступна: ID текущей не совпадает") return - if any(item.card_id == event.card_id for item in state.dds_completed): + if state.desk.is_completed(event.card_id): _error(session_id, "Эта карточка уже завершена") return - state.dds_completed.append(score_current_dds(state)) - completed_index = state.dds_card_index - state.dds_live_cards = [ - item for item in state.dds_live_cards if item.card_id != event.card_id - ] - remaining = sorted(state.dds_live_cards, key=lambda item: item.original_index) - if remaining: - following = next( - (item for item in remaining if item.original_index > completed_index), - remaining[0], - ) - state.activate_dds_card(following.card_id, capture=False) + # Keep the lesson alive if selected cards have not arrived yet. + # The next delivery may become the active card immediately or + # after its configured interval; no completed card is reused. + if state.desk.complete(card.card_id, score_dds_card(state, card)) is not None: hub.to_station(session_id, state.card_received_event()) - else: - # Keep the lesson alive if selected cards have not arrived yet. - # The next delivery may become the active card immediately or - # after its configured interval; no completed card is reused. - state.dds_active_card_id = None - state.dispatched_card = None - state.dispatched_at = None - state.dds_card_index = completed_index - active_before_delivery = state.dds_active_card_id - deliver_due_cards(state) - if state.dds_active_card_id and state.dds_active_card_id != active_before_delivery: + active_before_delivery = state.desk.active_id + state.desk.deliver_due() + if state.desk.active_id and state.desk.active_id != active_before_delivery: hub.to_station(session_id, state.card_received_event()) hub.to_station(session_id, StationState(snapshot=state.station_snapshot())) - if not state.dds_live_cards and len(state.dds_completed) >= len(state.dds_scenarios): + if not state.desk.cards and len(state.desk.completed) >= len(state.desk.scenarios): await _finish_dds(session_id, state) case "station.finish": if state.exercise is not Exercise.DDS and not state.handoff_to_dds: @@ -352,7 +343,7 @@ async def _handle(session_id: UUID, state, event) -> None: return case "zone.decision": previous = next( - (detail for action, _at, detail in reversed(state.dds_log) + (detail for action, _at, detail in reversed(card.dds_log) if action == "zone.decision"), None, ) @@ -361,16 +352,15 @@ async def _handle(session_id: UUID, state, event) -> None: if previous != decision: _error(session_id, "Решение по зоне уже записано для этой карточки") return - state.on_event("zone.decision") - state.dds_log.append(("zone.decision", now_utc(), decision)) + card.on_event("zone.decision") + card.dds_log.append(("zone.decision", now_utc(), decision)) case "crew.dispatched": - state.kio = state.kio.model_copy(update={"dispatch_order_at": event.at}) - state.dds_log.append(("crew.dispatched", now_utc(), None)) + card.kio = card.kio.model_copy(update={"dispatch_order_at": event.at}) + card.dds_log.append(("crew.dispatched", now_utc(), None)) case "crew.arrived": - state.on_event("crew.arrived") - state.kio = state.kio.model_copy(update={"arrival_at": event.at}) - state.dds_log.append(("crew.arrived", now_utc(), None)) - state.capture_active_dds() + card.on_event("crew.arrived") + card.kio = card.kio.model_copy(update={"arrival_at": event.at}) + card.dds_log.append(("crew.arrived", now_utc(), None)) await hub.checkpoint(session_id) hub.to_station(session_id, StationState(snapshot=state.station_snapshot())) hub.to_observers(session_id, state.snapshot()) @@ -434,7 +424,7 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None: try: # Карточка, переданная до подключения станции, не теряется: # диспетчер садится за АРМ, когда вызов уже идёт. - if state.dispatched_card is not None: + if state.desk.active is not None: hub.to_station(session_id, state.card_received_event()) hub.to_station(session_id, StationState(snapshot=state.station_snapshot())) while True: diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py index faa571f..0be2530 100644 --- a/backend/app/scoring/dispatcher.py +++ b/backend/app/scoring/dispatcher.py @@ -286,6 +286,8 @@ def dispatcher_metrics( deadline_ms: int, expected_decision: str = "accept", expected_decision_reason: str | None = None, + *, + services: list[str] | None = None, ) -> list[Metric]: """Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму. @@ -293,7 +295,7 @@ def dispatcher_metrics( источник информации, но не обязательный шлюз статуса. """ metrics: list[Metric] = [] - for service in state.managed_services(): + 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] statuses = {mark.status for mark in marks} diff --git a/backend/app/scoring/report.py b/backend/app/scoring/report.py index 81fb9f1..3f7a9aa 100644 --- a/backend/app/scoring/report.py +++ b/backend/app/scoring/report.py @@ -76,8 +76,8 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport: return SessionReport( session_id=session_id, - scenario_id=(state.dds_scenarios[0].id - if state.exercise is Exercise.DDS and state.dds_scenarios + scenario_id=(state.desk.scenarios[0].id + if state.exercise is Exercise.DDS and state.desk.scenarios else scenario.id), mode=state.mode, exercise=state.exercise, diff --git a/backend/app/session/checkpoint.py b/backend/app/session/checkpoint.py index 0b27515..a1bc551 100644 --- a/backend/app/session/checkpoint.py +++ b/backend/app/session/checkpoint.py @@ -75,8 +75,13 @@ def _dump_live_card(item: DdsLiveCard, now: float) -> dict: def dump_state(state: SessionState) -> dict: - """Сериализовать только данные, необходимые для точного продолжения.""" - state.capture_active_dds() + """Сериализовать только данные, необходимые для точного продолжения. + + Чистое чтение. Верхние ключи работы диспетчера повторяют активную + карточку пульта — так их писал формат до `DdsDesk`. + """ + desk = state.desk + active = desk.active now = time.monotonic() payload = { "version": CHECKPOINT_VERSION, @@ -117,30 +122,28 @@ def dump_state(state: SessionState) -> dict: "dispatched_at": state.dispatched_at.isoformat() if state.dispatched_at else None, "bounced_fields": state.bounced_fields, "dds_log": [ - [action, at.isoformat(), detail] for action, at, detail in state.dds_log - ], - "status_log": [item.model_dump(mode="json") for item in state.status_log], - "crew_selected": state.crew_selected, - "crew_assignments": state.crew_assignments, - "phone_reports": [item.model_dump(mode="json") for item in state.phone_reports], - "phone_lines": [item.model_dump(mode="json") for item in state.phone_lines], + [action, at.isoformat(), detail] for action, at, detail in active.dds_log + ] if active else [], + "status_log": [item.model_dump(mode="json") for item in active.status_log] if active else [], + "crew_selected": active.crew_selected if active else None, + "crew_assignments": active.crew_assignments if active else {}, + "phone_reports": [item.model_dump(mode="json") for item in active.phone_reports] if active else [], + "phone_lines": [item.model_dump(mode="json") for item in active.phone_lines] if active else [], "phone_pending": ( - state.phone_pending.model_dump(mode="json") if state.phone_pending else None + active.phone_pending.model_dump(mode="json") if active and active.phone_pending else None ), - "dds_scenarios": [item.model_dump(mode="json") for item in state.dds_scenarios], + "dds_scenarios": [item.model_dump(mode="json") for item in desk.scenarios], "pending_dds_scenarios": [item.model_dump(mode="json") for item in state.pending_dds_scenarios], "operator_kio": state.operator_kio.model_dump(mode="json") if state.operator_kio else None, "operator_scenario": state.operator_scenario.model_dump(mode="json") if state.operator_scenario else None, - "dds_live_cards": [_dump_live_card(item, now) for item in state.dds_live_cards], - "dds_active_card_id": ( - str(state.dds_active_card_id) if state.dds_active_card_id else None - ), - "dds_card_index": state.dds_card_index, - "dds_arrival_interval_seconds": state.dds_arrival_interval_seconds, - "dds_max_waiting": state.dds_max_waiting, - "dds_next_scenario_index": state.dds_next_scenario_index, + "dds_live_cards": [_dump_live_card(item, now) for item in desk.ordered()], + "dds_active_card_id": str(desk.active_id) if desk.active_id else None, + "dds_card_index": desk.card_index, + "dds_arrival_interval_seconds": desk.arrival_interval_seconds, + "dds_max_waiting": desk.max_waiting, + "dds_next_scenario_index": desk.next_index, "dds_next_arrival_at": ( - state.dds_next_arrival_at.isoformat() if state.dds_next_arrival_at else None + desk.next_arrival_at.isoformat() if desk.next_arrival_at else None ), "dds_completed": [ { @@ -160,10 +163,10 @@ def dump_state(state: SessionState) -> dict: "managed_service": item.managed_service, "recipient_services": item.recipient_services, } - for item in state.dds_completed + for item in desk.completed ], - "reply_text": state.reply_text, - "reply_log": [[at.isoformat(), text] for at, text in state.reply_log], + "reply_text": active.reply_text if active else "", + "reply_log": [[at.isoformat(), text] for at, text in active.reply_log] if active else [], "text_revealed_facts": state.text_revealed_facts, "resolved_outcome": state.resolved_outcome, "resolve_comment": state.resolve_comment, @@ -232,6 +235,75 @@ def _restore_live_card(item: dict, saved_at: datetime) -> DdsLiveCard: ) +def _restore_completed(item: dict) -> DdsCardRecord: + return DdsCardRecord( + card_id=UUID(item["card_id"]), + scenario_id=item["scenario_id"], + reply_text=item.get("reply_text", ""), + metrics=[Metric.model_validate(metric) for metric in item.get("metrics", [])], + findings=[Finding.model_validate(finding) for finding in item.get("findings", [])], + actions=list(item.get("actions") or []), + duration_ms=int(item.get("duration_ms", 0)), + title=item.get("title"), + address=item.get("address"), + description=item.get("description"), + incident_type=item.get("incident_type"), + victims_count=item.get("victims_count"), + received_at=_dt(item.get("received_at")), + managed_service=item.get("managed_service"), + recipient_services=list(item.get("recipient_services") or []), + ) + + +def _restore_desk(state: SessionState, payload: dict, saved_at: datetime) -> None: + desk = state.desk + live = payload.get("dds_live_cards", []) + desk.scenarios = [Scenario.model_validate(item) for item in payload.get("dds_scenarios", [])] + desk.completed = [_restore_completed(item) for item in payload.get("dds_completed", [])] + desk.limits = dict(state.timers.limits) + desk.card_index = int(payload.get("dds_card_index", 0)) + desk.arrival_interval_seconds = int(payload.get("dds_arrival_interval_seconds", 0)) + desk.max_waiting = int(payload.get("dds_max_waiting", 3)) + desk.next_index = int(payload.get( + "dds_next_scenario_index", + max((item["original_index"] for item in live), default=-1) + 1, + )) + desk.next_arrival_at = _dt(payload.get("dds_next_arrival_at")) + for item in live: + desk.add(_restore_live_card(item, saved_at)) + if desk.cards: + # Legacy snapshots had no explicit active ID; newer snapshots may + # intentionally be between cards while waiting for the next arrival. + raw_id = payload.get("dds_active_card_id") + active_id = UUID(raw_id) if raw_id else None + if active_id is None and "dds_active_card_id" not in payload: + active_id = desk.ordered()[0].card_id + if active_id is not None: + desk.open(active_id) + elif (state.exercise is Exercise.CALL and state.dispatched_card is not None + and state.scenario is not None): + # До пульта живой диспетчер упражнения 112 писал прямо в сессию: + # его работа лежит в верхних ключах снимка. + desk.add(_restore_live_card({ + **payload, + "original_index": 0, + "scenario": payload["scenario"], + "dispatched_card": payload["dispatched_card"], + "dispatched_at": payload.get("dispatched_at") or saved_at.isoformat(), + }, saved_at)) + desk.open(state.dispatched_card.card_id) + if state.exercise is Exercise.CALL: + # Таймеры звонка и диспетчера — одна цепочка 112 → ДДС (см. dispatch). + for card in desk.cards.values(): + card.timers = state.timers + if (desk.scenarios + and desk.next_index < len(desk.scenarios) + and desk.next_arrival_at is None): + # Old checkpoints had no delivery schedule; resume any remaining + # selected scenarios immediately rather than strand the session. + desk.next_arrival_at = now_utc() + + def load_state(payload: dict, saved_at: datetime) -> SessionState: """Восстановить состояние; неизвестная версия отклоняется явно.""" if payload.get("version") != CHECKPOINT_VERSION: @@ -276,84 +348,18 @@ def load_state(payload: dict, saved_at: datetime) -> SessionState: ), dispatched_at=_dt(payload.get("dispatched_at")), bounced_fields=list(payload.get("bounced_fields") or []), - dds_log=[(action, datetime.fromisoformat(at), detail) - for action, at, detail in payload.get("dds_log", [])], - status_log=[StatusEntry.model_validate(item) for item in payload.get("status_log", [])], - crew_selected=payload.get("crew_selected"), - crew_assignments=dict(payload.get("crew_assignments") or {}), - phone_reports=[PhoneReportRecord.model_validate(item) - for item in payload.get("phone_reports", [])], - phone_lines=[PhoneLineRecord.model_validate(item) - for item in payload.get("phone_lines", [])], - phone_pending=( - PhoneCallPending.model_validate(payload["phone_pending"]) - if payload.get("phone_pending") else None - ), - dds_scenarios=[Scenario.model_validate(item) - for item in payload.get("dds_scenarios", [])], pending_dds_scenarios=[Scenario.model_validate(item) for item in payload.get("pending_dds_scenarios", [])], operator_kio=(KIO.model_validate(payload["operator_kio"]) if payload.get("operator_kio") else None), operator_scenario=(Scenario.model_validate(payload["operator_scenario"]) if payload.get("operator_scenario") else None), - dds_live_cards=[_restore_live_card(item, saved_at) - for item in payload.get("dds_live_cards", [])], - dds_active_card_id=( - UUID(payload["dds_active_card_id"]) - if payload.get("dds_active_card_id") else None - ), - dds_card_index=int(payload.get("dds_card_index", 0)), - dds_arrival_interval_seconds=int(payload.get("dds_arrival_interval_seconds", 0)), - dds_max_waiting=int(payload.get("dds_max_waiting", 3)), - dds_next_scenario_index=int(payload.get( - "dds_next_scenario_index", - max((item["original_index"] for item in payload.get("dds_live_cards", [])), default=-1) + 1, - )), - dds_next_arrival_at=_dt(payload.get("dds_next_arrival_at")), - dds_completed=[ - DdsCardRecord( - card_id=UUID(item["card_id"]), - scenario_id=item["scenario_id"], - reply_text=item.get("reply_text", ""), - metrics=[Metric.model_validate(metric) for metric in item.get("metrics", [])], - findings=[Finding.model_validate(finding) - for finding in item.get("findings", [])], - actions=list(item.get("actions") or []), - duration_ms=int(item.get("duration_ms", 0)), - title=item.get("title"), - address=item.get("address"), - description=item.get("description"), - incident_type=item.get("incident_type"), - victims_count=item.get("victims_count"), - received_at=_dt(item.get("received_at")), - managed_service=item.get("managed_service"), - recipient_services=list(item.get("recipient_services") or []), - ) - for item in payload.get("dds_completed", []) - ], - reply_text=payload.get("reply_text", ""), - reply_log=[(datetime.fromisoformat(at), text) - for at, text in payload.get("reply_log", [])], text_revealed_facts=dict(payload.get("text_revealed_facts") or {}), resolved_outcome=payload.get("resolved_outcome"), resolve_comment=payload.get("resolve_comment", ""), processed_station_commands=list(payload.get("processed_station_commands") or [])[-512:], ) - if state.dds_live_cards: - # Legacy snapshots had no explicit active ID; newer snapshots may - # intentionally be between cards while waiting for the next arrival. - active_id = state.dds_active_card_id - if active_id is None and "dds_active_card_id" not in payload: - active_id = state.dds_live_cards[0].card_id - if active_id is not None: - state.activate_dds_card(active_id, capture=False) - if (state.dds_scenarios - and state.dds_next_scenario_index < len(state.dds_scenarios) - and state.dds_next_arrival_at is None): - # Old checkpoints had no delivery schedule; resume any remaining - # selected scenarios immediately rather than strand the session. - state.dds_next_arrival_at = now_utc() + _restore_desk(state, payload, saved_at) # Голосовые runtime-объекты не сериализуются. Их безопасно собрать заново; # карточка и оценка при этом остаются прежними. if state.exercise is Exercise.CALL and state.scenario is not None: diff --git a/backend/app/session/dds.py b/backend/app/session/dds.py index 87c9414..0382649 100644 --- a/backend/app/session/dds.py +++ b/backend/app/session/dds.py @@ -1,23 +1,109 @@ -"""Одновременная выдача учебных карточек ДДС в рамках одного занятия.""" +"""Пульт ДДС: одновременно выданные учебные карточки одного занятия. + +Живое состояние карточки существует в одном экземпляре — в `DdsLiveCard`. +Пульт хранит карточки и id активной; переключение меняет id и ничего не +копирует, поэтому обработчики станции пишут прямо в `desk.active`. +""" import re +from dataclasses import dataclass, field from datetime import datetime, timedelta -from uuid import uuid4 +from typing import Any +from uuid import UUID, uuid4 from app.domain import ekp -from app.domain.kio import KIO, ResponseStatus +from app.domain.events import Metric +from app.domain.kio import KIO, ResponseStatus, apply_patch +from app.domain.statuses import ( + PhoneCallPending, + PhoneLineRecord, + PhoneReportRecord, + ServiceStatus, + StatusEntry, + check, +) +from app.domain.taxonomy import Finding +from app.domain.timers import TimerCode from app.scenarios.schema import Scenario -from app.session.state import DdsLiveCard, SessionState, now_utc -from app.session.timers import SessionTimers +from app.session.timers import SessionTimers, now_utc -def prepare_card(state: SessionState, scenario: Scenario) -> None: - """Сменить активную карточку, сохранив общий ID занятия и прошлые результаты.""" - state.scenario = scenario.model_copy(deep=True) - state.scenario_id = scenario.id - state.scenario_title = scenario.title - state.level = scenario.level.value - state.required_fields = list(scenario.required_fields) +@dataclass +class DdsCardRecord: + card_id: UUID + scenario_id: str + reply_text: str + metrics: list[Metric] + findings: list[Finding] + actions: list[dict[str, Any]] + duration_ms: int + title: str | None = None + address: str | None = None + description: str | None = None + incident_type: str | None = None + victims_count: int | None = None + received_at: datetime | None = None + managed_service: str | None = None + recipient_services: list[str] = field(default_factory=list) + + @property + def score_auto(self) -> float: + total = sum(metric.weight for metric in self.metrics) + passed = sum(metric.weight * (metric.credit if metric.credit is not None + else float(metric.passed)) for metric in self.metrics) + return round(100 * passed / total, 1) if total else 0.0 + + +@dataclass +class DdsLiveCard: + """Изолированное живое состояние одной одновременно выданной карточки.""" + + original_index: int + scenario: Scenario + kio: KIO + dispatched_card: KIO + dispatched_at: datetime + timers: SessionTimers + bounced_fields: list[str] = field(default_factory=list) + dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list) + status_log: list[StatusEntry] = field(default_factory=list) + crew_selected: str | None = None + crew_assignments: dict[str, str] = field(default_factory=dict) + phone_reports: list[PhoneReportRecord] = field(default_factory=list) + phone_lines: list[PhoneLineRecord] = field(default_factory=list) + phone_pending: PhoneCallPending | None = None + reply_text: str = "" + reply_log: list[tuple[datetime, str]] = field(default_factory=list) + + @property + def card_id(self) -> UUID: + return self.dispatched_card.card_id + + def on_event(self, event_type: str) -> None: + self.timers.on_event(event_type) + + def set_service_status( + self, service: str, status: ServiceStatus, comment: str = "", author: str = "" + ) -> StatusEntry: + """Отметка службы. Проверка перехода — до записи: недопустимый статус + не должен попасть в журнал даже отвергнутым.""" + check(self.status_log, service, status, comment) + entry = StatusEntry( + service=service, status=status, at=now_utc(), comment=comment, author=author + ) + self.status_log.append(entry) + return entry + + +def build_card( + scenario: Scenario, index: int, limits: dict[TimerCode, int] +) -> DdsLiveCard: + """Готовая карточка сценария, переданная в ДДС в момент вызова. + + Каждая карточка получает свою копию сценария, свою историю и свой таймер + с лимитом преподавателя: 30 секунд идут с поступления, а не с открытия. + """ + scenario = scenario.model_copy(deep=True) truth = scenario.ground_truth address_fact = next( (fact.value for fact in scenario.facts if "address" in fact.id), "" @@ -58,7 +144,7 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None: if scenario.student_card is not None: # A moderated learner-authored KIO is the card itself; do not flatten # it to title/description and silently discard its structured fields. - state.kio = scenario.student_card.model_copy( + kio = scenario.student_card.model_copy( deep=True, update={ "card_id": uuid4(), @@ -69,7 +155,7 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None: }, ) else: - state.kio = KIO( + kio = KIO( registered_at=now_utc(), caller_number=caller_phone, caller_name=caller_name, @@ -91,130 +177,157 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None: description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line, ) - state.dispatched_card = None if service: - state.dispatch(service) - else: - state.kio.response_status = ResponseStatus.TRANSFERRED - state.dispatched_card = state.kio.model_copy(deep=True) - state.dispatched_at = now_utc() - - # A live DДС card keeps references to the current working collections. - # Never clear them in place when preparing the next card: that would also - # erase the previous card's history. Replace the working collections so - # every queued card retains an isolated snapshot. - state.status_log = [] - state.dds_log = [] - state.bounced_fields = [] - state.crew_selected = None - state.crew_assignments = {} - state.phone_reports = [] - state.phone_lines = [] - state.phone_pending = None - state.reply_text = "" - state.reply_log = [] - # Настроенный преподавателем лимит копируется в независимый таймер карточки. - state.timers = SessionTimers(limits=dict(state.timers.limits)) - state.on_event("dds.dispatch") - - -def _append_live_card( - state: SessionState, scenario: Scenario, index: int -) -> DdsLiveCard: - state.dds_card_index = index - prepare_card(state, scenario) - card = DdsLiveCard( + kio = apply_patch(kio, {"dds": service}) + kio.response_status = ResponseStatus.TRANSFERRED + timers = SessionTimers(limits=dict(limits)) + timers.on_event("dds.dispatch") + return DdsLiveCard( original_index=index, - scenario=state.scenario, - kio=state.kio, - dispatched_card=state.dispatched_card, - dispatched_at=state.dispatched_at, - timers=state.timers, - bounced_fields=state.bounced_fields, - dds_log=state.dds_log, - status_log=state.status_log, - crew_selected=state.crew_selected, - crew_assignments=state.crew_assignments, - phone_reports=state.phone_reports, - phone_lines=state.phone_lines, - phone_pending=state.phone_pending, - reply_text=state.reply_text, - reply_log=state.reply_log, + scenario=scenario, + kio=kio, + dispatched_card=kio.model_copy(deep=True), + dispatched_at=now_utc(), + timers=timers, ) - state.dds_live_cards.append(card) - return card -def deliver_due_cards(state: SessionState, now: datetime | None = None) -> int: - """Выдать всё, чему настал срок и для чего есть место в ожидании. +@dataclass +class DdsDesk: + """Живые карточки занятия, активная из них и очередь поступления.""" - Активная карточка не считается ожидающей. Каждый вызов prepare_card - запускает собственный 30-секундный таймер ровно при поступлении. - """ - now = now or now_utc() - if state.dds_next_arrival_at is None or state.dds_next_arrival_at > now: - return 0 + scenarios: list[Scenario] = field(default_factory=list) + cards: dict[UUID, DdsLiveCard] = field(default_factory=dict) + active_id: UUID | None = None + #: Номер карточки на экране. После завершения последней из поступивших + #: остаётся прежним, пока не придёт следующая. + card_index: int = 0 + completed: list[DdsCardRecord] = field(default_factory=list) + limits: dict[TimerCode, int] = field(default_factory=lambda: dict(SessionTimers().limits)) + arrival_interval_seconds: int = 0 + max_waiting: int = 3 + next_index: int = 0 + next_arrival_at: datetime | None = None - delivered = 0 - while state.dds_next_scenario_index < len(state.dds_scenarios): - active_id = ( - state.dds_active_card_id - if any( - item.card_id == state.dds_active_card_id - for item in state.dds_live_cards - ) - else None + @property + def active(self) -> DdsLiveCard | None: + return self.cards.get(self.active_id) if self.active_id is not None else None + + def ordered(self) -> list[DdsLiveCard]: + return sorted(self.cards.values(), key=lambda card: card.original_index) + + def open(self, card_id: UUID) -> bool: + """Сделать карточку активной; остальные продолжают идти.""" + card = self.cards.get(card_id) + if card is None: + return False + self.active_id = card_id + self.card_index = card.original_index + return True + + def add(self, card: DdsLiveCard) -> DdsLiveCard: + self.cards[card.card_id] = card + return card + + def is_completed(self, card_id: UUID) -> bool: + return any(item.card_id == card_id for item in self.completed) + + def complete(self, card_id: UUID, record: DdsCardRecord) -> DdsLiveCard | None: + """Закрыть карточку и открыть следующую по порядку поступления. + + Если поступивших больше нет, пульт остаётся между карточками: номер + завершённой сохраняется, пока очередь не выдаст следующую. + """ + card = self.cards.pop(card_id) + self.completed.append(record) + remaining = self.ordered() + if not remaining: + self.active_id = None + self.card_index = card.original_index + return None + following = next( + (item for item in remaining if item.original_index > card.original_index), + remaining[0], ) - active_exists = active_id is not None - waiting_count = len(state.dds_live_cards) - int(active_exists) - if waiting_count >= state.dds_max_waiting: - break + self.open(following.card_id) + return following - index = state.dds_next_scenario_index - card = _append_live_card(state, state.dds_scenarios[index], index) - state.dds_next_scenario_index += 1 - delivered += 1 - if not active_exists: - state.activate_dds_card(card.card_id, capture=False) - else: - # prepare_card builds the new snapshot through SessionState's - # working fields; restore the trainee's active card immediately. - state.activate_dds_card(active_id, capture=False) + def start( + self, + scenarios: list[Scenario], + *, + limits: dict[TimerCode, int], + arrival_interval_seconds: int = 0, + max_waiting: int = 3, + now: datetime | None = None, + ) -> int: + """Начать поступление очереди с первой карточки; остальные идут по темпу.""" + self.cards.clear() + self.active_id = None + self.scenarios = scenarios + self.limits = dict(limits) + self.arrival_interval_seconds = arrival_interval_seconds + self.max_waiting = max_waiting + self.next_index = 0 + self.next_arrival_at = now or now_utc() + return self.deliver_due(self.next_arrival_at) - if state.dds_next_scenario_index >= len(state.dds_scenarios): - state.dds_next_arrival_at = None - break - if state.dds_arrival_interval_seconds: - # Pace from actual delivery time. If the cap was full, the next - # card becomes due one full interval after this release. - state.dds_next_arrival_at = card.dispatched_at + timedelta( - seconds=state.dds_arrival_interval_seconds - ) - break - # Zero interval means fill active + available waiting slots now. - state.dds_next_arrival_at = now + def deliver_due(self, now: datetime | None = None) -> int: + """Выдать всё, чему настал срок и для чего есть место в ожидании. - return delivered + Активная карточка не считается ожидающей. Каждая карточка запускает + собственный 30-секундный таймер ровно при поступлении. + """ + now = now or now_utc() + if self.next_arrival_at is None or self.next_arrival_at > now: + return 0 + + delivered = 0 + while self.next_index < len(self.scenarios): + waiting_count = len(self.cards) - int(self.active is not None) + if waiting_count >= self.max_waiting: + break + + index = self.next_index + card = self.add(build_card(self.scenarios[index], index, self.limits)) + self.next_index += 1 + delivered += 1 + if self.active is None: + self.open(card.card_id) + + if self.next_index >= len(self.scenarios): + self.next_arrival_at = None + break + if self.arrival_interval_seconds: + # Pace from actual delivery time. If the cap was full, the next + # card becomes due one full interval after this release. + self.next_arrival_at = card.dispatched_at + timedelta( + seconds=self.arrival_interval_seconds + ) + break + # Zero interval means fill active + available waiting slots now. + self.next_arrival_at = now + + return delivered def prepare_queue( - state: SessionState, + state, scenarios: list[Scenario], arrival_interval_seconds: int = 0, max_waiting: int = 3, ) -> None: - """Начать поступление очереди с первой карточки; остальные идут по темпу.""" - state.dds_live_cards.clear() - state.dds_scenarios = scenarios - state.dds_arrival_interval_seconds = arrival_interval_seconds - state.dds_max_waiting = max_waiting - state.dds_next_scenario_index = 0 - state.dds_next_arrival_at = now_utc() - deliver_due_cards(state) + """Упражнение ДДС: очередь готовых карточек с лимитами занятия.""" + state.desk.start( + scenarios, + limits=state.timers.limits, + arrival_interval_seconds=arrival_interval_seconds, + max_waiting=max_waiting, + ) def prepare_handoff_queue( - state: SessionState, + state, additional_scenarios: list[Scenario], arrival_interval_seconds: int = 0, max_waiting: int = 3, @@ -237,12 +350,14 @@ def prepare_handoff_queue( dispatched_at=state.dispatched_at or now, timers=dds_timers, ) - state.dds_live_cards = [first] - state.dds_scenarios = [state.operator_scenario, *additional_scenarios] - state.dds_arrival_interval_seconds = arrival_interval_seconds - state.dds_max_waiting = max_waiting - state.dds_next_scenario_index = 1 - state.dds_next_arrival_at = ( + desk = state.desk + desk.cards = {first.card_id: first} + desk.scenarios = [state.operator_scenario, *additional_scenarios] + desk.limits = dict(state.timers.limits) + desk.arrival_interval_seconds = arrival_interval_seconds + desk.max_waiting = max_waiting + desk.next_index = 1 + desk.next_arrival_at = ( ( now + timedelta(seconds=arrival_interval_seconds) if additional_scenarios and arrival_interval_seconds @@ -251,5 +366,5 @@ def prepare_handoff_queue( if additional_scenarios else None ) - state.activate_dds_card(first.card_id, capture=False) - deliver_due_cards(state) + desk.open(first.card_id) + desk.deliver_due(now) diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py index c294078..719977f 100644 --- a/backend/app/session/finish.py +++ b/backend/app/session/finish.py @@ -28,42 +28,47 @@ from app.scoring.taxonomy import METRIC_WEIGHTS from app.scoring.timing import time_credit from app.scoring.weights import apply_weights from app.session.hub import hub -from app.session.state import DdsCardRecord, now_utc +from app.session.state import DdsCardRecord, DdsLiveCard, now_utc log = logging.getLogger(__name__) -def score_current_dds(state) -> DdsCardRecord: - """Оценить активную карточку отдельно, до выдачи следующей.""" - number = state.dds_card_index + 1 - decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK] - dds_decision = state.scenario.dds_decision +def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord: + """Оценить одну карточку пульта; активная карточка при этом не меняется. + + Из сессии берутся только служба обучающегося и критерии занятия. + """ + number = card.original_index + 1 + services = state.card_services(card) + decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK] + dds_decision = card.scenario.dds_decision findings = evaluate_dispatcher( - entries=state.status_log, - services=state.managed_services(), - crew_assignments=state.crew_assignments, + entries=card.status_log, + services=services, + crew_assignments=card.crew_assignments, deadline_ms=decision_limit_ms, - elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK), - reply_text=state.reply_text, + elapsed_ms=card.timers.measured_ms(TimerCode.DDS_ACK), + reply_text=card.reply_text, expected_decision=dds_decision.expected, expected_decision_reason=dds_decision.reason, ) metrics = dispatcher_metrics( - state, decision_limit_ms, dds_decision.expected, dds_decision.reason + card, decision_limit_ms, dds_decision.expected, dds_decision.reason, + services=services, ) weighted = GostResult(metrics=metrics, findings=findings) - work_limit_ms = state.timers.limits[TimerCode.DDS_WORK] - work_timer = state.timers.timers.get(TimerCode.DDS_WORK) - work_elapsed_ms = state.timers.measured_ms(TimerCode.DDS_WORK) + work_limit_ms = card.timers.limits[TimerCode.DDS_WORK] + work_timer = card.timers.timers.get(TimerCode.DDS_WORK) + 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(state.managed_services()) and all( - current(state.status_log, service) in { + terminal = bool(services) and all( + current(card.status_log, service) in { ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED, } - for service in state.managed_services() + for service in services ) work_passed = terminal and work_elapsed_ms is not None and work_elapsed_ms <= work_limit_ms work_delta_ms = (work_elapsed_ms - work_limit_ms) if work_elapsed_ms is not None else None @@ -98,78 +103,71 @@ def score_current_dds(state) -> DdsCardRecord: ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026", competency=Competency.NORMS, )) - apply_weights(weighted, {**state.scenario.score_weights, **state.criteria.score_weights}) + apply_weights(weighted, {**card.scenario.score_weights, **state.criteria.score_weights}) actions = [ {"type": "card.status", "service": mark.service, "status": mark.status.value, "comment": mark.comment, "at": mark.at.isoformat()} - for mark in state.status_log + for mark in card.status_log ] + [ {"type": "phone.report", "service": report.service, "crew": report.crew, "phase": report.phase, "text": report.text, "at": report.at.isoformat()} - for report in state.phone_reports + for report in card.phone_reports ] + [ {"type": "card.reply", "text": text, "at": at.isoformat()} - for at, text in state.reply_log + for at, text in card.reply_log ] + [ {"type": kind, "text": text or "", "at": at.isoformat()} - for kind, at, text in state.dds_log + for kind, at, text in card.dds_log ] actions.sort(key=lambda item: item["at"]) return DdsCardRecord( - card_id=state.dispatched_card.card_id, - scenario_id=state.scenario_id, - reply_text=state.reply_text, + 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}"}) for metric in weighted.metrics], findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"}) for finding in weighted.findings], actions=actions, - duration_ms=(max(0, int((now_utc() - state.dispatched_at).total_seconds() * 1000)) - if state.dispatched_at else 0), - title=state.scenario.title, - address=state.dispatched_card.address, - description=state.dispatched_card.description, - incident_type=(state.dispatched_card.incident_type.value - if state.dispatched_card.incident_type else None), - victims_count=state.dispatched_card.victims_count, - received_at=state.dispatched_at, - managed_service=(state.managed_services()[0] if state.managed_services() else None), - recipient_services=list(state.dispatched_card.notify), + duration_ms=max(0, int((now_utc() - card.dispatched_at).total_seconds() * 1000)), + title=card.scenario.title, + address=card.dispatched_card.address, + description=card.dispatched_card.description, + incident_type=(card.dispatched_card.incident_type.value + if card.dispatched_card.incident_type else None), + victims_count=card.dispatched_card.victims_count, + received_at=card.dispatched_at, + managed_service=services[0] if services else None, + recipient_services=list(card.dispatched_card.notify), ) def scoring_scenario(state): """Сценарий, по которому оценивается и разбирается занятие. - После передачи 112→ДДС `state.scenario` — активная карточка очереди, - а КИО курсанта оценивается по эталону упражнения 112. Балл, разбор - и чек-лист самооценки обязаны брать один и тот же сценарий. + После передачи 112→ДДС КИО курсанта оценивается по эталону упражнения + 112. Балл, разбор и чек-лист самооценки обязаны брать один и тот же + сценарий. """ if state.handoff_to_dds and state.operator_scenario: return state.operator_scenario + if state.scenario is None and state.desk.scenarios: + return state.desk.scenarios[0] return state.scenario or store.get(state.scenario_id) def score_live_dds_cards(state) -> list[DdsCardRecord]: - """Оценить все неоценённые карточки очереди и вернуть пульт курсанту. + """Все карточки занятия: завершённые и оценка ещё открытых. - Оценка переключает рабочие поля сессии на каждую карточку по очереди. - Без возврата отчёт взял бы card_status у карточки, оценённой последней, - а пульт после завершения показывал бы чужую карточку. + Открытые карточки оцениваются по месту, пульт не переключается: отчёт + берёт card_status у карточки, активной у курсанта при завершении. """ - cards = list(state.dds_completed) - state.capture_active_dds() - active_id = state.dds_active_card_id - active_card = state.dispatched_card - for live in sorted(state.dds_live_cards, key=lambda item: item.original_index): - if any(item.card_id == live.card_id for item in cards): - continue - state.activate_dds_card(live.card_id, capture=False) - cards.append(score_current_dds(state)) - state.dds_completed = cards - if active_id is None or not state.activate_dds_card(active_id, capture=False): - state.dds_active_card_id = active_id - state.dispatched_card = active_card + desk = state.desk + cards = list(desk.completed) + for live in desk.ordered(): + if not desk.is_completed(live.card_id): + cards.append(score_dds_card(state, live)) + desk.completed = cards return cards @@ -273,30 +271,32 @@ async def finish(session_id: UUID, state) -> None: # Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут # рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии # участвуют оба (docs/spec/DATASET.md#статусы-реагирования). - if state.dispatched_card is not None and state.exercise is Exercise.CALL: - decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK] + live_card = state.desk.active + if live_card is not None and state.exercise is Exercise.CALL: + decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK] + services = state.card_services(live_card) dispatcher_findings = evaluate_dispatcher( - entries=state.status_log, - services=state.managed_services(), - crew_assignments=state.crew_assignments, + entries=live_card.status_log, + services=services, + crew_assignments=live_card.crew_assignments, deadline_ms=decision_limit_ms, - elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK), - reply_text=state.reply_text, + elapsed_ms=live_card.timers.measured_ms(TimerCode.DDS_ACK), + reply_text=live_card.reply_text, expected_decision=scenario.dds_decision.expected, expected_decision_reason=scenario.dds_decision.reason, ) result.findings.extend(dispatcher_findings) result.metrics.extend(dispatcher_metrics( - state, decision_limit_ms, scenario.dds_decision.expected, - scenario.dds_decision.reason, + live_card, decision_limit_ms, scenario.dds_decision.expected, + scenario.dds_decision.reason, services=services, )) - # DDS cards were weighted individually in score_current_dds using each + # DDS cards were weighted individually in score_dds_card using each # card's scenario defaults plus the lesson override. Reapplying the first # scenario's weights here would corrupt the other ticket cards. if state.exercise is not Exercise.DDS: apply_weights(result, {**scenario.score_weights, **state.criteria.score_weights}) - if state.handoff_to_dds and state.dds_scenarios: + if state.handoff_to_dds and state.desk.scenarios: # В связке КИО оценивается относительно эталона и весов упражнения # 112, а каждая карточка очереди уже взвешена собственным сценарием. cards = score_live_dds_cards(state) diff --git a/backend/app/session/hub.py b/backend/app/session/hub.py index bb20c0a..9e2b8b4 100644 --- a/backend/app/session/hub.py +++ b/backend/app/session/hub.py @@ -20,7 +20,6 @@ from app.domain.events import ( CardReceived, ErrorEvent, ErrorKind, - Exercise, StationState, TimerTick, ) @@ -131,7 +130,7 @@ class SessionHub: return any( not state.ended and not state.lease_fenced and ( state.scenario_id == scenario_id - or any(item.id == scenario_id for item in state.dds_scenarios) + or any(item.id == scenario_id for item in state.desk.scenarios) ) for state in self._sessions.values() ) @@ -316,19 +315,17 @@ class SessionHub: state = self.get(session_id) if state is None or state.ended: return - if state.exercise is Exercise.DDS or (state.handoff_to_dds and state.dds_scenarios): - from app.session.dds import deliver_due_cards - - active_before = state.dds_active_card_id - delivered = deliver_due_cards(state) - if state.dds_active_card_id != active_before and state.dds_active_card_id: + if state.dds_phase: + active_before = state.desk.active_id + delivered = state.desk.deliver_due() + if state.desk.active_id != active_before and state.desk.active_id: self.to_station(session_id, state.card_received_event()) if delivered: await self.checkpoint(session_id) # Keep the pending count and countdown live even while # the active dispatcher card is being handled. self.to_station(session_id, StationState(snapshot=state.station_snapshot())) - tick = TimerTick(timers=state.timers.snapshot()) + tick = TimerTick(timers=state.shown_timers().snapshot()) self.broadcast(session_id, tick) # Срок первичного решения пульт берёт отсюда: после передачи # из 112 без очереди ДДС лимит преподавателя больше нигде не виден. diff --git a/backend/app/session/state.py b/backend/app/session/state.py index b58dc47..60bd7b7 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -7,7 +7,7 @@ import time from dataclasses import dataclass, field -from datetime import UTC, datetime +from datetime import datetime from typing import Any from uuid import UUID @@ -18,7 +18,6 @@ from app.domain.events import ( CallEndReason, Exercise, LessonCriteria, - Metric, Mood, SessionMode, SessionSnapshot, @@ -31,77 +30,17 @@ from app.domain.statuses import ( CardStatus, DdsCardSummary, DdsQueueCard, - PhoneCallPending, - PhoneLineRecord, - PhoneReportRecord, ServiceStatus, StationSnapshot, - StatusEntry, card_status, - check, current, ) -from app.domain.taxonomy import Finding from app.domain.timers import TimerCode from app.scenarios.schema import Scenario -from app.session.timers import SessionTimers +from app.session.dds import DdsCardRecord, DdsDesk, DdsLiveCard +from app.session.timers import SessionTimers, now_utc - -def now_utc() -> datetime: - """Часы серверные. Метрика, посчитанная по часам браузера, недоказуема.""" - return datetime.now(UTC) - - -@dataclass -class DdsCardRecord: - card_id: UUID - scenario_id: str - reply_text: str - metrics: list[Metric] - findings: list[Finding] - actions: list[dict[str, Any]] - duration_ms: int - title: str | None = None - address: str | None = None - description: str | None = None - incident_type: str | None = None - victims_count: int | None = None - received_at: datetime | None = None - managed_service: str | None = None - recipient_services: list[str] = field(default_factory=list) - - @property - def score_auto(self) -> float: - total = sum(metric.weight for metric in self.metrics) - passed = sum(metric.weight * (metric.credit if metric.credit is not None - else float(metric.passed)) for metric in self.metrics) - return round(100 * passed / total, 1) if total else 0.0 - - -@dataclass -class DdsLiveCard: - """Изолированное живое состояние одной одновременно выданной карточки.""" - - original_index: int - scenario: Scenario - kio: KIO - dispatched_card: KIO - dispatched_at: datetime - timers: SessionTimers - bounced_fields: list[str] = field(default_factory=list) - dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list) - status_log: list[StatusEntry] = field(default_factory=list) - crew_selected: str | None = None - crew_assignments: dict[str, str] = field(default_factory=dict) - phone_reports: list[PhoneReportRecord] = field(default_factory=list) - phone_lines: list[PhoneLineRecord] = field(default_factory=list) - phone_pending: PhoneCallPending | None = None - reply_text: str = "" - reply_log: list[tuple[datetime, str]] = field(default_factory=list) - - @property - def card_id(self) -> UUID: - return self.dispatched_card.card_id +__all__ = ["DdsCardRecord", "DdsLiveCard", "SessionState", "now_utc"] @dataclass @@ -167,34 +106,18 @@ class SessionState: started_at: datetime | None = None ended_at: datetime | None = None end_reason: CallEndReason | None = None + #: Карточка оператора 112, замороженная при передаче в ДДС. dispatched_card: KIO | None = None dispatched_at: datetime | None = None #: Поля, из-за которых диспетчер вернул карточку, — основание E6. bounced_fields: list[str] = field(default_factory=list) - dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list) - #: Отметки служб по списку оповещения — предмет второго режима занятия. - status_log: list[StatusEntry] = field(default_factory=list) - crew_selected: str | None = None - crew_assignments: dict[str, str] = field(default_factory=dict) - phone_reports: list[PhoneReportRecord] = field(default_factory=list) - phone_lines: list[PhoneLineRecord] = field(default_factory=list) - phone_pending: PhoneCallPending | None = None - dds_scenarios: list[Scenario] = field(default_factory=list) + #: Готовые карточки связки 112→ДДС ждут, пока курсант не сдаст свою. pending_dds_scenarios: list[Scenario] = field(default_factory=list) - #: Исходная часть упражнения 112→ДДС сохраняется отдельно от активной - #: карточки ДДС, которая может переключаться по очереди. + #: Исходная часть упражнения 112→ДДС сохраняется отдельно от карточек пульта. operator_kio: KIO | None = None operator_scenario: Scenario | None = None - dds_live_cards: list[DdsLiveCard] = field(default_factory=list) - dds_active_card_id: UUID | None = None - dds_card_index: int = 0 - dds_completed: list[DdsCardRecord] = field(default_factory=list) - dds_arrival_interval_seconds: int = 0 - dds_max_waiting: int = 3 - dds_next_scenario_index: int = 0 - dds_next_arrival_at: datetime | None = None - reply_text: str = "" - reply_log: list[tuple[datetime, str]] = field(default_factory=list) + #: Работа диспетчера живёт только здесь — в карточках пульта. + desk: DdsDesk = field(default_factory=DdsDesk) #: Чем курсант закрыл вызов, если не карточкой (lct-36). resolved_outcome: str | None = None resolve_comment: str = "" @@ -230,8 +153,48 @@ class SessionState: self.kio.response_status = ResponseStatus.TRANSFERRED self.dispatched_card = self.kio.model_copy(deep=True) self.dispatched_at = now_utc() + if self.exercise is Exercise.CALL: + self._receive_call_card() return self.dispatched_card + def _receive_call_card(self) -> None: + """Живой диспетчер упражнения 112 работает с переданной карточкой на пульте. + + Таймеры общие с оператором: норматив приёма ДДС в этом упражнении + часть одной цепочки 112 → ДДС и оценивается вместе с ней. Повторная + передача обновляет содержимое карточки, не стирая работу диспетчера. + """ + if self.scenario is None or self.dispatched_card is None: + return + card = self.desk.cards.get(self.dispatched_card.card_id) + if card is None: + card = self.desk.add(DdsLiveCard( + original_index=0, + scenario=self.scenario, + kio=self.kio, + dispatched_card=self.dispatched_card, + dispatched_at=self.dispatched_at, + timers=self.timers, + )) + else: + card.kio = self.kio + card.dispatched_card = self.dispatched_card + card.dispatched_at = self.dispatched_at + self.desk.open(card.card_id) + + @property + def dds_phase(self) -> bool: + """Экран курсанта — пульт с очередью: упражнение ДДС или вторая часть связки.""" + return self.exercise is Exercise.DDS or (self.handoff_to_dds and bool(self.desk.scenarios)) + + def shown_card(self) -> DdsLiveCard | None: + """Карточка, которую монитор показывает вместо КИО оператора.""" + return self.desk.active if self.dds_phase else None + + def shown_timers(self) -> SessionTimers: + card = self.shown_card() + return card.timers if card is not None else self.timers + def notified_services(self) -> list[str]: """Список оповещения переданной карточки. До передачи — пусто: отмечаться диспетчеру пока не по чему.""" @@ -269,105 +232,52 @@ class SessionState: return [recipients[0]] def managed_services(self) -> list[str]: + """Службы обучающегося: по активной карточке пульта, до неё — по КИО 112.""" + card = self.desk.active + if card is not None: + return self.card_services(card) return self._managed_services_for(self.notified_services()) - def capture_active_dds(self) -> None: - """Сохранить изменяемые поля активной карточки перед переключением.""" - if not self.dispatched_card: - return - card = next( - (item for item in self.dds_live_cards - if item.card_id == self.dispatched_card.card_id), - None, - ) - if card is None: - return - card.scenario = self.scenario - card.kio = self.kio - card.dispatched_card = self.dispatched_card - card.dispatched_at = self.dispatched_at or card.dispatched_at - card.timers = self.timers - card.bounced_fields = self.bounced_fields - card.dds_log = self.dds_log - card.status_log = self.status_log - card.crew_selected = self.crew_selected - card.crew_assignments = self.crew_assignments - card.phone_reports = self.phone_reports - card.phone_lines = self.phone_lines - card.phone_pending = self.phone_pending - card.reply_text = self.reply_text - card.reply_log = self.reply_log - self.dds_active_card_id = card.card_id + def card_services(self, card: DdsLiveCard) -> list[str]: + """Службы карточки пульта, от имени которых работает обучающийся.""" + return self._managed_services_for(list(card.dispatched_card.notify)) - def activate_dds_card(self, card_id: UUID, *, capture: bool = True) -> bool: - """Переключить рабочее место на карточку, не останавливая остальные.""" - if capture: - self.capture_active_dds() - card = next((item for item in self.dds_live_cards if item.card_id == card_id), None) - if card is None: - return False - scenario = card.scenario - self.scenario = scenario - self.scenario_id = scenario.id - self.scenario_title = scenario.title - self.level = scenario.level.value - self.required_fields = list(scenario.required_fields) - self.kio = card.kio - self.dispatched_card = card.dispatched_card - self.dispatched_at = card.dispatched_at - self.timers = card.timers - self.bounced_fields = card.bounced_fields - self.dds_log = card.dds_log - self.status_log = card.status_log - self.crew_selected = card.crew_selected - self.crew_assignments = card.crew_assignments - self.phone_reports = card.phone_reports - self.phone_lines = card.phone_lines - self.phone_pending = card.phone_pending - self.reply_text = card.reply_text - self.reply_log = card.reply_log - self.dds_card_index = card.original_index - self.dds_active_card_id = card.card_id - return True + @staticmethod + def _crew_options_for(services: list[str]) -> list[str]: + return [f"{service} — бригада №{number}" for service in services for number in (1, 2)] - def crew_options(self) -> list[str]: - return [f"{service} — бригада №{number}" - for service in self.managed_services() for number in (1, 2)] + def crew_options(self, card: DdsLiveCard) -> list[str]: + return self._crew_options_for(self.card_services(card)) - def crew_service(self, crew: str) -> str | None: - return next((service for service in self.managed_services() + def crew_service(self, card: DdsLiveCard, crew: str) -> str | None: + return next((service for service in self.card_services(card) if crew in (f"{service} — бригада №1", f"{service} — бригада №2")), None) - def set_service_status( - self, service: str, status: ServiceStatus, comment: str = "", author: str = "" - ) -> StatusEntry: - """Отметка службы. Проверка перехода — до записи: недопустимый статус - не должен попасть в журнал даже отвергнутым.""" - check(self.status_log, service, status, comment) - entry = StatusEntry( - service=service, status=status, at=now_utc(), comment=comment, author=author - ) - self.status_log.append(entry) - return entry - def station_snapshot(self) -> StationSnapshot: """Что видит диспетчер: службы, их статусы и доступные переходы.""" - self.capture_active_dds() - has_active_dds_card = any( - item.card_id == self.dds_active_card_id for item in self.dds_live_cards - ) - recipients = self.notified_services() if has_active_dds_card or not self.dds_scenarios else [] + desk = self.desk + card = desk.active + if card is not None: + recipients = list(card.dispatched_card.notify) + elif not desk.scenarios: + # Пульт ещё пуст (до передачи из 112) или упражнение без ДДС: + # показываем список оповещения карточки оператора. + recipients = self.notified_services() + else: + recipients = [] services = self._managed_services_for(recipients) - statuses = {service: current(self.status_log, service) for service in services} - elapsed = self.timers.measured_ms(TimerCode.DDS_ACK) - limit = self.timers.limits[TimerCode.DDS_ACK] - overdue = has_active_dds_card and self.dispatched_at is not None and ( - elapsed is None - and (now_utc() - self.dispatched_at).total_seconds() * 1000 > limit - ) + log = list(card.status_log) if card is not None else [] + statuses = {service: current(log, service) for service in services} + overdue = False + if card is not None: + elapsed = card.timers.measured_ms(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() queue_cards: list[DdsQueueCard] = [] - for item in sorted(self.dds_live_cards, key=lambda queued: queued.original_index): + for item in (desk.ordered() if self.dds_phase else []): recipients_for_card = list(item.dispatched_card.notify) managed = self._managed_services_for(recipients_for_card) service = managed[0] if managed else None @@ -395,60 +305,62 @@ class SessionState: elapsed_ms=elapsed_ms, limit_ms=limit_ms, timer_stopped=stopped, - active=item.card_id == self.dds_active_card_id, + active=item.card_id == desk.active_id, )) + shows_card = card is not None or not desk.scenarios return StationSnapshot( services=services, recipient_services=recipients, managed_service=services[0] if services else None, statuses=statuses, available={service: list(NEXT[status]) for service, status in statuses.items()}, - card=(card_status(self.status_log, services, notify_deadline_passed=overdue) - if has_active_dds_card or not self.dds_scenarios else CardStatus.REGISTERED), - log=list(self.status_log), - crew_options=(self.crew_options() if has_active_dds_card or not self.dds_scenarios else []), - crew_selected=self.crew_selected if has_active_dds_card else None, + card=(card_status(log, services, notify_deadline_passed=overdue) + if shows_card else CardStatus.REGISTERED), + log=log, + crew_options=(self._crew_options_for(services) if shows_card else []), + crew_selected=card.crew_selected if card is not None else None, zone_decision=( - next((detail == "в зоне" for action, _at, detail in reversed(self.dds_log) + next((detail == "в зоне" for action, _at, detail in reversed(card.dds_log) if action == "zone.decision"), None) - if has_active_dds_card else None + if card is not None else None ), - phone_reports=list(self.phone_reports) if has_active_dds_card else [], - phone_lines=list(self.phone_lines) if has_active_dds_card else [], - phone_pending=self.phone_pending if has_active_dds_card else None, - card_id=(self.dispatched_card.card_id if self.dispatched_card and has_active_dds_card else None), - card_index=(self.dds_card_index + 1 if has_active_dds_card - else min(self.dds_next_scenario_index + 1, len(self.dds_scenarios))), - card_total=len(self.dds_scenarios) or 1, - reply_text=self.reply_text if has_active_dds_card else "", + phone_reports=list(card.phone_reports) if card is not None else [], + phone_lines=list(card.phone_lines) if card is not None else [], + phone_pending=card.phone_pending if card is not None else None, + card_id=card.card_id if card is not None else None, + card_index=(desk.card_index + 1 if card is not None + else min(desk.next_index + 1, len(desk.scenarios))), + card_total=len(desk.scenarios) or 1, + reply_text=card.reply_text if card is not None else "", completed_cards=[DdsCardSummary(card_id=item.card_id, scenario_id=item.scenario_id, score_auto=item.score_auto) - for item in self.dds_completed], + for item in desk.completed], queue_cards=queue_cards, pending_cards_count=max( - 0, len(self.dds_scenarios) - len(self.dds_completed) - - (1 if has_active_dds_card else 0), + 0, len(desk.scenarios) - len(desk.completed) + - (1 if card is not None else 0), ), next_arrival_in_seconds=( - max(0, int((self.dds_next_arrival_at - now_utc()).total_seconds() + 0.999)) - if self.dds_next_arrival_at is not None else None + max(0, int((desk.next_arrival_at - now_utc()).total_seconds() + 0.999)) + if desk.next_arrival_at is not None else None ), - max_waiting_cards=self.dds_max_waiting, + max_waiting_cards=desk.max_waiting, ) def card_received_event(self): """Снимок карточки для станции ДДС.""" from app.domain.events import CardReceived + card = self.desk.active return CardReceived( - card=self.dispatched_card, + card=card.dispatched_card if card is not None else self.dispatched_card, from_operator=("учебный сценарий" if self.exercise is Exercise.DDS - or (self.handoff_to_dds and self.dds_card_index > 0) + or (self.handoff_to_dds and self.desk.card_index > 0) else self.trainee_name or "оператор 112"), - at=self.dispatched_at or now_utc(), - card_index=self.dds_card_index + 1, - card_total=len(self.dds_scenarios) or 1, + at=(card.dispatched_at if card is not None else self.dispatched_at) or now_utc(), + card_index=self.desk.card_index + 1, + card_total=len(self.desk.scenarios) or 1, ) @property @@ -458,20 +370,22 @@ class SessionState: def snapshot(self) -> SessionSnapshot: """Полное состояние. Монитор в классе включают посреди занятия — он обязан показать текущее, а не ждать следующего события.""" + card = self.shown_card() return SessionSnapshot( session_id=self.session_id, - scenario_id=self.scenario_id, - scenario_title=self.scenario_title, - level=self.level, + scenario_id=card.scenario.id if card is not None else self.scenario_id, + scenario_title=card.scenario.title if card is not None else self.scenario_title, + level=card.scenario.level.value if card is not None else self.level, mode=self.mode, exercise=self.exercise, criteria=self.criteria, trainee_name=self.trainee_name, started_at=self.started_at, - kio=self.kio, - required_fields=self.required_fields, + kio=card.kio if card is not None else self.kio, + required_fields=(list(card.scenario.required_fields) if card is not None + else self.required_fields), transcript=list(self.transcript), - timers=self.timers.snapshot(), + timers=self.shown_timers().snapshot(), hints_used=len(self.hints_shown), ended=self.ended, ) diff --git a/backend/app/session/timers.py b/backend/app/session/timers.py index 358b3f2..4ca1905 100644 --- a/backend/app/session/timers.py +++ b/backend/app/session/timers.py @@ -10,9 +10,16 @@ import time from dataclasses import dataclass, field +from datetime import UTC, datetime from app.domain.timers import NORMATIVES, TimerCode, TimerSnapshot, state_for + +def now_utc() -> datetime: + """Часы серверные. Метрика, посчитанная по часам браузера, недоказуема.""" + return datetime.now(UTC) + + #: Какое событие какой таймер запускает. #: #: `dds_notify` (≤ 60 с) не запускается ничем, и это сознательно. Стартуй он diff --git a/backend/tests/golden/checkpoint_before_desk.json b/backend/tests/golden/checkpoint_before_desk.json new file mode 100644 index 0000000..b1b0c73 --- /dev/null +++ b/backend/tests/golden/checkpoint_before_desk.json @@ -0,0 +1 @@ +{"dds": {"version": 1, "session_id": "35003185-d27a-4552-9c1a-b819456b06df", "scenario_id": "t20-2-stroke", "scenario_title": "Речь невнятная, лицо перекошено", "level": "L2", "mode": "training", "owner_login": null, "backend_fencing_epoch": 0, "exercise": "dds", "handoff_to_dds": false, "required_fields": ["address", "incident_type", "description", "victims_count"], "trainee_name": null, "trainee_id": null, "dds_service": null, "attempt": 1, "criteria": {"decision_time_limit_seconds": 30, "card_fill_time_limit_seconds": 180, "dds_card_work_time_limit_seconds": 180, "allowed_errors": 0, "require_correct_grammar": true, "score_weights": {}}, "kio": {"card_id": "91966e62-a166-490c-81da-a590b06cbe51", "registered_at": "2026-09-26T21:28:46.374117Z", "response_status": "transferred", "caller_number": "916 896 3254", "caller_name": "Титов Олег Иванович", "caller_contact": "916 896 3254", "phone_on_scene": null, "language": "ru", "okato": null, "address": "дорога от Каширского шоссе в сторону деревни Истомиха, у остановки Домодедовское кладбище, рядом с КПП", "street": null, "building": null, "entrance": null, "floor": null, "intercom_code": null, "coords": null, "incident_group": 22, "signs": ["Парализовало"], "incident_code": "22350000", "incident_type": "medical", "description": "дорога от Каширского шоссе в сторону деревни Истомиха, около автобусной остановки Домодедовское кладбище, рядом с КПП; речь невнятная, лицо перекошено; прохожий просит вызвать 03; женщина на вид семидесяти лет, неизвестная", "victims_count": 1, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["СМП", "ГКУ НТУ"], "dds": "03", "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "transcript": [], "timers": {"limits": {"answer": 8000, "interview": 75000, "dds_notify": 60000, "dds_ack": 30000, "card_fill": 180000, "dds_work": 180000, "zone_check": 30000, "callback": 10000, "close": 300000}, "items": {"dds_ack": {"elapsed_ms": 0, "attempt": 1, "stopped": false}, "close": {"elapsed_ms": 0, "attempt": 1, "stopped": false}, "interview": {"elapsed_ms": 0, "attempt": 1, "stopped": true}}}, "hints_shown": [], "hints_log": [], "notes": [], "directives": [], "scenario": {"id": "t20-2-stroke", "title": "Речь невнятная, лицо перекошено", "type": "medical", "level": "L2", "topics": ["medical"], "modes": ["training", "exam", "self"], "extends": "checklists/medical.yaml", "persona": {"base": "worried", "arc": [{"stage": "registration", "mood": "worried"}, {"stage": "interview", "mood": "worried"}]}, "background": null, "first_line": "Женщине плохо, говорит невнятно, лицо перекосило.", "signs": ["Парализовало"], "ticket": 20, "position": 2, "outcome": "card", "dds_decision": {"expected": "accept", "reason": null}, "student_card": null, "facts": [{"id": "f_address", "value": "дорога от Каширского шоссе в сторону деревни Истомиха, около автобусной остановки Домодедовское кладбище, рядом с КПП", "reveal_on": {"question": "q_address"}, "refined": null, "refine_on": null}, {"id": "f_what_happened", "value": "речь невнятная, лицо перекошено; прохожий просит вызвать 03", "reveal_on": {"question": "q_what_happened"}, "refined": null, "refine_on": null}, {"id": "f_who", "value": "женщина на вид семидесяти лет, неизвестная", "reveal_on": {"question": "q_who"}, "refined": null, "refine_on": null}, {"id": "f_conscious", "value": "сведения о сознании женщины не указаны", "reveal_on": {"question": "q_conscious"}, "refined": null, "refine_on": null}, {"id": "f_caller", "value": "вызывает прохожий Титов Олег Иванович, телефон 916 896 3254", "reveal_on": {"question": "q_caller"}, "refined": null, "refine_on": null}], "checklist": [{"id": "q_address", "question": "Назовите адрес: улица, дом, квартира, этаж, код домофона?", "fact": "f_address", "examples": ["Какой у вас адрес?", "Где вы находитесь?", "Куда ехать скорой?", "Код домофона какой?", "Номер квартиры?"]}, {"id": "q_what_happened", "question": "Что случилось с пострадавшим?", "fact": "f_what_happened", "examples": ["Что с человеком?", "Что произошло?", "На что жалуется?", "Как это случилось?"]}, {"id": "q_who", "question": "Кто пострадавший: имя, возраст, пол?", "fact": "f_who", "examples": ["Сколько лет?", "Как зовут больного?", "Это мужчина или женщина?", "Возраст какой?"]}, {"id": "q_conscious", "question": "Человек в сознании, дышит?", "fact": "f_conscious", "examples": ["В сознании?", "Дышит?", "Он отвечает вам?", "Реагирует на голос?"]}, {"id": "q_can_move", "question": "Может ли пострадавший передвигаться самостоятельно?", "fact": null, "examples": ["Он может идти?", "Ходит сам?", "Встать может?"]}, {"id": "q_caller", "question": "Представьтесь и оставьте контактный телефон", "fact": "f_caller", "examples": ["Как вас зовут?", "Ваш номер телефона?", "Кем вы приходитесь пострадавшему?"]}], "required_fields": ["address", "incident_type", "description", "victims_count"], "score_weights": {}, "not_questions": ["Служба 112, что случилось?", "Успокойтесь, пожалуйста", "Оставайтесь на линии", "Помощь уже едет", "Я вас понял", "Говорите громче, плохо слышно", "Выходите из квартиры", "Не кладите трубку", "Хорошо, записываю"], "ground_truth": {"incident_type": "medical", "dds": "03", "incident_code": "22350000", "notify": ["СМП", "ГКУ НТУ"], "required_facts": ["f_address", "f_what_happened", "f_who", "f_conscious", "f_caller"], "address": "дорога от Каширского шоссе в сторону деревни Истомиха, у остановки Домодедовское кладбище, рядом с КПП", "victims": 1}, "era_glonass": null, "tree": {"pregenerated": false}}, "audio_frames": 0, "bad_frames": 0, "self_assessed": false, "self_assessment": null, "score": null, "started_at": null, "ended_at": null, "end_reason": null, "dispatched_card": {"card_id": "91966e62-a166-490c-81da-a590b06cbe51", "registered_at": "2026-09-26T21:28:46.374117Z", "response_status": "transferred", "caller_number": "916 896 3254", "caller_name": "Титов Олег Иванович", "caller_contact": "916 896 3254", "phone_on_scene": null, "language": "ru", "okato": null, "address": "дорога от Каширского шоссе в сторону деревни Истомиха, у остановки Домодедовское кладбище, рядом с КПП", "street": null, "building": null, "entrance": null, "floor": null, "intercom_code": null, "coords": null, "incident_group": 22, "signs": ["Парализовало"], "incident_code": "22350000", "incident_type": "medical", "description": "дорога от Каширского шоссе в сторону деревни Истомиха, около автобусной остановки Домодедовское кладбище, рядом с КПП; речь невнятная, лицо перекошено; прохожий просит вызвать 03; женщина на вид семидесяти лет, неизвестная", "victims_count": 1, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["СМП", "ГКУ НТУ"], "dds": "03", "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "dispatched_at": "2026-09-26T21:28:46.374340+00:00", "bounced_fields": [], "dds_log": [], "status_log": [], "crew_selected": null, "crew_assignments": {}, "phone_reports": [], "phone_lines": [], "phone_pending": null, "dds_scenarios": [{"id": "fire-apartment-l2", "title": "Пожар в квартире, паникующий заявитель", "type": "fire", "level": "L2", "topics": ["fires"], "modes": ["training", "exam", "self"], "extends": "checklists/fire.yaml", "persona": {"base": "panic", "arc": [{"stage": "registration", "mood": "panic"}, {"stage": "interview", "mood": "panic"}, {"stage": "dispatch", "mood": "worried"}]}, "background": {"loop": "fire_crackle.mp3", "gain_db": -18.0}, "first_line": "Алло! Алло! Помогите! Горим! А-а-а!", "signs": ["жилой дом", "балкон", "открытое пламя"], "ticket": null, "position": null, "outcome": "card", "dds_decision": {"expected": "accept", "reason": null}, "student_card": null, "facts": [{"id": "f_address", "value": "улица Ленина, 14, квартира 47, 5-й этаж", "reveal_on": {"question": "q_address"}, "refined": null, "refine_on": null}, {"id": "f_what_burns", "value": "горит балкон, дым пошёл в квартиру", "reveal_on": {"question": "q_what_burns"}, "refined": null, "refine_on": null}, {"id": "f_people", "value": "в квартире жена и ребёнок, не выходят", "reveal_on": {"question": "q_people"}, "refined": null, "refine_on": null}, {"id": "f_smoke", "value": "дым идёт в подъезд, на площадке ничего не видно", "reveal_on": {"question": "q_smoke"}, "refined": null, "refine_on": null}, {"id": "f_gas", "value": "плита газовая, вентиль в кухне, перекрыть не успел", "reveal_on": {"question": "q_gas"}, "refined": null, "refine_on": null}], "checklist": [{"id": "q_address", "question": "Назовите адрес: улица, дом, квартира, этаж?", "fact": "f_address", "examples": ["Какой у вас адрес?", "Где вы находитесь?", "На каком этаже?", "Номер дома и квартиры?"]}, {"id": "q_what_burns", "question": "Что именно горит?", "fact": "f_what_burns", "examples": ["Что горит?", "Что именно загорелось?", "Откуда идёт огонь?"]}, {"id": "q_people", "question": "Есть ли люди в помещении?", "fact": "f_people", "examples": ["Кто-то остался внутри?", "Все успели выйти?"]}, {"id": "q_smoke", "question": "Куда идёт дым, есть ли задымление подъезда?", "fact": "f_smoke", "examples": ["Куда идёт дым?", "Есть ли задымление в подъезде?", "Сильный дым?"]}, {"id": "q_gas", "question": "Есть ли газ, можно ли перекрыть?", "fact": "f_gas", "examples": ["У вас газовая плита?", "Перекрыть газ сможете?"]}, {"id": "q_caller", "question": "Представьтесь и оставьте контактный телефон", "fact": null, "examples": ["Как вас зовут?", "Ваш номер телефона?"]}], "required_fields": ["address", "floor", "incident_type", "dds", "victims_count", "description"], "score_weights": {}, "not_questions": ["Служба 112, что случилось?", "Успокойтесь, пожалуйста", "Оставайтесь на линии", "Помощь уже едет", "Я вас понял", "Говорите громче, плохо слышно", "Выходите из квартиры", "Не кладите трубку", "Хорошо, записываю"], "ground_truth": {"incident_type": "fire", "dds": "01", "incident_code": "1050201", "notify": ["Служба 101", "МВД", "СМП", "ЦЭМП", "Мособлгаз", "Гор. Хозяйство", "Мослифт", "ЦОДД", "Департамент РБиПК (ГКУ МОСБЕЗ)", "МКП, Аналитика", "Аппарат МЭРА", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "Мосжилинспекция", "ГКУ НТУ", "ФСО", "ДГП (Департамент градостроительной политики)", "ЦУКБ Министерство обороны"], "required_facts": ["f_address", "f_what_burns", "f_people", "f_smoke", "f_gas"], "address": "улица Ленина, 14", "victims": 2}, "era_glonass": null, "tree": {"pregenerated": false}}, {"id": "t20-2-stroke", "title": "Речь невнятная, лицо перекошено", "type": "medical", "level": "L2", "topics": ["medical"], "modes": ["training", "exam", "self"], "extends": "checklists/medical.yaml", "persona": {"base": "worried", "arc": [{"stage": "registration", "mood": "worried"}, {"stage": "interview", "mood": "worried"}]}, "background": null, "first_line": "Женщине плохо, говорит невнятно, лицо перекосило.", "signs": ["Парализовало"], "ticket": 20, "position": 2, "outcome": "card", "dds_decision": {"expected": "accept", "reason": null}, "student_card": null, "facts": [{"id": "f_address", "value": "дорога от Каширского шоссе в сторону деревни Истомиха, около автобусной остановки Домодедовское кладбище, рядом с КПП", "reveal_on": {"question": "q_address"}, "refined": null, "refine_on": null}, {"id": "f_what_happened", "value": "речь невнятная, лицо перекошено; прохожий просит вызвать 03", "reveal_on": {"question": "q_what_happened"}, "refined": null, "refine_on": null}, {"id": "f_who", "value": "женщина на вид семидесяти лет, неизвестная", "reveal_on": {"question": "q_who"}, "refined": null, "refine_on": null}, {"id": "f_conscious", "value": "сведения о сознании женщины не указаны", "reveal_on": {"question": "q_conscious"}, "refined": null, "refine_on": null}, {"id": "f_caller", "value": "вызывает прохожий Титов Олег Иванович, телефон 916 896 3254", "reveal_on": {"question": "q_caller"}, "refined": null, "refine_on": null}], "checklist": [{"id": "q_address", "question": "Назовите адрес: улица, дом, квартира, этаж, код домофона?", "fact": "f_address", "examples": ["Какой у вас адрес?", "Где вы находитесь?", "Куда ехать скорой?", "Код домофона какой?", "Номер квартиры?"]}, {"id": "q_what_happened", "question": "Что случилось с пострадавшим?", "fact": "f_what_happened", "examples": ["Что с человеком?", "Что произошло?", "На что жалуется?", "Как это случилось?"]}, {"id": "q_who", "question": "Кто пострадавший: имя, возраст, пол?", "fact": "f_who", "examples": ["Сколько лет?", "Как зовут больного?", "Это мужчина или женщина?", "Возраст какой?"]}, {"id": "q_conscious", "question": "Человек в сознании, дышит?", "fact": "f_conscious", "examples": ["В сознании?", "Дышит?", "Он отвечает вам?", "Реагирует на голос?"]}, {"id": "q_can_move", "question": "Может ли пострадавший передвигаться самостоятельно?", "fact": null, "examples": ["Он может идти?", "Ходит сам?", "Встать может?"]}, {"id": "q_caller", "question": "Представьтесь и оставьте контактный телефон", "fact": "f_caller", "examples": ["Как вас зовут?", "Ваш номер телефона?", "Кем вы приходитесь пострадавшему?"]}], "required_fields": ["address", "incident_type", "description", "victims_count"], "score_weights": {}, "not_questions": ["Служба 112, что случилось?", "Успокойтесь, пожалуйста", "Оставайтесь на линии", "Помощь уже едет", "Я вас понял", "Говорите громче, плохо слышно", "Выходите из квартиры", "Не кладите трубку", "Хорошо, записываю"], "ground_truth": {"incident_type": "medical", "dds": "03", "incident_code": "22350000", "notify": ["СМП", "ГКУ НТУ"], "required_facts": ["f_address", "f_what_happened", "f_who", "f_conscious", "f_caller"], "address": "дорога от Каширского шоссе в сторону деревни Истомиха, у остановки Домодедовское кладбище, рядом с КПП", "victims": 1}, "era_glonass": null, "tree": {"pregenerated": false}}, {"id": "t01-1-fire-container", "title": "Возгорание мусорного контейнера", "type": "fire", "level": "L2", "topics": ["fires"], "modes": ["training", "exam", "self"], "extends": "checklists/fire.yaml", "persona": {"base": "calm", "arc": [{"stage": "registration", "mood": "calm"}, {"stage": "interview", "mood": "calm"}]}, "background": null, "first_line": "Здравствуйте. Тут мусорный контейнер горит, у депо.", "signs": ["на улице", "мусор", "открытое пламя"], "ticket": 1, "position": 1, "outcome": "card", "dds_decision": {"expected": "accept", "reason": null}, "student_card": null, "facts": [{"id": "f_address", "value": "Москва, Депо, около станции Москва-Пассажирская Киевская, длинное помещение недалеко от участкового пункта полиции", "reveal_on": {"question": "q_address"}, "refined": "Москва, Московская железная дорога, Киевская, 1 км, дом 2, строение 2", "refine_on": "q_address_check"}, {"id": "f_what_burns", "value": "горит мусорный контейнер, пламя открытое", "reveal_on": {"question": "q_what_burns"}, "refined": null, "refine_on": null}, {"id": "f_people", "value": "пострадавших нет", "reveal_on": {"question": "q_people"}, "refined": null, "refine_on": null}, {"id": "f_caller", "value": "Сидоров Иван Сергеевич, телефон 916-126-34-71", "reveal_on": {"question": "q_caller"}, "refined": null, "refine_on": null}], "checklist": [{"id": "q_address_check", "question": "Уточните: происшествие по этому адресу, вы сейчас там?", "fact": null, "examples": ["Это точно Москва?", "Вы сами находитесь по этому адресу?", "Происшествие там же, где вы?", "Какой город?", "Это в черте города?", "Уточните адрес происшествия", "Вы смотрите на это или вам рассказали?", "Адрес происшествия и ваш адрес совпадают?"]}, {"id": "q_address", "question": "Назовите адрес: улица, дом, квартира, этаж?", "fact": "f_address", "examples": ["Какой у вас адрес?", "Где вы находитесь?", "На каком этаже?", "Номер дома и квартиры?"]}, {"id": "q_what_burns", "question": "Что именно горит?", "fact": "f_what_burns", "examples": ["Что горит?", "Что именно загорелось?", "Откуда идёт огонь?"]}, {"id": "q_people", "question": "Есть ли люди в помещении?", "fact": "f_people", "examples": ["Кто-то остался внутри?", "Все успели выйти?"]}, {"id": "q_smoke", "question": "Куда идёт дым, есть ли задымление подъезда?", "fact": null, "examples": ["Куда идёт дым?", "Есть ли задымление в подъезде?", "Сильный дым?"]}, {"id": "q_gas", "question": "Есть ли газ, можно ли перекрыть?", "fact": null, "examples": ["У вас газовая плита?", "Перекрыть газ сможете?"]}, {"id": "q_caller", "question": "Представьтесь и оставьте контактный телефон", "fact": "f_caller", "examples": ["Как вас зовут?", "Ваш номер телефона?"]}], "required_fields": ["address", "incident_type", "description"], "score_weights": {}, "not_questions": ["Служба 112, что случилось?", "Успокойтесь, пожалуйста", "Оставайтесь на линии", "Помощь уже едет", "Я вас понял", "Говорите громче, плохо слышно", "Выходите из квартиры", "Не кладите трубку", "Хорошо, записываю"], "ground_truth": {"incident_type": "fire", "dds": "01", "incident_code": "1010101", "notify": ["Служба 101", "Гор. Хозяйство", "ЦОДД", "Аппарат МЭРА", "ОАТИ", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "ГКУ НТУ", "ФСО", "ГУП МСР", "ЦУКБ Министерство обороны", "ГПБУ Мосэкомониторинг"], "required_facts": ["f_address", "f_what_burns", "f_people", "f_caller"], "address": "Москва, Московская железная дорога, Киевская, 1 км, дом 2, строение 2", "victims": 0}, "era_glonass": null, "tree": {"pregenerated": false}}], "pending_dds_scenarios": [], "operator_kio": null, "operator_scenario": null, "dds_live_cards": [{"original_index": 0, "scenario": {"id": "fire-apartment-l2", "title": "Пожар в квартире, паникующий заявитель", "type": "fire", "level": "L2", "topics": ["fires"], "modes": ["training", "exam", "self"], "extends": "checklists/fire.yaml", "persona": {"base": "panic", "arc": [{"stage": "registration", "mood": "panic"}, {"stage": "interview", "mood": "panic"}, {"stage": "dispatch", "mood": "worried"}]}, "background": {"loop": "fire_crackle.mp3", "gain_db": -18.0}, "first_line": "Алло! Алло! Помогите! Горим! А-а-а!", "signs": ["жилой дом", "балкон", "открытое пламя"], "ticket": null, "position": null, "outcome": "card", "dds_decision": {"expected": "accept", "reason": null}, "student_card": null, "facts": [{"id": "f_address", "value": "улица Ленина, 14, квартира 47, 5-й этаж", "reveal_on": {"question": "q_address"}, "refined": null, "refine_on": null}, {"id": "f_what_burns", "value": "горит балкон, дым пошёл в квартиру", "reveal_on": {"question": "q_what_burns"}, "refined": null, "refine_on": null}, {"id": "f_people", "value": "в квартире жена и ребёнок, не выходят", "reveal_on": {"question": "q_people"}, "refined": null, "refine_on": null}, {"id": "f_smoke", "value": "дым идёт в подъезд, на площадке ничего не видно", "reveal_on": {"question": "q_smoke"}, "refined": null, "refine_on": null}, {"id": "f_gas", "value": "плита газовая, вентиль в кухне, перекрыть не успел", "reveal_on": {"question": "q_gas"}, "refined": null, "refine_on": null}], "checklist": [{"id": "q_address", "question": "Назовите адрес: улица, дом, квартира, этаж?", "fact": "f_address", "examples": ["Какой у вас адрес?", "Где вы находитесь?", "На каком этаже?", "Номер дома и квартиры?"]}, {"id": "q_what_burns", "question": "Что именно горит?", "fact": "f_what_burns", "examples": ["Что горит?", "Что именно загорелось?", "Откуда идёт огонь?"]}, {"id": "q_people", "question": "Есть ли люди в помещении?", "fact": "f_people", "examples": ["Кто-то остался внутри?", "Все успели выйти?"]}, {"id": "q_smoke", "question": "Куда идёт дым, есть ли задымление подъезда?", "fact": "f_smoke", "examples": ["Куда идёт дым?", "Есть ли задымление в подъезде?", "Сильный дым?"]}, {"id": "q_gas", "question": "Есть ли газ, можно ли перекрыть?", "fact": "f_gas", "examples": ["У вас газовая плита?", "Перекрыть газ сможете?"]}, {"id": "q_caller", "question": "Представьтесь и оставьте контактный телефон", "fact": null, "examples": ["Как вас зовут?", "Ваш номер телефона?"]}], "required_fields": ["address", "floor", "incident_type", "dds", "victims_count", "description"], "score_weights": {}, "not_questions": ["Служба 112, что случилось?", "Успокойтесь, пожалуйста", "Оставайтесь на линии", "Помощь уже едет", "Я вас понял", "Говорите громче, плохо слышно", "Выходите из квартиры", "Не кладите трубку", "Хорошо, записываю"], "ground_truth": {"incident_type": "fire", "dds": "01", "incident_code": "1050201", "notify": ["Служба 101", "МВД", "СМП", "ЦЭМП", "Мособлгаз", "Гор. Хозяйство", "Мослифт", "ЦОДД", "Департамент РБиПК (ГКУ МОСБЕЗ)", "МКП, Аналитика", "Аппарат МЭРА", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "Мосжилинспекция", "ГКУ НТУ", "ФСО", "ДГП (Департамент градостроительной политики)", "ЦУКБ Министерство обороны"], "required_facts": ["f_address", "f_what_burns", "f_people", "f_smoke", "f_gas"], "address": "улица Ленина, 14", "victims": 2}, "era_glonass": null, "tree": {"pregenerated": false}}, "kio": {"card_id": "85c1845e-c97e-4573-a376-113bf53bb95e", "registered_at": "2026-09-26T21:28:46.373191Z", "response_status": "transferred", "caller_number": null, "caller_name": null, "caller_contact": null, "phone_on_scene": null, "language": "ru", "okato": null, "address": "улица Ленина, 14", "street": null, "building": null, "entrance": null, "floor": "5", "intercom_code": null, "coords": null, "incident_group": 1, "signs": ["жилой дом", "балкон", "открытое пламя"], "incident_code": "1050201", "incident_type": "fire", "description": "улица Ленина, 14, квартира 47, 5-й этаж; горит балкон, дым пошёл в квартиру; в квартире жена и ребёнок, не выходят", "victims_count": 2, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["Служба 101", "МВД", "СМП", "ЦЭМП", "Мособлгаз", "Гор. Хозяйство", "Мослифт", "ЦОДД", "Департамент РБиПК (ГКУ МОСБЕЗ)", "МКП, Аналитика", "Аппарат МЭРА", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "Мосжилинспекция", "ГКУ НТУ", "ФСО", "ДГП (Департамент градостроительной политики)", "ЦУКБ Министерство обороны"], "dds": "01", "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "dispatched_card": {"card_id": "85c1845e-c97e-4573-a376-113bf53bb95e", "registered_at": "2026-09-26T21:28:46.373191Z", "response_status": "transferred", "caller_number": null, "caller_name": null, "caller_contact": null, "phone_on_scene": null, "language": "ru", "okato": null, "address": "улица Ленина, 14", "street": null, "building": null, "entrance": null, "floor": "5", "intercom_code": null, "coords": null, "incident_group": 1, "signs": ["жилой дом", "балкон", "открытое пламя"], "incident_code": "1050201", "incident_type": "fire", "description": "улица Ленина, 14, квартира 47, 5-й этаж; горит балкон, дым пошёл в квартиру; в квартире жена и ребёнок, не выходят", "victims_count": 2, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["Служба 101", "МВД", "СМП", "ЦЭМП", "Мособлгаз", "Гор. Хозяйство", "Мослифт", "ЦОДД", "Департамент РБиПК (ГКУ МОСБЕЗ)", "МКП, Аналитика", "Аппарат МЭРА", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "Мосжилинспекция", "ГКУ НТУ", "ФСО", "ДГП (Департамент градостроительной политики)", "ЦУКБ Министерство обороны"], "dds": "01", "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "dispatched_at": "2026-09-26T21:28:46.373637+00:00", "timers": {"limits": {"answer": 8000, "interview": 75000, "dds_notify": 60000, "dds_ack": 30000, "card_fill": 180000, "dds_work": 180000, "zone_check": 30000, "callback": 10000, "close": 300000}, "items": {"dds_ack": {"elapsed_ms": 1, "attempt": 1, "stopped": true}, "close": {"elapsed_ms": 1, "attempt": 1, "stopped": false}, "interview": {"elapsed_ms": 0, "attempt": 1, "stopped": true}}}, "bounced_fields": [], "dds_log": [["crew.select", "2026-09-26T21:28:46.375082+00:00", "Служба 101 — бригада №1"]], "status_log": [{"service": "Служба 101", "status": "accepted", "at": "2026-09-26T21:28:46.375034Z", "comment": "Основание: доклад старшего.\nСведения: карточка принята.", "author": "диспетчер"}], "crew_selected": "Служба 101 — бригада №1", "crew_assignments": {"Служба 101": "Служба 101 — бригада №1"}, "phone_reports": [], "phone_lines": [], "phone_pending": null, "reply_text": "", "reply_log": []}, {"original_index": 1, "scenario": {"id": "t20-2-stroke", "title": "Речь невнятная, лицо перекошено", "type": "medical", "level": "L2", "topics": ["medical"], "modes": ["training", "exam", "self"], "extends": "checklists/medical.yaml", "persona": {"base": "worried", "arc": [{"stage": "registration", "mood": "worried"}, {"stage": "interview", "mood": "worried"}]}, "background": null, "first_line": "Женщине плохо, говорит невнятно, лицо перекосило.", "signs": ["Парализовало"], "ticket": 20, "position": 2, "outcome": "card", "dds_decision": {"expected": "accept", "reason": null}, "student_card": null, "facts": [{"id": "f_address", "value": "дорога от Каширского шоссе в сторону деревни Истомиха, около автобусной остановки Домодедовское кладбище, рядом с КПП", "reveal_on": {"question": "q_address"}, "refined": null, "refine_on": null}, {"id": "f_what_happened", "value": "речь невнятная, лицо перекошено; прохожий просит вызвать 03", "reveal_on": {"question": "q_what_happened"}, "refined": null, "refine_on": null}, {"id": "f_who", "value": "женщина на вид семидесяти лет, неизвестная", "reveal_on": {"question": "q_who"}, "refined": null, "refine_on": null}, {"id": "f_conscious", "value": "сведения о сознании женщины не указаны", "reveal_on": {"question": "q_conscious"}, "refined": null, "refine_on": null}, {"id": "f_caller", "value": "вызывает прохожий Титов Олег Иванович, телефон 916 896 3254", "reveal_on": {"question": "q_caller"}, "refined": null, "refine_on": null}], "checklist": [{"id": "q_address", "question": "Назовите адрес: улица, дом, квартира, этаж, код домофона?", "fact": "f_address", "examples": ["Какой у вас адрес?", "Где вы находитесь?", "Куда ехать скорой?", "Код домофона какой?", "Номер квартиры?"]}, {"id": "q_what_happened", "question": "Что случилось с пострадавшим?", "fact": "f_what_happened", "examples": ["Что с человеком?", "Что произошло?", "На что жалуется?", "Как это случилось?"]}, {"id": "q_who", "question": "Кто пострадавший: имя, возраст, пол?", "fact": "f_who", "examples": ["Сколько лет?", "Как зовут больного?", "Это мужчина или женщина?", "Возраст какой?"]}, {"id": "q_conscious", "question": "Человек в сознании, дышит?", "fact": "f_conscious", "examples": ["В сознании?", "Дышит?", "Он отвечает вам?", "Реагирует на голос?"]}, {"id": "q_can_move", "question": "Может ли пострадавший передвигаться самостоятельно?", "fact": null, "examples": ["Он может идти?", "Ходит сам?", "Встать может?"]}, {"id": "q_caller", "question": "Представьтесь и оставьте контактный телефон", "fact": "f_caller", "examples": ["Как вас зовут?", "Ваш номер телефона?", "Кем вы приходитесь пострадавшему?"]}], "required_fields": ["address", "incident_type", "description", "victims_count"], "score_weights": {}, "not_questions": ["Служба 112, что случилось?", "Успокойтесь, пожалуйста", "Оставайтесь на линии", "Помощь уже едет", "Я вас понял", "Говорите громче, плохо слышно", "Выходите из квартиры", "Не кладите трубку", "Хорошо, записываю"], "ground_truth": {"incident_type": "medical", "dds": "03", "incident_code": "22350000", "notify": ["СМП", "ГКУ НТУ"], "required_facts": ["f_address", "f_what_happened", "f_who", "f_conscious", "f_caller"], "address": "дорога от Каширского шоссе в сторону деревни Истомиха, у остановки Домодедовское кладбище, рядом с КПП", "victims": 1}, "era_glonass": null, "tree": {"pregenerated": false}}, "kio": {"card_id": "91966e62-a166-490c-81da-a590b06cbe51", "registered_at": "2026-09-26T21:28:46.374117Z", "response_status": "transferred", "caller_number": "916 896 3254", "caller_name": "Титов Олег Иванович", "caller_contact": "916 896 3254", "phone_on_scene": null, "language": "ru", "okato": null, "address": "дорога от Каширского шоссе в сторону деревни Истомиха, у остановки Домодедовское кладбище, рядом с КПП", "street": null, "building": null, "entrance": null, "floor": null, "intercom_code": null, "coords": null, "incident_group": 22, "signs": ["Парализовало"], "incident_code": "22350000", "incident_type": "medical", "description": "дорога от Каширского шоссе в сторону деревни Истомиха, около автобусной остановки Домодедовское кладбище, рядом с КПП; речь невнятная, лицо перекошено; прохожий просит вызвать 03; женщина на вид семидесяти лет, неизвестная", "victims_count": 1, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["СМП", "ГКУ НТУ"], "dds": "03", "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "dispatched_card": {"card_id": "91966e62-a166-490c-81da-a590b06cbe51", "registered_at": "2026-09-26T21:28:46.374117Z", "response_status": "transferred", "caller_number": "916 896 3254", "caller_name": "Титов Олег Иванович", "caller_contact": "916 896 3254", "phone_on_scene": null, "language": "ru", "okato": null, "address": "дорога от Каширского шоссе в сторону деревни Истомиха, у остановки Домодедовское кладбище, рядом с КПП", "street": null, "building": null, "entrance": null, "floor": null, "intercom_code": null, "coords": null, "incident_group": 22, "signs": ["Парализовало"], "incident_code": "22350000", "incident_type": "medical", "description": "дорога от Каширского шоссе в сторону деревни Истомиха, около автобусной остановки Домодедовское кладбище, рядом с КПП; речь невнятная, лицо перекошено; прохожий просит вызвать 03; женщина на вид семидесяти лет, неизвестная", "victims_count": 1, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["СМП", "ГКУ НТУ"], "dds": "03", "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "dispatched_at": "2026-09-26T21:28:46.374340+00:00", "timers": {"limits": {"answer": 8000, "interview": 75000, "dds_notify": 60000, "dds_ack": 30000, "card_fill": 180000, "dds_work": 180000, "zone_check": 30000, "callback": 10000, "close": 300000}, "items": {"dds_ack": {"elapsed_ms": 0, "attempt": 1, "stopped": false}, "close": {"elapsed_ms": 0, "attempt": 1, "stopped": false}, "interview": {"elapsed_ms": 0, "attempt": 1, "stopped": true}}}, "bounced_fields": [], "dds_log": [], "status_log": [], "crew_selected": null, "crew_assignments": {}, "phone_reports": [], "phone_lines": [], "phone_pending": null, "reply_text": "Ответ по второй карточке", "reply_log": [["2026-09-26T21:28:46.375115+00:00", "Ответ по второй карточке"]]}, {"original_index": 2, "scenario": {"id": "t01-1-fire-container", "title": "Возгорание мусорного контейнера", "type": "fire", "level": "L2", "topics": ["fires"], "modes": ["training", "exam", "self"], "extends": "checklists/fire.yaml", "persona": {"base": "calm", "arc": [{"stage": "registration", "mood": "calm"}, {"stage": "interview", "mood": "calm"}]}, "background": null, "first_line": "Здравствуйте. Тут мусорный контейнер горит, у депо.", "signs": ["на улице", "мусор", "открытое пламя"], "ticket": 1, "position": 1, "outcome": "card", "dds_decision": {"expected": "accept", "reason": null}, "student_card": null, "facts": [{"id": "f_address", "value": "Москва, Депо, около станции Москва-Пассажирская Киевская, длинное помещение недалеко от участкового пункта полиции", "reveal_on": {"question": "q_address"}, "refined": "Москва, Московская железная дорога, Киевская, 1 км, дом 2, строение 2", "refine_on": "q_address_check"}, {"id": "f_what_burns", "value": "горит мусорный контейнер, пламя открытое", "reveal_on": {"question": "q_what_burns"}, "refined": null, "refine_on": null}, {"id": "f_people", "value": "пострадавших нет", "reveal_on": {"question": "q_people"}, "refined": null, "refine_on": null}, {"id": "f_caller", "value": "Сидоров Иван Сергеевич, телефон 916-126-34-71", "reveal_on": {"question": "q_caller"}, "refined": null, "refine_on": null}], "checklist": [{"id": "q_address_check", "question": "Уточните: происшествие по этому адресу, вы сейчас там?", "fact": null, "examples": ["Это точно Москва?", "Вы сами находитесь по этому адресу?", "Происшествие там же, где вы?", "Какой город?", "Это в черте города?", "Уточните адрес происшествия", "Вы смотрите на это или вам рассказали?", "Адрес происшествия и ваш адрес совпадают?"]}, {"id": "q_address", "question": "Назовите адрес: улица, дом, квартира, этаж?", "fact": "f_address", "examples": ["Какой у вас адрес?", "Где вы находитесь?", "На каком этаже?", "Номер дома и квартиры?"]}, {"id": "q_what_burns", "question": "Что именно горит?", "fact": "f_what_burns", "examples": ["Что горит?", "Что именно загорелось?", "Откуда идёт огонь?"]}, {"id": "q_people", "question": "Есть ли люди в помещении?", "fact": "f_people", "examples": ["Кто-то остался внутри?", "Все успели выйти?"]}, {"id": "q_smoke", "question": "Куда идёт дым, есть ли задымление подъезда?", "fact": null, "examples": ["Куда идёт дым?", "Есть ли задымление в подъезде?", "Сильный дым?"]}, {"id": "q_gas", "question": "Есть ли газ, можно ли перекрыть?", "fact": null, "examples": ["У вас газовая плита?", "Перекрыть газ сможете?"]}, {"id": "q_caller", "question": "Представьтесь и оставьте контактный телефон", "fact": "f_caller", "examples": ["Как вас зовут?", "Ваш номер телефона?"]}], "required_fields": ["address", "incident_type", "description"], "score_weights": {}, "not_questions": ["Служба 112, что случилось?", "Успокойтесь, пожалуйста", "Оставайтесь на линии", "Помощь уже едет", "Я вас понял", "Говорите громче, плохо слышно", "Выходите из квартиры", "Не кладите трубку", "Хорошо, записываю"], "ground_truth": {"incident_type": "fire", "dds": "01", "incident_code": "1010101", "notify": ["Служба 101", "Гор. Хозяйство", "ЦОДД", "Аппарат МЭРА", "ОАТИ", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "ГКУ НТУ", "ФСО", "ГУП МСР", "ЦУКБ Министерство обороны", "ГПБУ Мосэкомониторинг"], "required_facts": ["f_address", "f_what_burns", "f_people", "f_caller"], "address": "Москва, Московская железная дорога, Киевская, 1 км, дом 2, строение 2", "victims": 0}, "era_glonass": null, "tree": {"pregenerated": false}}, "kio": {"card_id": "017cc2bd-fa73-4746-a656-bf5a7dcb6531", "registered_at": "2026-09-26T21:28:46.374694Z", "response_status": "transferred", "caller_number": "916-126-34-71", "caller_name": "Сидоров Иван Сергеевич", "caller_contact": "916-126-34-71", "phone_on_scene": null, "language": "ru", "okato": null, "address": "Москва, Московская железная дорога, Киевская, 1 км, дом 2, строение 2", "street": null, "building": null, "entrance": null, "floor": null, "intercom_code": null, "coords": null, "incident_group": 1, "signs": ["на улице", "мусор", "открытое пламя"], "incident_code": "1010101", "incident_type": "fire", "description": "Москва, Депо, около станции Москва-Пассажирская Киевская, длинное помещение недалеко от участкового пункта полиции; горит мусорный контейнер, пламя открытое; пострадавших нет", "victims_count": 0, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["Служба 101", "Гор. Хозяйство", "ЦОДД", "Аппарат МЭРА", "ОАТИ", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "ГКУ НТУ", "ФСО", "ГУП МСР", "ЦУКБ Министерство обороны", "ГПБУ Мосэкомониторинг"], "dds": "01", "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "dispatched_card": {"card_id": "017cc2bd-fa73-4746-a656-bf5a7dcb6531", "registered_at": "2026-09-26T21:28:46.374694Z", "response_status": "transferred", "caller_number": "916-126-34-71", "caller_name": "Сидоров Иван Сергеевич", "caller_contact": "916-126-34-71", "phone_on_scene": null, "language": "ru", "okato": null, "address": "Москва, Московская железная дорога, Киевская, 1 км, дом 2, строение 2", "street": null, "building": null, "entrance": null, "floor": null, "intercom_code": null, "coords": null, "incident_group": 1, "signs": ["на улице", "мусор", "открытое пламя"], "incident_code": "1010101", "incident_type": "fire", "description": "Москва, Депо, около станции Москва-Пассажирская Киевская, длинное помещение недалеко от участкового пункта полиции; горит мусорный контейнер, пламя открытое; пострадавших нет", "victims_count": 0, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["Служба 101", "Гор. Хозяйство", "ЦОДД", "Аппарат МЭРА", "ОАТИ", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "ГКУ НТУ", "ФСО", "ГУП МСР", "ЦУКБ Министерство обороны", "ГПБУ Мосэкомониторинг"], "dds": "01", "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "dispatched_at": "2026-09-26T21:28:46.374962+00:00", "timers": {"limits": {"answer": 8000, "interview": 75000, "dds_notify": 60000, "dds_ack": 30000, "card_fill": 180000, "dds_work": 180000, "zone_check": 30000, "callback": 10000, "close": 300000}, "items": {"dds_ack": {"elapsed_ms": 0, "attempt": 1, "stopped": false}, "close": {"elapsed_ms": 0, "attempt": 1, "stopped": false}, "interview": {"elapsed_ms": 0, "attempt": 1, "stopped": true}}}, "bounced_fields": [], "dds_log": [], "status_log": [], "crew_selected": null, "crew_assignments": {}, "phone_reports": [], "phone_lines": [], "phone_pending": null, "reply_text": "", "reply_log": []}], "dds_active_card_id": "91966e62-a166-490c-81da-a590b06cbe51", "dds_card_index": 1, "dds_arrival_interval_seconds": 0, "dds_max_waiting": 3, "dds_next_scenario_index": 3, "dds_next_arrival_at": null, "dds_completed": [], "reply_text": "Ответ по второй карточке", "reply_log": [["2026-09-26T21:28:46.375115+00:00", "Ответ по второй карточке"]], "text_revealed_facts": {}, "resolved_outcome": null, "resolve_comment": "", "processed_station_commands": []}, "call": {"version": 1, "session_id": "d3470132-b311-4a3a-bbd8-d503fcbf987b", "scenario_id": "fire-apartment-l2", "scenario_title": "Пожар в квартире, паникующий заявитель", "level": "L2", "mode": "training", "owner_login": null, "backend_fencing_epoch": 0, "exercise": "call", "handoff_to_dds": false, "required_fields": ["address", "floor", "incident_type", "dds", "victims_count", "description"], "trainee_name": null, "trainee_id": null, "dds_service": null, "attempt": 1, "criteria": {"decision_time_limit_seconds": 30, "card_fill_time_limit_seconds": 180, "dds_card_work_time_limit_seconds": 180, "allowed_errors": 0, "require_correct_grammar": true, "score_weights": {}}, "kio": {"card_id": "9f21be83-9749-4f65-a1b9-6bbc08e2c1de", "registered_at": null, "response_status": "transferred", "caller_number": null, "caller_name": null, "caller_contact": null, "phone_on_scene": null, "language": "ru", "okato": null, "address": "улица Ленина, 14", "street": null, "building": null, "entrance": null, "floor": null, "intercom_code": null, "coords": null, "incident_group": null, "signs": ["жилой дом", "балкон", "открытое пламя"], "incident_code": "1050201", "incident_type": null, "description": null, "victims_count": null, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["Служба 101", "МВД", "Мособлгаз", "Гор. Хозяйство", "Мослифт", "ЦОДД", "Департамент РБиПК (ГКУ МОСБЕЗ)", "МКП, Аналитика", "Аппарат МЭРА", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "Мосжилинспекция", "ГКУ НТУ", "ФСО", "ДГП (Департамент градостроительной политики)", "ЦУКБ Министерство обороны"], "dds": null, "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "transcript": [], "timers": {"limits": {"answer": 8000, "interview": 75000, "dds_notify": 60000, "dds_ack": 30000, "card_fill": 180000, "dds_work": 180000, "zone_check": 30000, "callback": 10000, "close": 300000}, "items": {"answer": {"elapsed_ms": 0, "attempt": 1, "stopped": true}, "interview": {"elapsed_ms": 0, "attempt": 1, "stopped": true}, "dds_ack": {"elapsed_ms": 0, "attempt": 1, "stopped": true}, "close": {"elapsed_ms": 0, "attempt": 1, "stopped": false}}}, "hints_shown": [], "hints_log": [], "notes": [], "directives": [], "scenario": {"id": "fire-apartment-l2", "title": "Пожар в квартире, паникующий заявитель", "type": "fire", "level": "L2", "topics": ["fires"], "modes": ["training", "exam", "self"], "extends": "checklists/fire.yaml", "persona": {"base": "panic", "arc": [{"stage": "registration", "mood": "panic"}, {"stage": "interview", "mood": "panic"}, {"stage": "dispatch", "mood": "worried"}]}, "background": {"loop": "fire_crackle.mp3", "gain_db": -18.0}, "first_line": "Алло! Алло! Помогите! Горим! А-а-а!", "signs": ["жилой дом", "балкон", "открытое пламя"], "ticket": null, "position": null, "outcome": "card", "dds_decision": {"expected": "accept", "reason": null}, "student_card": null, "facts": [{"id": "f_address", "value": "улица Ленина, 14, квартира 47, 5-й этаж", "reveal_on": {"question": "q_address"}, "refined": null, "refine_on": null}, {"id": "f_what_burns", "value": "горит балкон, дым пошёл в квартиру", "reveal_on": {"question": "q_what_burns"}, "refined": null, "refine_on": null}, {"id": "f_people", "value": "в квартире жена и ребёнок, не выходят", "reveal_on": {"question": "q_people"}, "refined": null, "refine_on": null}, {"id": "f_smoke", "value": "дым идёт в подъезд, на площадке ничего не видно", "reveal_on": {"question": "q_smoke"}, "refined": null, "refine_on": null}, {"id": "f_gas", "value": "плита газовая, вентиль в кухне, перекрыть не успел", "reveal_on": {"question": "q_gas"}, "refined": null, "refine_on": null}], "checklist": [{"id": "q_address", "question": "Назовите адрес: улица, дом, квартира, этаж?", "fact": "f_address", "examples": ["Какой у вас адрес?", "Где вы находитесь?", "На каком этаже?", "Номер дома и квартиры?"]}, {"id": "q_what_burns", "question": "Что именно горит?", "fact": "f_what_burns", "examples": ["Что горит?", "Что именно загорелось?", "Откуда идёт огонь?"]}, {"id": "q_people", "question": "Есть ли люди в помещении?", "fact": "f_people", "examples": ["Кто-то остался внутри?", "Все успели выйти?"]}, {"id": "q_smoke", "question": "Куда идёт дым, есть ли задымление подъезда?", "fact": "f_smoke", "examples": ["Куда идёт дым?", "Есть ли задымление в подъезде?", "Сильный дым?"]}, {"id": "q_gas", "question": "Есть ли газ, можно ли перекрыть?", "fact": "f_gas", "examples": ["У вас газовая плита?", "Перекрыть газ сможете?"]}, {"id": "q_caller", "question": "Представьтесь и оставьте контактный телефон", "fact": null, "examples": ["Как вас зовут?", "Ваш номер телефона?"]}], "required_fields": ["address", "floor", "incident_type", "dds", "victims_count", "description"], "score_weights": {}, "not_questions": ["Служба 112, что случилось?", "Успокойтесь, пожалуйста", "Оставайтесь на линии", "Помощь уже едет", "Я вас понял", "Говорите громче, плохо слышно", "Выходите из квартиры", "Не кладите трубку", "Хорошо, записываю"], "ground_truth": {"incident_type": "fire", "dds": "01", "incident_code": "1050201", "notify": ["Служба 101", "МВД", "СМП", "ЦЭМП", "Мособлгаз", "Гор. Хозяйство", "Мослифт", "ЦОДД", "Департамент РБиПК (ГКУ МОСБЕЗ)", "МКП, Аналитика", "Аппарат МЭРА", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "Мосжилинспекция", "ГКУ НТУ", "ФСО", "ДГП (Департамент градостроительной политики)", "ЦУКБ Министерство обороны"], "required_facts": ["f_address", "f_what_burns", "f_people", "f_smoke", "f_gas"], "address": "улица Ленина, 14", "victims": 2}, "era_glonass": null, "tree": {"pregenerated": false}}, "audio_frames": 0, "bad_frames": 0, "self_assessed": false, "self_assessment": null, "score": null, "started_at": null, "ended_at": null, "end_reason": null, "dispatched_card": {"card_id": "9f21be83-9749-4f65-a1b9-6bbc08e2c1de", "registered_at": null, "response_status": "transferred", "caller_number": null, "caller_name": null, "caller_contact": null, "phone_on_scene": null, "language": "ru", "okato": null, "address": "улица Ленина, 14", "street": null, "building": null, "entrance": null, "floor": null, "intercom_code": null, "coords": null, "incident_group": null, "signs": ["жилой дом", "балкон", "открытое пламя"], "incident_code": "1050201", "incident_type": null, "description": null, "victims_count": null, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["Служба 101", "МВД", "Мособлгаз", "Гор. Хозяйство", "Мослифт", "ЦОДД", "Департамент РБиПК (ГКУ МОСБЕЗ)", "МКП, Аналитика", "Аппарат МЭРА", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "Мосжилинспекция", "ГКУ НТУ", "ФСО", "ДГП (Департамент градостроительной политики)", "ЦУКБ Министерство обороны"], "dds": null, "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "dispatched_at": "2026-09-26T21:28:46.384104+00:00", "bounced_fields": [], "dds_log": [["crew.select", "2026-09-26T21:28:46.384182+00:00", "Служба 101 — бригада №1"]], "status_log": [{"service": "Служба 101", "status": "accepted", "at": "2026-09-26T21:28:46.384136Z", "comment": "Основание: доклад старшего.\nСведения: карточка принята.", "author": "диспетчер"}], "crew_selected": "Служба 101 — бригада №1", "crew_assignments": {"Служба 101": "Служба 101 — бригада №1"}, "phone_reports": [], "phone_lines": [], "phone_pending": null, "dds_scenarios": [], "pending_dds_scenarios": [], "operator_kio": null, "operator_scenario": null, "dds_live_cards": [], "dds_active_card_id": null, "dds_card_index": 0, "dds_arrival_interval_seconds": 0, "dds_max_waiting": 3, "dds_next_scenario_index": 0, "dds_next_arrival_at": null, "dds_completed": [], "reply_text": "", "reply_log": [], "text_revealed_facts": {}, "resolved_outcome": null, "resolve_comment": "", "processed_station_commands": []}, "handoff": {"version": 1, "session_id": "ad457f80-7401-4691-bf35-d8dd2b5e978d", "scenario_id": "fire-apartment-l2", "scenario_title": "Пожар в квартире, паникующий заявитель", "level": "L2", "mode": "training", "owner_login": null, "backend_fencing_epoch": 0, "exercise": "card", "handoff_to_dds": true, "required_fields": ["address", "floor", "incident_type", "dds", "victims_count", "description"], "trainee_name": null, "trainee_id": null, "dds_service": null, "attempt": 1, "criteria": {"decision_time_limit_seconds": 30, "card_fill_time_limit_seconds": 180, "dds_card_work_time_limit_seconds": 180, "allowed_errors": 0, "require_correct_grammar": true, "score_weights": {}}, "kio": {"card_id": "2592720c-1463-4a25-a2a2-343f8fe30194", "registered_at": null, "response_status": "transferred", "caller_number": null, "caller_name": null, "caller_contact": null, "phone_on_scene": null, "language": "ru", "okato": null, "address": "улица Ленина, 14", "street": null, "building": null, "entrance": null, "floor": null, "intercom_code": null, "coords": null, "incident_group": null, "signs": [], "incident_code": null, "incident_type": null, "description": "горит балкон", "victims_count": null, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["Служба 101"], "dds": null, "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "transcript": [], "timers": {"limits": {"answer": 8000, "interview": 75000, "dds_notify": 60000, "dds_ack": 30000, "card_fill": 180000, "dds_work": 180000, "zone_check": 30000, "callback": 10000, "close": 300000}, "items": {"dds_ack": {"elapsed_ms": 0, "attempt": 1, "stopped": false}, "close": {"elapsed_ms": 0, "attempt": 1, "stopped": false}, "interview": {"elapsed_ms": 0, "attempt": 1, "stopped": true}}}, "hints_shown": [], "hints_log": [], "notes": [], "directives": [], "scenario": {"id": "fire-apartment-l2", "title": "Пожар в квартире, паникующий заявитель", "type": "fire", "level": "L2", "topics": ["fires"], "modes": ["training", "exam", "self"], "extends": "checklists/fire.yaml", "persona": {"base": "panic", "arc": [{"stage": "registration", "mood": "panic"}, {"stage": "interview", "mood": "panic"}, {"stage": "dispatch", "mood": "worried"}]}, "background": {"loop": "fire_crackle.mp3", "gain_db": -18.0}, "first_line": "Алло! Алло! Помогите! Горим! А-а-а!", "signs": ["жилой дом", "балкон", "открытое пламя"], "ticket": null, "position": null, "outcome": "card", "dds_decision": {"expected": "accept", "reason": null}, "student_card": null, "facts": [{"id": "f_address", "value": "улица Ленина, 14, квартира 47, 5-й этаж", "reveal_on": {"question": "q_address"}, "refined": null, "refine_on": null}, {"id": "f_what_burns", "value": "горит балкон, дым пошёл в квартиру", "reveal_on": {"question": "q_what_burns"}, "refined": null, "refine_on": null}, {"id": "f_people", "value": "в квартире жена и ребёнок, не выходят", "reveal_on": {"question": "q_people"}, "refined": null, "refine_on": null}, {"id": "f_smoke", "value": "дым идёт в подъезд, на площадке ничего не видно", "reveal_on": {"question": "q_smoke"}, "refined": null, "refine_on": null}, {"id": "f_gas", "value": "плита газовая, вентиль в кухне, перекрыть не успел", "reveal_on": {"question": "q_gas"}, "refined": null, "refine_on": null}], "checklist": [{"id": "q_address", "question": "Назовите адрес: улица, дом, квартира, этаж?", "fact": "f_address", "examples": ["Какой у вас адрес?", "Где вы находитесь?", "На каком этаже?", "Номер дома и квартиры?"]}, {"id": "q_what_burns", "question": "Что именно горит?", "fact": "f_what_burns", "examples": ["Что горит?", "Что именно загорелось?", "Откуда идёт огонь?"]}, {"id": "q_people", "question": "Есть ли люди в помещении?", "fact": "f_people", "examples": ["Кто-то остался внутри?", "Все успели выйти?"]}, {"id": "q_smoke", "question": "Куда идёт дым, есть ли задымление подъезда?", "fact": "f_smoke", "examples": ["Куда идёт дым?", "Есть ли задымление в подъезде?", "Сильный дым?"]}, {"id": "q_gas", "question": "Есть ли газ, можно ли перекрыть?", "fact": "f_gas", "examples": ["У вас газовая плита?", "Перекрыть газ сможете?"]}, {"id": "q_caller", "question": "Представьтесь и оставьте контактный телефон", "fact": null, "examples": ["Как вас зовут?", "Ваш номер телефона?"]}], "required_fields": ["address", "floor", "incident_type", "dds", "victims_count", "description"], "score_weights": {}, "not_questions": ["Служба 112, что случилось?", "Успокойтесь, пожалуйста", "Оставайтесь на линии", "Помощь уже едет", "Я вас понял", "Говорите громче, плохо слышно", "Выходите из квартиры", "Не кладите трубку", "Хорошо, записываю"], "ground_truth": {"incident_type": "fire", "dds": "01", "incident_code": "1050201", "notify": ["Служба 101", "МВД", "СМП", "ЦЭМП", "Мособлгаз", "Гор. Хозяйство", "Мослифт", "ЦОДД", "Департамент РБиПК (ГКУ МОСБЕЗ)", "МКП, Аналитика", "Аппарат МЭРА", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "Мосжилинспекция", "ГКУ НТУ", "ФСО", "ДГП (Департамент градостроительной политики)", "ЦУКБ Министерство обороны"], "required_facts": ["f_address", "f_what_burns", "f_people", "f_smoke", "f_gas"], "address": "улица Ленина, 14", "victims": 2}, "era_glonass": null, "tree": {"pregenerated": false}}, "audio_frames": 0, "bad_frames": 0, "self_assessed": false, "self_assessment": null, "score": null, "started_at": null, "ended_at": null, "end_reason": null, "dispatched_card": {"card_id": "2592720c-1463-4a25-a2a2-343f8fe30194", "registered_at": null, "response_status": "transferred", "caller_number": null, "caller_name": null, "caller_contact": null, "phone_on_scene": null, "language": "ru", "okato": null, "address": "улица Ленина, 14", "street": null, "building": null, "entrance": null, "floor": null, "intercom_code": null, "coords": null, "incident_group": null, "signs": [], "incident_code": null, "incident_type": null, "description": "горит балкон", "victims_count": null, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["Служба 101"], "dds": null, "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "dispatched_at": "2026-09-26T21:28:46.391009+00:00", "bounced_fields": [], "dds_log": [], "status_log": [{"service": "Служба 101", "status": "accepted", "at": "2026-09-26T21:28:46.392282Z", "comment": "Основание: доклад старшего.\nСведения: карточка принята.", "author": "диспетчер"}], "crew_selected": null, "crew_assignments": {}, "phone_reports": [], "phone_lines": [], "phone_pending": null, "dds_scenarios": [{"id": "fire-apartment-l2", "title": "Пожар в квартире, паникующий заявитель", "type": "fire", "level": "L2", "topics": ["fires"], "modes": ["training", "exam", "self"], "extends": "checklists/fire.yaml", "persona": {"base": "panic", "arc": [{"stage": "registration", "mood": "panic"}, {"stage": "interview", "mood": "panic"}, {"stage": "dispatch", "mood": "worried"}]}, "background": {"loop": "fire_crackle.mp3", "gain_db": -18.0}, "first_line": "Алло! Алло! Помогите! Горим! А-а-а!", "signs": ["жилой дом", "балкон", "открытое пламя"], "ticket": null, "position": null, "outcome": "card", "dds_decision": {"expected": "accept", "reason": null}, "student_card": null, "facts": [{"id": "f_address", "value": "улица Ленина, 14, квартира 47, 5-й этаж", "reveal_on": {"question": "q_address"}, "refined": null, "refine_on": null}, {"id": "f_what_burns", "value": "горит балкон, дым пошёл в квартиру", "reveal_on": {"question": "q_what_burns"}, "refined": null, "refine_on": null}, {"id": "f_people", "value": "в квартире жена и ребёнок, не выходят", "reveal_on": {"question": "q_people"}, "refined": null, "refine_on": null}, {"id": "f_smoke", "value": "дым идёт в подъезд, на площадке ничего не видно", "reveal_on": {"question": "q_smoke"}, "refined": null, "refine_on": null}, {"id": "f_gas", "value": "плита газовая, вентиль в кухне, перекрыть не успел", "reveal_on": {"question": "q_gas"}, "refined": null, "refine_on": null}], "checklist": [{"id": "q_address", "question": "Назовите адрес: улица, дом, квартира, этаж?", "fact": "f_address", "examples": ["Какой у вас адрес?", "Где вы находитесь?", "На каком этаже?", "Номер дома и квартиры?"]}, {"id": "q_what_burns", "question": "Что именно горит?", "fact": "f_what_burns", "examples": ["Что горит?", "Что именно загорелось?", "Откуда идёт огонь?"]}, {"id": "q_people", "question": "Есть ли люди в помещении?", "fact": "f_people", "examples": ["Кто-то остался внутри?", "Все успели выйти?"]}, {"id": "q_smoke", "question": "Куда идёт дым, есть ли задымление подъезда?", "fact": "f_smoke", "examples": ["Куда идёт дым?", "Есть ли задымление в подъезде?", "Сильный дым?"]}, {"id": "q_gas", "question": "Есть ли газ, можно ли перекрыть?", "fact": "f_gas", "examples": ["У вас газовая плита?", "Перекрыть газ сможете?"]}, {"id": "q_caller", "question": "Представьтесь и оставьте контактный телефон", "fact": null, "examples": ["Как вас зовут?", "Ваш номер телефона?"]}], "required_fields": ["address", "floor", "incident_type", "dds", "victims_count", "description"], "score_weights": {}, "not_questions": ["Служба 112, что случилось?", "Успокойтесь, пожалуйста", "Оставайтесь на линии", "Помощь уже едет", "Я вас понял", "Говорите громче, плохо слышно", "Выходите из квартиры", "Не кладите трубку", "Хорошо, записываю"], "ground_truth": {"incident_type": "fire", "dds": "01", "incident_code": "1050201", "notify": ["Служба 101", "МВД", "СМП", "ЦЭМП", "Мособлгаз", "Гор. Хозяйство", "Мослифт", "ЦОДД", "Департамент РБиПК (ГКУ МОСБЕЗ)", "МКП, Аналитика", "Аппарат МЭРА", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "Мосжилинспекция", "ГКУ НТУ", "ФСО", "ДГП (Департамент градостроительной политики)", "ЦУКБ Министерство обороны"], "required_facts": ["f_address", "f_what_burns", "f_people", "f_smoke", "f_gas"], "address": "улица Ленина, 14", "victims": 2}, "era_glonass": null, "tree": {"pregenerated": false}}, {"id": "t01-1-fire-container", "title": "Возгорание мусорного контейнера", "type": "fire", "level": "L2", "topics": ["fires"], "modes": ["training", "exam", "self"], "extends": "checklists/fire.yaml", "persona": {"base": "calm", "arc": [{"stage": "registration", "mood": "calm"}, {"stage": "interview", "mood": "calm"}]}, "background": null, "first_line": "Здравствуйте. Тут мусорный контейнер горит, у депо.", "signs": ["на улице", "мусор", "открытое пламя"], "ticket": 1, "position": 1, "outcome": "card", "dds_decision": {"expected": "accept", "reason": null}, "student_card": null, "facts": [{"id": "f_address", "value": "Москва, Депо, около станции Москва-Пассажирская Киевская, длинное помещение недалеко от участкового пункта полиции", "reveal_on": {"question": "q_address"}, "refined": "Москва, Московская железная дорога, Киевская, 1 км, дом 2, строение 2", "refine_on": "q_address_check"}, {"id": "f_what_burns", "value": "горит мусорный контейнер, пламя открытое", "reveal_on": {"question": "q_what_burns"}, "refined": null, "refine_on": null}, {"id": "f_people", "value": "пострадавших нет", "reveal_on": {"question": "q_people"}, "refined": null, "refine_on": null}, {"id": "f_caller", "value": "Сидоров Иван Сергеевич, телефон 916-126-34-71", "reveal_on": {"question": "q_caller"}, "refined": null, "refine_on": null}], "checklist": [{"id": "q_address_check", "question": "Уточните: происшествие по этому адресу, вы сейчас там?", "fact": null, "examples": ["Это точно Москва?", "Вы сами находитесь по этому адресу?", "Происшествие там же, где вы?", "Какой город?", "Это в черте города?", "Уточните адрес происшествия", "Вы смотрите на это или вам рассказали?", "Адрес происшествия и ваш адрес совпадают?"]}, {"id": "q_address", "question": "Назовите адрес: улица, дом, квартира, этаж?", "fact": "f_address", "examples": ["Какой у вас адрес?", "Где вы находитесь?", "На каком этаже?", "Номер дома и квартиры?"]}, {"id": "q_what_burns", "question": "Что именно горит?", "fact": "f_what_burns", "examples": ["Что горит?", "Что именно загорелось?", "Откуда идёт огонь?"]}, {"id": "q_people", "question": "Есть ли люди в помещении?", "fact": "f_people", "examples": ["Кто-то остался внутри?", "Все успели выйти?"]}, {"id": "q_smoke", "question": "Куда идёт дым, есть ли задымление подъезда?", "fact": null, "examples": ["Куда идёт дым?", "Есть ли задымление в подъезде?", "Сильный дым?"]}, {"id": "q_gas", "question": "Есть ли газ, можно ли перекрыть?", "fact": null, "examples": ["У вас газовая плита?", "Перекрыть газ сможете?"]}, {"id": "q_caller", "question": "Представьтесь и оставьте контактный телефон", "fact": "f_caller", "examples": ["Как вас зовут?", "Ваш номер телефона?"]}], "required_fields": ["address", "incident_type", "description"], "score_weights": {}, "not_questions": ["Служба 112, что случилось?", "Успокойтесь, пожалуйста", "Оставайтесь на линии", "Помощь уже едет", "Я вас понял", "Говорите громче, плохо слышно", "Выходите из квартиры", "Не кладите трубку", "Хорошо, записываю"], "ground_truth": {"incident_type": "fire", "dds": "01", "incident_code": "1010101", "notify": ["Служба 101", "Гор. Хозяйство", "ЦОДД", "Аппарат МЭРА", "ОАТИ", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "ГКУ НТУ", "ФСО", "ГУП МСР", "ЦУКБ Министерство обороны", "ГПБУ Мосэкомониторинг"], "required_facts": ["f_address", "f_what_burns", "f_people", "f_caller"], "address": "Москва, Московская железная дорога, Киевская, 1 км, дом 2, строение 2", "victims": 0}, "era_glonass": null, "tree": {"pregenerated": false}}], "pending_dds_scenarios": [], "operator_kio": {"card_id": "2592720c-1463-4a25-a2a2-343f8fe30194", "registered_at": null, "response_status": "transferred", "caller_number": null, "caller_name": null, "caller_contact": null, "phone_on_scene": null, "language": "ru", "okato": null, "address": "улица Ленина, 14", "street": null, "building": null, "entrance": null, "floor": null, "intercom_code": null, "coords": null, "incident_group": null, "signs": [], "incident_code": null, "incident_type": null, "description": "горит балкон", "victims_count": null, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["Служба 101"], "dds": null, "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "operator_scenario": {"id": "fire-apartment-l2", "title": "Пожар в квартире, паникующий заявитель", "type": "fire", "level": "L2", "topics": ["fires"], "modes": ["training", "exam", "self"], "extends": "checklists/fire.yaml", "persona": {"base": "panic", "arc": [{"stage": "registration", "mood": "panic"}, {"stage": "interview", "mood": "panic"}, {"stage": "dispatch", "mood": "worried"}]}, "background": {"loop": "fire_crackle.mp3", "gain_db": -18.0}, "first_line": "Алло! Алло! Помогите! Горим! А-а-а!", "signs": ["жилой дом", "балкон", "открытое пламя"], "ticket": null, "position": null, "outcome": "card", "dds_decision": {"expected": "accept", "reason": null}, "student_card": null, "facts": [{"id": "f_address", "value": "улица Ленина, 14, квартира 47, 5-й этаж", "reveal_on": {"question": "q_address"}, "refined": null, "refine_on": null}, {"id": "f_what_burns", "value": "горит балкон, дым пошёл в квартиру", "reveal_on": {"question": "q_what_burns"}, "refined": null, "refine_on": null}, {"id": "f_people", "value": "в квартире жена и ребёнок, не выходят", "reveal_on": {"question": "q_people"}, "refined": null, "refine_on": null}, {"id": "f_smoke", "value": "дым идёт в подъезд, на площадке ничего не видно", "reveal_on": {"question": "q_smoke"}, "refined": null, "refine_on": null}, {"id": "f_gas", "value": "плита газовая, вентиль в кухне, перекрыть не успел", "reveal_on": {"question": "q_gas"}, "refined": null, "refine_on": null}], "checklist": [{"id": "q_address", "question": "Назовите адрес: улица, дом, квартира, этаж?", "fact": "f_address", "examples": ["Какой у вас адрес?", "Где вы находитесь?", "На каком этаже?", "Номер дома и квартиры?"]}, {"id": "q_what_burns", "question": "Что именно горит?", "fact": "f_what_burns", "examples": ["Что горит?", "Что именно загорелось?", "Откуда идёт огонь?"]}, {"id": "q_people", "question": "Есть ли люди в помещении?", "fact": "f_people", "examples": ["Кто-то остался внутри?", "Все успели выйти?"]}, {"id": "q_smoke", "question": "Куда идёт дым, есть ли задымление подъезда?", "fact": "f_smoke", "examples": ["Куда идёт дым?", "Есть ли задымление в подъезде?", "Сильный дым?"]}, {"id": "q_gas", "question": "Есть ли газ, можно ли перекрыть?", "fact": "f_gas", "examples": ["У вас газовая плита?", "Перекрыть газ сможете?"]}, {"id": "q_caller", "question": "Представьтесь и оставьте контактный телефон", "fact": null, "examples": ["Как вас зовут?", "Ваш номер телефона?"]}], "required_fields": ["address", "floor", "incident_type", "dds", "victims_count", "description"], "score_weights": {}, "not_questions": ["Служба 112, что случилось?", "Успокойтесь, пожалуйста", "Оставайтесь на линии", "Помощь уже едет", "Я вас понял", "Говорите громче, плохо слышно", "Выходите из квартиры", "Не кладите трубку", "Хорошо, записываю"], "ground_truth": {"incident_type": "fire", "dds": "01", "incident_code": "1050201", "notify": ["Служба 101", "МВД", "СМП", "ЦЭМП", "Мособлгаз", "Гор. Хозяйство", "Мослифт", "ЦОДД", "Департамент РБиПК (ГКУ МОСБЕЗ)", "МКП, Аналитика", "Аппарат МЭРА", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "Мосжилинспекция", "ГКУ НТУ", "ФСО", "ДГП (Департамент градостроительной политики)", "ЦУКБ Министерство обороны"], "required_facts": ["f_address", "f_what_burns", "f_people", "f_smoke", "f_gas"], "address": "улица Ленина, 14", "victims": 2}, "era_glonass": null, "tree": {"pregenerated": false}}, "dds_live_cards": [{"original_index": 0, "scenario": {"id": "fire-apartment-l2", "title": "Пожар в квартире, паникующий заявитель", "type": "fire", "level": "L2", "topics": ["fires"], "modes": ["training", "exam", "self"], "extends": "checklists/fire.yaml", "persona": {"base": "panic", "arc": [{"stage": "registration", "mood": "panic"}, {"stage": "interview", "mood": "panic"}, {"stage": "dispatch", "mood": "worried"}]}, "background": {"loop": "fire_crackle.mp3", "gain_db": -18.0}, "first_line": "Алло! Алло! Помогите! Горим! А-а-а!", "signs": ["жилой дом", "балкон", "открытое пламя"], "ticket": null, "position": null, "outcome": "card", "dds_decision": {"expected": "accept", "reason": null}, "student_card": null, "facts": [{"id": "f_address", "value": "улица Ленина, 14, квартира 47, 5-й этаж", "reveal_on": {"question": "q_address"}, "refined": null, "refine_on": null}, {"id": "f_what_burns", "value": "горит балкон, дым пошёл в квартиру", "reveal_on": {"question": "q_what_burns"}, "refined": null, "refine_on": null}, {"id": "f_people", "value": "в квартире жена и ребёнок, не выходят", "reveal_on": {"question": "q_people"}, "refined": null, "refine_on": null}, {"id": "f_smoke", "value": "дым идёт в подъезд, на площадке ничего не видно", "reveal_on": {"question": "q_smoke"}, "refined": null, "refine_on": null}, {"id": "f_gas", "value": "плита газовая, вентиль в кухне, перекрыть не успел", "reveal_on": {"question": "q_gas"}, "refined": null, "refine_on": null}], "checklist": [{"id": "q_address", "question": "Назовите адрес: улица, дом, квартира, этаж?", "fact": "f_address", "examples": ["Какой у вас адрес?", "Где вы находитесь?", "На каком этаже?", "Номер дома и квартиры?"]}, {"id": "q_what_burns", "question": "Что именно горит?", "fact": "f_what_burns", "examples": ["Что горит?", "Что именно загорелось?", "Откуда идёт огонь?"]}, {"id": "q_people", "question": "Есть ли люди в помещении?", "fact": "f_people", "examples": ["Кто-то остался внутри?", "Все успели выйти?"]}, {"id": "q_smoke", "question": "Куда идёт дым, есть ли задымление подъезда?", "fact": "f_smoke", "examples": ["Куда идёт дым?", "Есть ли задымление в подъезде?", "Сильный дым?"]}, {"id": "q_gas", "question": "Есть ли газ, можно ли перекрыть?", "fact": "f_gas", "examples": ["У вас газовая плита?", "Перекрыть газ сможете?"]}, {"id": "q_caller", "question": "Представьтесь и оставьте контактный телефон", "fact": null, "examples": ["Как вас зовут?", "Ваш номер телефона?"]}], "required_fields": ["address", "floor", "incident_type", "dds", "victims_count", "description"], "score_weights": {}, "not_questions": ["Служба 112, что случилось?", "Успокойтесь, пожалуйста", "Оставайтесь на линии", "Помощь уже едет", "Я вас понял", "Говорите громче, плохо слышно", "Выходите из квартиры", "Не кладите трубку", "Хорошо, записываю"], "ground_truth": {"incident_type": "fire", "dds": "01", "incident_code": "1050201", "notify": ["Служба 101", "МВД", "СМП", "ЦЭМП", "Мособлгаз", "Гор. Хозяйство", "Мослифт", "ЦОДД", "Департамент РБиПК (ГКУ МОСБЕЗ)", "МКП, Аналитика", "Аппарат МЭРА", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "Мосжилинспекция", "ГКУ НТУ", "ФСО", "ДГП (Департамент градостроительной политики)", "ЦУКБ Министерство обороны"], "required_facts": ["f_address", "f_what_burns", "f_people", "f_smoke", "f_gas"], "address": "улица Ленина, 14", "victims": 2}, "era_glonass": null, "tree": {"pregenerated": false}}, "kio": {"card_id": "2592720c-1463-4a25-a2a2-343f8fe30194", "registered_at": null, "response_status": "transferred", "caller_number": null, "caller_name": null, "caller_contact": null, "phone_on_scene": null, "language": "ru", "okato": null, "address": "улица Ленина, 14", "street": null, "building": null, "entrance": null, "floor": null, "intercom_code": null, "coords": null, "incident_group": null, "signs": [], "incident_code": null, "incident_type": null, "description": "горит балкон", "victims_count": null, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["Служба 101"], "dds": null, "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "dispatched_card": {"card_id": "2592720c-1463-4a25-a2a2-343f8fe30194", "registered_at": null, "response_status": "transferred", "caller_number": null, "caller_name": null, "caller_contact": null, "phone_on_scene": null, "language": "ru", "okato": null, "address": "улица Ленина, 14", "street": null, "building": null, "entrance": null, "floor": null, "intercom_code": null, "coords": null, "incident_group": null, "signs": [], "incident_code": null, "incident_type": null, "description": "горит балкон", "victims_count": null, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["Служба 101"], "dds": null, "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "dispatched_at": "2026-09-26T21:28:46.391009+00:00", "timers": {"limits": {"answer": 8000, "interview": 75000, "dds_notify": 60000, "dds_ack": 30000, "card_fill": 180000, "dds_work": 180000, "zone_check": 30000, "callback": 10000, "close": 300000}, "items": {"dds_ack": {"elapsed_ms": 0, "attempt": 1, "stopped": false}, "close": {"elapsed_ms": 0, "attempt": 1, "stopped": false}, "interview": {"elapsed_ms": 0, "attempt": 1, "stopped": true}}}, "bounced_fields": [], "dds_log": [], "status_log": [{"service": "Служба 101", "status": "accepted", "at": "2026-09-26T21:28:46.392282Z", "comment": "Основание: доклад старшего.\nСведения: карточка принята.", "author": "диспетчер"}], "crew_selected": null, "crew_assignments": {}, "phone_reports": [], "phone_lines": [], "phone_pending": null, "reply_text": "", "reply_log": []}, {"original_index": 1, "scenario": {"id": "t01-1-fire-container", "title": "Возгорание мусорного контейнера", "type": "fire", "level": "L2", "topics": ["fires"], "modes": ["training", "exam", "self"], "extends": "checklists/fire.yaml", "persona": {"base": "calm", "arc": [{"stage": "registration", "mood": "calm"}, {"stage": "interview", "mood": "calm"}]}, "background": null, "first_line": "Здравствуйте. Тут мусорный контейнер горит, у депо.", "signs": ["на улице", "мусор", "открытое пламя"], "ticket": 1, "position": 1, "outcome": "card", "dds_decision": {"expected": "accept", "reason": null}, "student_card": null, "facts": [{"id": "f_address", "value": "Москва, Депо, около станции Москва-Пассажирская Киевская, длинное помещение недалеко от участкового пункта полиции", "reveal_on": {"question": "q_address"}, "refined": "Москва, Московская железная дорога, Киевская, 1 км, дом 2, строение 2", "refine_on": "q_address_check"}, {"id": "f_what_burns", "value": "горит мусорный контейнер, пламя открытое", "reveal_on": {"question": "q_what_burns"}, "refined": null, "refine_on": null}, {"id": "f_people", "value": "пострадавших нет", "reveal_on": {"question": "q_people"}, "refined": null, "refine_on": null}, {"id": "f_caller", "value": "Сидоров Иван Сергеевич, телефон 916-126-34-71", "reveal_on": {"question": "q_caller"}, "refined": null, "refine_on": null}], "checklist": [{"id": "q_address_check", "question": "Уточните: происшествие по этому адресу, вы сейчас там?", "fact": null, "examples": ["Это точно Москва?", "Вы сами находитесь по этому адресу?", "Происшествие там же, где вы?", "Какой город?", "Это в черте города?", "Уточните адрес происшествия", "Вы смотрите на это или вам рассказали?", "Адрес происшествия и ваш адрес совпадают?"]}, {"id": "q_address", "question": "Назовите адрес: улица, дом, квартира, этаж?", "fact": "f_address", "examples": ["Какой у вас адрес?", "Где вы находитесь?", "На каком этаже?", "Номер дома и квартиры?"]}, {"id": "q_what_burns", "question": "Что именно горит?", "fact": "f_what_burns", "examples": ["Что горит?", "Что именно загорелось?", "Откуда идёт огонь?"]}, {"id": "q_people", "question": "Есть ли люди в помещении?", "fact": "f_people", "examples": ["Кто-то остался внутри?", "Все успели выйти?"]}, {"id": "q_smoke", "question": "Куда идёт дым, есть ли задымление подъезда?", "fact": null, "examples": ["Куда идёт дым?", "Есть ли задымление в подъезде?", "Сильный дым?"]}, {"id": "q_gas", "question": "Есть ли газ, можно ли перекрыть?", "fact": null, "examples": ["У вас газовая плита?", "Перекрыть газ сможете?"]}, {"id": "q_caller", "question": "Представьтесь и оставьте контактный телефон", "fact": "f_caller", "examples": ["Как вас зовут?", "Ваш номер телефона?"]}], "required_fields": ["address", "incident_type", "description"], "score_weights": {}, "not_questions": ["Служба 112, что случилось?", "Успокойтесь, пожалуйста", "Оставайтесь на линии", "Помощь уже едет", "Я вас понял", "Говорите громче, плохо слышно", "Выходите из квартиры", "Не кладите трубку", "Хорошо, записываю"], "ground_truth": {"incident_type": "fire", "dds": "01", "incident_code": "1010101", "notify": ["Служба 101", "Гор. Хозяйство", "ЦОДД", "Аппарат МЭРА", "ОАТИ", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "ГКУ НТУ", "ФСО", "ГУП МСР", "ЦУКБ Министерство обороны", "ГПБУ Мосэкомониторинг"], "required_facts": ["f_address", "f_what_burns", "f_people", "f_caller"], "address": "Москва, Московская железная дорога, Киевская, 1 км, дом 2, строение 2", "victims": 0}, "era_glonass": null, "tree": {"pregenerated": false}}, "kio": {"card_id": "fde25b52-b8c4-4702-a5fc-e431cb24d7d4", "registered_at": "2026-09-26T21:28:46.391943Z", "response_status": "transferred", "caller_number": "916-126-34-71", "caller_name": "Сидоров Иван Сергеевич", "caller_contact": "916-126-34-71", "phone_on_scene": null, "language": "ru", "okato": null, "address": "Москва, Московская железная дорога, Киевская, 1 км, дом 2, строение 2", "street": null, "building": null, "entrance": null, "floor": null, "intercom_code": null, "coords": null, "incident_group": 1, "signs": ["на улице", "мусор", "открытое пламя"], "incident_code": "1010101", "incident_type": "fire", "description": "Москва, Депо, около станции Москва-Пассажирская Киевская, длинное помещение недалеко от участкового пункта полиции; горит мусорный контейнер, пламя открытое; пострадавших нет", "victims_count": 0, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["Служба 101", "Гор. Хозяйство", "ЦОДД", "Аппарат МЭРА", "ОАТИ", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "ГКУ НТУ", "ФСО", "ГУП МСР", "ЦУКБ Министерство обороны", "ГПБУ Мосэкомониторинг"], "dds": "01", "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "dispatched_card": {"card_id": "fde25b52-b8c4-4702-a5fc-e431cb24d7d4", "registered_at": "2026-09-26T21:28:46.391943Z", "response_status": "transferred", "caller_number": "916-126-34-71", "caller_name": "Сидоров Иван Сергеевич", "caller_contact": "916-126-34-71", "phone_on_scene": null, "language": "ru", "okato": null, "address": "Москва, Московская железная дорога, Киевская, 1 км, дом 2, строение 2", "street": null, "building": null, "entrance": null, "floor": null, "intercom_code": null, "coords": null, "incident_group": 1, "signs": ["на улице", "мусор", "открытое пламя"], "incident_code": "1010101", "incident_type": "fire", "description": "Москва, Депо, около станции Москва-Пассажирская Киевская, длинное помещение недалеко от участкового пункта полиции; горит мусорный контейнер, пламя открытое; пострадавших нет", "victims_count": 0, "is_emergency": false, "life_threat": false, "evacuation_needed": false, "notify": ["Служба 101", "Гор. Хозяйство", "ЦОДД", "Аппарат МЭРА", "ОАТИ", "Территориальные ОИВ", "Территориальные ОИВ ТиНАО", "ГКУ НТУ", "ФСО", "ГУП МСР", "ЦУКБ Министерство обороны", "ГПБУ Мосэкомониторинг"], "dds": "01", "dispatch_order_at": null, "arrival_at": null, "fire": null, "police": null, "medical": null, "utility": null}, "dispatched_at": "2026-09-26T21:28:46.392216+00:00", "timers": {"limits": {"answer": 8000, "interview": 75000, "dds_notify": 60000, "dds_ack": 30000, "card_fill": 180000, "dds_work": 180000, "zone_check": 30000, "callback": 10000, "close": 300000}, "items": {"dds_ack": {"elapsed_ms": 0, "attempt": 1, "stopped": false}, "close": {"elapsed_ms": 0, "attempt": 1, "stopped": false}, "interview": {"elapsed_ms": 0, "attempt": 1, "stopped": true}}}, "bounced_fields": [], "dds_log": [], "status_log": [], "crew_selected": null, "crew_assignments": {}, "phone_reports": [], "phone_lines": [], "phone_pending": null, "reply_text": "", "reply_log": []}], "dds_active_card_id": "2592720c-1463-4a25-a2a2-343f8fe30194", "dds_card_index": 0, "dds_arrival_interval_seconds": 0, "dds_max_waiting": 3, "dds_next_scenario_index": 2, "dds_next_arrival_at": null, "dds_completed": [], "reply_text": "", "reply_log": [], "text_revealed_facts": {}, "resolved_outcome": null, "resolve_comment": "", "processed_station_commands": []}} diff --git a/backend/tests/golden/dds_desk_scores.json b/backend/tests/golden/dds_desk_scores.json new file mode 100644 index 0000000..84654ca --- /dev/null +++ b/backend/tests/golden/dds_desk_scores.json @@ -0,0 +1,824 @@ +{ + "dds_several_cards": { + "score_auto": 53.3, + "card_status": "processed", + "metrics": [ + [ + "dds_primary", + "Карточка 1: Служба 101: решение по карточке", + "accepted", + true, + 2.0, + null + ], + [ + "dds_ack", + "Карточка 1: Служба 101: первичная отметка за 30 с", + "0.0 с", + true, + 1.0, + null + ], + [ + "dds_decision", + "Карточка 1: Служба 101: решение по эталону сценария", + "accepted", + true, + 2.0, + null + ], + [ + "dds_crew", + "Карточка 1: Служба 101: назначение бригады", + "Служба 101 — бригада №1", + true, + 2.0, + null + ], + [ + "dds_progress", + "Карточка 1: Служба 101: ведение хода реагирования", + "responding", + false, + 2.0, + null + ], + [ + "dds_completion", + "Карточка 1: Служба 101: закрытие работ", + "не завершено", + false, + 1.0, + null + ], + [ + "dds_reply", + "Карточка 1: Служба 101: основание статусов и сведения о ходе работ", + "Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.; Бригада выехала, доклад дежурному", + false, + 1.0, + null + ], + [ + "dds_work_time", + "Карточка 1: Отработка карточки ДДС", + "карточка не завершена; 0 с (на 180 с быстрее норматива)", + false, + 1.5, + 0.0 + ], + [ + "dds_primary", + "Карточка 2: СМП: решение по карточке", + "accepted", + true, + 2.0, + null + ], + [ + "dds_ack", + "Карточка 2: СМП: первичная отметка за 30 с", + "0.0 с", + true, + 1.0, + null + ], + [ + "dds_decision", + "Карточка 2: СМП: решение по эталону сценария", + "accepted", + true, + 2.0, + null + ], + [ + "dds_crew", + "Карточка 2: СМП: назначение бригады", + "бригада не выбрана", + false, + 2.0, + null + ], + [ + "dds_progress", + "Карточка 2: СМП: ведение хода реагирования", + "статусов хода нет", + false, + 2.0, + null + ], + [ + "dds_completion", + "Карточка 2: СМП: закрытие работ", + "не завершено", + false, + 1.0, + null + ], + [ + "dds_reply", + "Карточка 2: СМП: основание статусов и сведения о ходе работ", + "Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.", + true, + 1.0, + null + ], + [ + "dds_work_time", + "Карточка 2: Отработка карточки ДДС", + "карточка не завершена; 0 с (на 180 с быстрее норматива)", + false, + 1.5, + 0.0 + ], + [ + "dds_primary", + "Карточка 3: Служба 101: решение по карточке", + "accepted", + true, + 2.0, + null + ], + [ + "dds_ack", + "Карточка 3: Служба 101: первичная отметка за 30 с", + "0.0 с", + true, + 1.0, + null + ], + [ + "dds_decision", + "Карточка 3: Служба 101: решение по эталону сценария", + "accepted", + true, + 2.0, + null + ], + [ + "dds_crew", + "Карточка 3: Служба 101: назначение бригады", + "Служба 101 — бригада №1", + true, + 2.0, + null + ], + [ + "dds_progress", + "Карточка 3: Служба 101: ведение хода реагирования", + "responding", + false, + 2.0, + null + ], + [ + "dds_completion", + "Карточка 3: Служба 101: закрытие работ", + "не завершено", + false, + 1.0, + null + ], + [ + "dds_reply", + "Карточка 3: Служба 101: основание статусов и сведения о ходе работ", + "Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.; Бригада выехала, доклад дежурному", + false, + 1.0, + null + ], + [ + "dds_work_time", + "Карточка 3: Отработка карточки ДДС", + "карточка не завершена; 0 с (на 180 с быстрее норматива)", + false, + 1.5, + 0.0 + ] + ], + "findings": [ + [ + "D5", + "Карточка 1: Служба 101: комментарии к статусам не разделяют основание и сведения", + "неполные комментарии: Начало реагирования" + ], + [ + "D6", + "Карточка 1: Служба 101: ход реагирования не доведён до конца", + "не отмечены: Прибытие, Проведение работ" + ], + [ + "E3", + "Карточка 1: ДДС: норматив времени отработки карточки не выполнен", + "карточка не завершена; 0 с (на 180 с быстрее норматива)" + ], + [ + "D2", + "Карточка 2: СМП: «Принята» без назначения бригады и хода реагирования", + "бригада не выбрана, после приёма статусов не было" + ], + [ + "D6", + "Карточка 2: СМП: ход реагирования не доведён до конца", + "не отмечены: Начало реагирования, Прибытие, Проведение работ" + ], + [ + "E3", + "Карточка 2: ДДС: норматив времени отработки карточки не выполнен", + "карточка не завершена; 0 с (на 180 с быстрее норматива)" + ], + [ + "D5", + "Карточка 3: Служба 101: комментарии к статусам не разделяют основание и сведения", + "неполные комментарии: Начало реагирования" + ], + [ + "D6", + "Карточка 3: Служба 101: ход реагирования не доведён до конца", + "не отмечены: Прибытие, Проведение работ" + ], + [ + "E3", + "Карточка 3: ДДС: норматив времени отработки карточки не выполнен", + "карточка не завершена; 0 с (на 180 с быстрее норматива)" + ] + ], + "cards": [ + [ + "fire-apartment-l2", + 56.0, + "Принято, бригада направлена", + [ + "card.ack", + "card.status", + "crew.select", + "card.status", + "zone.decision", + "card.reply" + ] + ], + [ + "t20-2-stroke", + 48.0, + "", + [ + "card.ack", + "card.status" + ] + ], + [ + "t01-1-fire-container", + 56.0, + "", + [ + "card.ack", + "card.status", + "crew.select", + "card.status", + "zone.decision" + ] + ] + ] + }, + "dds_early_finish": { + "score_auto": 35.3, + "card_status": "processed", + "metrics": [ + [ + "dds_primary", + "Карточка 1: СМП: решение по карточке", + "accepted", + true, + 2.0, + null + ], + [ + "dds_ack", + "Карточка 1: СМП: первичная отметка за 30 с", + "0.0 с", + true, + 1.0, + null + ], + [ + "dds_decision", + "Карточка 1: СМП: решение по эталону сценария", + "accepted", + true, + 2.0, + null + ], + [ + "dds_crew", + "Карточка 1: СМП: назначение бригады", + "бригада не выбрана", + false, + 2.0, + null + ], + [ + "dds_progress", + "Карточка 1: СМП: ведение хода реагирования", + "статусов хода нет", + false, + 2.0, + null + ], + [ + "dds_completion", + "Карточка 1: СМП: закрытие работ", + "не завершено", + false, + 1.0, + null + ], + [ + "dds_reply", + "Карточка 1: СМП: основание статусов и сведения о ходе работ", + "Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.", + true, + 1.0, + null + ], + [ + "dds_work_time", + "Карточка 1: Отработка карточки ДДС", + "карточка не завершена; 0 с (на 180 с быстрее норматива)", + false, + 1.5, + 0.0 + ], + [ + "dds_primary", + "Карточка 2: Служба 101: решение по карточке", + "решение отсутствует", + false, + 2.0, + null + ], + [ + "dds_ack", + "Карточка 2: Служба 101: первичная отметка за 30 с", + "не отмечено", + false, + 1.0, + null + ], + [ + "dds_work_time", + "Карточка 2: Отработка карточки ДДС", + "карточка не завершена; время обработки не зафиксировано", + false, + 1.5, + 0.0 + ] + ], + "findings": [ + [ + "D2", + "Карточка 1: СМП: «Принята» без назначения бригады и хода реагирования", + "бригада не выбрана, после приёма статусов не было" + ], + [ + "D6", + "Карточка 1: СМП: ход реагирования не доведён до конца", + "не отмечены: Начало реагирования, Прибытие, Проведение работ" + ], + [ + "E3", + "Карточка 1: ДДС: норматив времени отработки карточки не выполнен", + "карточка не завершена; 0 с (на 180 с быстрее норматива)" + ], + [ + "D1", + "Карточка 2: Служба 101: статус реагирования не проставлен", + "карточка осталась без отметки" + ], + [ + "E3", + "Карточка 2: ДДС: норматив времени отработки карточки не выполнен", + "карточка не завершена; время обработки не зафиксировано" + ] + ], + "cards": [ + [ + "t20-2-stroke", + 48.0, + "", + [ + "card.ack", + "card.status" + ] + ], + [ + "fire-apartment-l2", + 0.0, + "", + [] + ] + ] + }, + "handoff_112_to_dds": { + "score_auto": 47.8, + "card_status": "processed", + "metrics": [ + [ + "incident_signs", + "Признаки происшествия", + "не определён", + false, + 2.0, + null + ], + [ + "address", + "Адрес происшествия", + "улица Ленина, 14", + true, + 2.0, + null + ], + [ + "victims_count", + "Число пострадавших", + "не указано", + false, + 1.0, + null + ], + [ + "required_fields", + "Обязательные поля карточки", + "пусто: floor, incident_type, victims_count", + false, + 2.0, + null + ], + [ + "card_fill_time", + "Время заполнения карточки", + "0 с (на 180 с быстрее норматива)", + true, + 1.5, + 1.0 + ], + [ + "description_grammar", + "Грамматика описания происшествия", + "предложение начинается со строчной буквы; нет завершающего знака препинания", + false, + 1.0, + null + ], + [ + "dds_primary", + "Карточка 1: Служба 101: решение по карточке", + "accepted", + true, + 2.0, + null + ], + [ + "dds_ack", + "Карточка 1: Служба 101: первичная отметка за 30 с", + "0.0 с", + true, + 1.0, + null + ], + [ + "dds_decision", + "Карточка 1: Служба 101: решение по эталону сценария", + "accepted", + true, + 2.0, + null + ], + [ + "dds_crew", + "Карточка 1: Служба 101: назначение бригады", + "Служба 101 — бригада №1", + true, + 2.0, + null + ], + [ + "dds_progress", + "Карточка 1: Служба 101: ведение хода реагирования", + "responding", + false, + 2.0, + null + ], + [ + "dds_completion", + "Карточка 1: Служба 101: закрытие работ", + "не завершено", + false, + 1.0, + null + ], + [ + "dds_reply", + "Карточка 1: Служба 101: основание статусов и сведения о ходе работ", + "Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.; Бригада выехала, доклад дежурному", + false, + 1.0, + null + ], + [ + "dds_work_time", + "Карточка 1: Отработка карточки ДДС", + "карточка не завершена; 0 с (на 180 с быстрее норматива)", + false, + 1.5, + 0.0 + ], + [ + "dds_primary", + "Карточка 2: Служба 101: решение по карточке", + "accepted", + true, + 2.0, + null + ], + [ + "dds_ack", + "Карточка 2: Служба 101: первичная отметка за 30 с", + "0.0 с", + true, + 1.0, + null + ], + [ + "dds_decision", + "Карточка 2: Служба 101: решение по эталону сценария", + "accepted", + true, + 2.0, + null + ], + [ + "dds_crew", + "Карточка 2: Служба 101: назначение бригады", + "бригада не выбрана", + false, + 2.0, + null + ], + [ + "dds_progress", + "Карточка 2: Служба 101: ведение хода реагирования", + "статусов хода нет", + false, + 2.0, + null + ], + [ + "dds_completion", + "Карточка 2: Служба 101: закрытие работ", + "не завершено", + false, + 1.0, + null + ], + [ + "dds_reply", + "Карточка 2: Служба 101: основание статусов и сведения о ходе работ", + "Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.", + true, + 1.0, + null + ], + [ + "dds_work_time", + "Карточка 2: Отработка карточки ДДС", + "карточка не завершена; 0 с (на 180 с быстрее норматива)", + false, + 1.5, + 0.0 + ] + ], + "findings": [ + [ + "E2", + "Признаки происшествия: не определён; требуется 1050201", + "не определён" + ], + [ + "E5", + "Число пострадавших: не указано; требуется 2", + "не указано" + ], + [ + "E5", + "Обязательные поля карточки: пусто: floor, incident_type, victims_count; требуется все обязательные поля заполнены", + "пусто: floor, incident_type, victims_count" + ], + [ + "E4", + "Грамматическая ошибка в описании происшествия", + "предложение начинается со строчной буквы; нет завершающего знака препинания" + ], + [ + "D5", + "Карточка 1: Служба 101: комментарии к статусам не разделяют основание и сведения", + "неполные комментарии: Начало реагирования" + ], + [ + "D6", + "Карточка 1: Служба 101: ход реагирования не доведён до конца", + "не отмечены: Прибытие, Проведение работ" + ], + [ + "E3", + "Карточка 1: ДДС: норматив времени отработки карточки не выполнен", + "карточка не завершена; 0 с (на 180 с быстрее норматива)" + ], + [ + "D2", + "Карточка 2: Служба 101: «Принята» без назначения бригады и хода реагирования", + "бригада не выбрана, после приёма статусов не было" + ], + [ + "D6", + "Карточка 2: Служба 101: ход реагирования не доведён до конца", + "не отмечены: Начало реагирования, Прибытие, Проведение работ" + ], + [ + "E3", + "Карточка 2: ДДС: норматив времени отработки карточки не выполнен", + "карточка не завершена; 0 с (на 180 с быстрее норматива)" + ] + ], + "cards": [ + [ + "fire-apartment-l2", + 56.0, + "", + [ + "card.ack", + "card.status", + "crew.select", + "card.status", + "zone.decision" + ] + ], + [ + "t01-1-fire-container", + 48.0, + "", + [ + "card.ack", + "card.status" + ] + ] + ] + }, + "call_live_dispatcher": { + "score_auto": 62.8, + "card_status": "processed", + "metrics": [ + [ + "answer_time", + "Ответ на звонок", + "0 с", + true, + 1.0, + null + ], + [ + "interview_time", + "Опрос / идентификация ситуации", + "0 с", + true, + 1.5, + null + ], + [ + "incident_signs", + "Признаки происшествия", + "жилой дом · балкон · открытое пламя", + true, + 2.0, + null + ], + [ + "address", + "Адрес", + "улица Ленина, 14", + true, + 2.0, + null + ], + [ + "victims_count", + "Число пострадавших", + "не указано", + false, + 1.0, + null + ], + [ + "required_fields", + "Обязательные поля КИО", + "пусто: floor, incident_type, dds, victims_count", + false, + 2.0, + null + ], + [ + "description_grammar", + "Грамматика описания происшествия", + "предложение начинается со строчной буквы; нет завершающего знака препинания", + false, + 1.0, + null + ], + [ + "dds_primary", + "Служба 101: решение по карточке", + "accepted", + true, + 2.0, + null + ], + [ + "dds_ack", + "Служба 101: первичная отметка за 30 с", + "0.0 с", + true, + 1.0, + null + ], + [ + "dds_decision", + "Служба 101: решение по эталону сценария", + "accepted", + true, + 2.0, + null + ], + [ + "dds_crew", + "Служба 101: назначение бригады", + "Служба 101 — бригада №1", + true, + 2.0, + null + ], + [ + "dds_progress", + "Служба 101: ведение хода реагирования", + "responding", + false, + 2.0, + null + ], + [ + "dds_completion", + "Служба 101: закрытие работ", + "не завершено", + false, + 1.0, + null + ], + [ + "dds_reply", + "Служба 101: основание статусов и сведения о ходе работ", + "Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.; Бригада выехала, доклад дежурному", + false, + 1.0, + null + ] + ], + "findings": [ + [ + "E5", + "Пострадавших в карточке не указано, верно — 2", + "не указано" + ], + [ + "E5", + "Не заполнены обязательные поля: floor, incident_type, dds, victims_count", + "пусто: floor, incident_type, dds, victims_count" + ], + [ + "E4", + "Грамматическая ошибка в описании происшествия", + "предложение начинается со строчной буквы; нет завершающего знака препинания" + ], + [ + "D5", + "Служба 101: комментарии к статусам не разделяют основание и сведения", + "неполные комментарии: Начало реагирования" + ], + [ + "D6", + "Служба 101: ход реагирования не доведён до конца", + "не отмечены: Прибытие, Проведение работ" + ] + ], + "cards": [] + } +} diff --git a/backend/tests/test_card_exercise.py b/backend/tests/test_card_exercise.py index 3e2e52a..16e3bbd 100644 --- a/backend/tests/test_card_exercise.py +++ b/backend/tests/test_card_exercise.py @@ -436,7 +436,7 @@ def test_handoff_report_and_checklist_follow_operator_scenario(client, monkeypat wait_for(lambda: hub.get(session_id).score) state = hub.get(session_id) # Предусловие ошибки: к завершению активна не карточка курсанта. - assert state.scenario_id == "t01-1-fire-container" + assert state.desk.active.scenario.id == "t01-1-fire-container" operator = store.get("fire-apartment-l2") checklist = client.get(f"/api/sessions/{session_id}/checklist").json() diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py index f4d0f7b..8a48aa6 100644 --- a/backend/tests/test_db.py +++ b/backend/tests/test_db.py @@ -31,11 +31,12 @@ from app.domain.events import Exercise, SessionMode from app.domain.roles import Role from app.main import LIBRARY, app from app.scenarios import store -from app.session.dds import prepare_card from app.session.checkpoint import load_state +from app.session.dds import build_card from app.session.hub import hub from app.session.journal import DbJournal, SessionLeaseLost from app.session.state import SessionState +from app.session.timers import SessionTimers @pytest.fixture @@ -894,16 +895,10 @@ async def test_kio_submission_moderation_and_dds_card_persist_on_real_postgres( scenario = store.get(scenario_id) assert scenario is not None and scenario.student_card is not None - state = SessionState( - session_id=UUID("00000000-0000-4000-8000-000000000702"), - scenario_id=scenario.id, scenario_title=scenario.title, - level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS, - ) - prepare_card(state, scenario) - assert state.dispatched_card is not None - assert state.dispatched_card.address == kio["address"] - assert state.dispatched_card.caller_name == kio["caller_name"] - assert state.dispatched_card.fire.object_kind == "мусорный контейнер" + card = build_card(scenario, 0, SessionTimers().limits) + assert card.dispatched_card.address == kio["address"] + assert card.dispatched_card.caller_name == kio["caller_name"] + assert card.dispatched_card.fire.object_kind == "мусорный контейнер" # Simulate a peer backend with a cold process-local scenario cache: # scenario.start must resolve the approved row from shared Postgres. @@ -950,9 +945,9 @@ async def test_kio_submission_moderation_and_dds_card_persist_on_real_postgres( deadline = time.monotonic() + 3 while time.monotonic() < deadline: state = hub.get(session_id) - if state and any( + if state and state.desk.active and any( mark.service == service and mark.status.value == expected - for mark in state.status_log + for mark in state.desk.active.status_log ): break time.sleep(0.01) diff --git a/backend/tests/test_dds_desk.py b/backend/tests/test_dds_desk.py new file mode 100644 index 0000000..da1a8d9 --- /dev/null +++ b/backend/tests/test_dds_desk.py @@ -0,0 +1,127 @@ +"""Пульт ДДС без сокетов и хаба: очередь, переключение, завершение, темп.""" + +from datetime import timedelta +from pathlib import Path +from uuid import uuid4 + +from app.domain.statuses import ServiceStatus +from app.domain.timers import TimerCode +from app.scenarios.loader import load_file +from app.session.dds import DdsCardRecord, DdsDesk +from app.session.timers import SessionTimers, now_utc + +LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" +NAMES = ("fire-apartment-l2.yaml", "tickets/t20-2-stroke.yaml", + "tickets/t01-1-fire-container.yaml") + + +def scenarios(count: int = 3): + return [load_file(LIBRARY / name, LIBRARY) for name in NAMES[:count]] + + +def record(card) -> DdsCardRecord: + return DdsCardRecord(card_id=card.card_id, scenario_id=card.scenario.id, reply_text="", + metrics=[], findings=[], actions=[], duration_ms=0) + + +def started(interval: int = 0, max_waiting: int = 3, now=None) -> DdsDesk: + desk = DdsDesk() + desk.start(scenarios(), limits=SessionTimers().limits, + arrival_interval_seconds=interval, max_waiting=max_waiting, now=now or now_utc()) + return desk + + +def test_first_card_becomes_active_and_rest_wait_when_no_pace(): + desk = started() + + assert [card.scenario.id for card in desk.ordered()] == [ + "fire-apartment-l2", "t20-2-stroke", "t01-1-fire-container"] + assert desk.active is desk.ordered()[0] + assert desk.next_arrival_at is None + + +def test_paced_queue_delivers_next_card_only_after_interval(): + start = now_utc() + desk = started(interval=60, now=start) + first = desk.active + + assert desk.deliver_due(start + timedelta(seconds=59)) == 0 + assert desk.deliver_due(start + timedelta(seconds=61)) == 1 + + assert len(desk.cards) == 2 + assert desk.active is first + + +def test_waiting_cap_holds_back_delivery(): + desk = started(max_waiting=1) + + assert len(desk.cards) == 2 + assert desk.next_index == 2 + + +def test_open_switches_by_reference_without_copying_fields(): + desk = started() + first, second = desk.ordered()[:2] + first.status_log.append(object()) + + assert desk.open(second.card_id) + second.reply_text = "ответ по второй" + assert desk.open(first.card_id) + + assert desk.active is first + assert len(first.status_log) == 1 and second.status_log == [] + assert second.reply_text == "ответ по второй" and first.reply_text == "" + assert desk.card_index == 0 + + +def test_open_unknown_card_keeps_active(): + desk = started() + active = desk.active + + assert not desk.open(uuid4()) + assert desk.active is active + + +def test_complete_moves_to_following_card_and_keeps_record(): + desk = started() + first, second, _third = desk.ordered() + + following = desk.complete(first.card_id, record(first)) + + assert following is second and desk.active is second + assert first.card_id not in desk.cards + assert desk.is_completed(first.card_id) + + +def test_completing_last_card_leaves_desk_between_cards_with_last_index(): + desk = started(interval=60, max_waiting=1) + only = desk.active + + assert desk.complete(only.card_id, record(only)) is None + + assert desk.active is None and desk.active_id is None + assert desk.card_index == 0 + assert desk.deliver_due(now_utc() + timedelta(seconds=61)) == 1 + assert desk.active.scenario.id == "t20-2-stroke" + + +def test_each_card_runs_own_decision_timer_with_configured_limit(): + desk = DdsDesk() + limits = {**SessionTimers().limits, TimerCode.DDS_ACK: 45_000} + desk.start(scenarios(2), limits=limits, now=now_utc()) + first, second = desk.ordered() + + assert first.timers is not second.timers + assert first.timers.limits[TimerCode.DDS_ACK] == 45_000 + assert TimerCode.DDS_ACK in second.timers.timers + + +def test_card_status_mark_lands_in_that_card_only(): + desk = started() + first, second = desk.ordered()[:2] + service = first.dispatched_card.notify[0] + + first.set_service_status(service, ServiceStatus.ACCEPTED, "Основание: доклад") + + assert [mark.status for mark in first.status_log] == [ServiceStatus.ACCEPTED] + assert second.status_log == [] diff --git a/backend/tests/test_dds_desk_checkpoint.py b/backend/tests/test_dds_desk_checkpoint.py new file mode 100644 index 0000000..5a2c294 --- /dev/null +++ b/backend/tests/test_dds_desk_checkpoint.py @@ -0,0 +1,172 @@ +"""Чекпойнт пульта ДДС: формат прежний, чтения ничего не меняют. + +`golden/checkpoint_before_desk.json` записан кодом до `DdsDesk`: занятие, +поднятое из такого снимка, продолжается с той же активной карточкой. +""" + +import asyncio +import json +from pathlib import Path +from uuid import UUID, uuid4 + +import pytest +from pydantic import TypeAdapter + +from app.api.ws import station as station_ws +from app.domain.events import Exercise, SessionMode, StationToServer +from app.domain.kio import KIO +from app.domain.statuses import ServiceStatus +from app.domain.timers import TimerCode +from app.scenarios.loader import load_file +from app.session import checkpoint +from app.session import finish as finish_module +from app.session.checkpoint import dump_state, load_state +from app.session.dds import prepare_handoff_queue +from app.session.hub import hub +from app.session.state import SessionState +from app.session.timers import now_utc + +LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" + +BEFORE_DESK = json.loads( + (Path(__file__).parent / "golden" / "checkpoint_before_desk.json").read_text() +) +_events = TypeAdapter(StationToServer) + + +@pytest.fixture(autouse=True) +def offline_finish(monkeypatch): + class NoCoaching: + def model_dump(self, **_kwargs): + return {} + + async def no_coach(_metrics): + return NoCoaching() + + monkeypatch.setattr(finish_module, "coach", no_coach) + monkeypatch.setattr(hub, "journal", None) + + +def restored(name: str, **changes): + payload = {**json.loads(json.dumps(BEFORE_DESK[name])), **changes} + return load_state(payload, now_utc()) + + +def station(state, **payload) -> None: + hub.register(state) + asyncio.run(station_ws._handle(state.session_id, state, _events.validate_python(payload))) + + +def test_dds_checkpoint_before_desk_resumes_same_active_card_and_work(): + state = restored("dds") + saved = BEFORE_DESK["dds"] + + assert str(state.desk.active_id) == saved["dds_active_card_id"] + assert state.desk.active.original_index == 1 + assert state.desk.active.reply_text == "Ответ по второй карточке" + first = state.desk.ordered()[0] + assert first.status_log[-1].status is ServiceStatus.ACCEPTED + assert first.crew_selected == saved["dds_live_cards"][0]["crew_selected"] + assert first.timers.measured_ms(TimerCode.DDS_ACK) is not None + + station(state, type="card.next", card_id=saved["dds_active_card_id"]) + + assert [record.card_id for record in state.desk.completed] == [ + UUID(saved["dds_active_card_id"])] + assert state.desk.active.original_index == 2 + assert state.station_snapshot().card_index == 3 + + +def test_legacy_checkpoint_without_active_id_opens_first_card(): + payload = json.loads(json.dumps(BEFORE_DESK["dds"])) + del payload["dds_active_card_id"] + + state = load_state(payload, now_utc()) + + assert state.desk.active.original_index == 0 + assert state.station_snapshot().card_index == 1 + + +def test_call_checkpoint_before_desk_moves_dispatcher_work_to_desk_card(): + state = restored("call") + saved = BEFORE_DESK["call"] + + card = state.desk.active + assert card.card_id == state.dispatched_card.card_id + assert card.crew_selected == saved["crew_selected"] + assert [mark.status for mark in card.status_log] == [ServiceStatus.ACCEPTED] + assert card.timers is state.timers + snapshot = state.station_snapshot() + assert snapshot.statuses == {snapshot.managed_service: ServiceStatus.ACCEPTED} + assert snapshot.queue_cards == [] + + state.ended_at = now_utc() + asyncio.run(finish_module.finish(state.session_id, state)) + + crew = next(item for item in state.score["metrics"] if item["key"] == "dds_crew") + assert crew["passed"] and crew["fact"] == saved["crew_selected"] + assert any(item["key"] == "interview_time" for item in state.score["metrics"]) + + +def test_handoff_checkpoint_before_desk_keeps_operator_card_and_queue(): + state = restored("handoff") + + assert state.operator_kio.address == "улица Ленина, 14" + assert [item.id for item in state.desk.scenarios] == [ + "fire-apartment-l2", "t01-1-fire-container"] + assert state.desk.active.original_index == 0 + assert state.desk.active.status_log[-1].status is ServiceStatus.ACCEPTED + + +@pytest.mark.parametrize("name", ["dds", "call", "handoff"]) +def test_new_checkpoint_keeps_json_keys_of_format_before_desk(name): + assert set(dump_state(restored(name))) == set(BEFORE_DESK[name]) + + +@pytest.mark.parametrize("name", ["dds", "call", "handoff"]) +def test_reads_do_not_change_state(name, monkeypatch): + monkeypatch.setattr(checkpoint.time, "monotonic", lambda: 1_000_000.0) + state = restored(name) + active = state.desk.active + + first = dump_state(state) + state.station_snapshot() + state.snapshot() + state.card_received_event() + + assert dump_state(state) == first + assert state.desk.active is active + + +@pytest.mark.parametrize("name", ["dds", "handoff"]) +def test_finish_scores_every_card_without_switching_active(name): + state = restored(name) + active = state.desk.active + unscored = {card.card_id for card in state.desk.cards.values()} + + state.ended_at = now_utc() + asyncio.run(finish_module.finish(state.session_id, state)) + + assert state.desk.active is active + assert {record.card_id for record in state.desk.completed} == unscored + + +def test_handoff_keeps_operator_timers_for_card_fill_metric(): + """До пульта связка подменяла таймеры оператора таймерами карточки ДДС, + и время заполнения карточки 112 в оценку не попадало.""" + first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + state = SessionState( + session_id=uuid4(), scenario_id=first.id, scenario_title=first.title, + level=first.level.value, mode=SessionMode.TRAINING, + exercise=Exercise.CARD, handoff_to_dds=True, scenario=first, + ) + state.on_event("card.start") + state.kio = KIO(address="улица Ленина, 14", notify=["Служба 101"]) + state.on_event("card.submit") + state.dispatch() + prepare_handoff_queue(state, []) + + assert state.timers.measured_ms(TimerCode.CARD_FILL) is not None + assert state.desk.active.timers is not state.timers + assert load_state(dump_state(state), now_utc()).timers.measured_ms( + TimerCode.CARD_FILL) is not None diff --git a/backend/tests/test_dds_desk_scores.py b/backend/tests/test_dds_desk_scores.py new file mode 100644 index 0000000..af7c05b --- /dev/null +++ b/backend/tests/test_dds_desk_scores.py @@ -0,0 +1,194 @@ +"""Оценки finish() по пульту ДДС закреплены эталоном (lct-dds-desk). + +Эталон `golden/dds_desk_scores.json` снят с кода до переноса живых карточек +в `DdsDesk`: перенос меняет владельца состояния, а не правила оценки. +Одно отличие намеренное: в связке 112→ДДС `card_fill_time` теперь считается +по таймерам оператора — до пульта их подменяли таймеры карточки ДДС. +Пересобрать эталон — `UPDATE_GOLDEN=1 pytest tests/test_dds_desk_scores.py`, +только если правила оценки меняются намеренно. +""" + +import asyncio +import json +import os +from pathlib import Path +from uuid import uuid4 + +import pytest +from pydantic import TypeAdapter + +from app.api.ws import station as station_ws +from app.domain.events import Exercise, SessionMode, StationToServer +from app.domain.kio import KIO +from app.scenarios.loader import load_file +from app.session import finish as finish_module +from app.session.dds import prepare_handoff_queue, prepare_queue +from app.session.hub import hub +from app.session.state import SessionState + +LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" +GOLDEN = Path(__file__).parent / "golden" / "dds_desk_scores.json" +COMMENT = "Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный." +_events = TypeAdapter(StationToServer) + + +@pytest.fixture(autouse=True) +def offline_finish(monkeypatch): + class NoCoaching: + def model_dump(self, **_kwargs): + return {} + + async def no_coach(_metrics): + return NoCoaching() + + monkeypatch.setattr(finish_module, "coach", no_coach) + monkeypatch.setattr(hub, "journal", None) + + +def scenario(name: str): + return load_file(LIBRARY / name, LIBRARY) + + +def station(state: SessionState, **payload) -> None: + hub.register(state) + asyncio.run(station_ws._handle(state.session_id, state, _events.validate_python(payload))) + + +def active_id(state: SessionState): + return state.station_snapshot().card_id + + +def service_of(state: SessionState) -> str: + return state.station_snapshot().managed_service + + +def work_card(state: SessionState) -> None: + """Принять, назначить бригаду и довести до выезда — типичный путь курсанта.""" + station(state, type="card.ack", comment=COMMENT) + snapshot = state.station_snapshot() + station(state, type="crew.select", crew=snapshot.crew_options[0]) + station(state, type="card.status", service=service_of(state), + status="responding", comment="Бригада выехала, доклад дежурному") + station(state, type="zone.decision", in_zone=True) + + +def summarize(state: SessionState) -> dict: + score = state.score + return { + "score_auto": score["score_auto"], + "card_status": score["card_status"], + "metrics": [ + [m["key"], m["title"], m["fact"], m["passed"], m["weight"], m.get("credit")] + for m in score["metrics"] + ], + "findings": [[f["code"], f["summary"], f["fact"]] for f in score["findings"]], + "cards": [[card["scenario_id"], card["score_auto"], card["reply_text"], + [action["type"] for action in card["actions"]]] + for card in score["card_results"]], + } + + +def dds_exercise_several_cards() -> SessionState: + cards = [scenario("fire-apartment-l2.yaml"), + scenario("tickets/t20-2-stroke.yaml"), + scenario("tickets/t01-1-fire-container.yaml")] + state = SessionState( + session_id=uuid4(), scenario_id=cards[0].id, scenario_title=cards[0].title, + level=cards[0].level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS, + ) + prepare_queue(state, cards) + first = active_id(state) + work_card(state) + station(state, type="card.reply", card_id=str(first), text="Принято, бригада направлена") + station(state, type="card.next", card_id=str(first)) + second = active_id(state) + station(state, type="card.ack", comment=COMMENT) + third = next(card.card_id for card in state.station_snapshot().queue_cards + if card.card_id not in {first, second}) + station(state, type="card.open", card_id=str(third)) + work_card(state) + station(state, type="card.open", card_id=str(second)) + station(state, type="station.finish") + return state + + +def dds_exercise_early_finish() -> SessionState: + cards = [scenario("tickets/t20-2-stroke.yaml"), + scenario("fire-apartment-l2.yaml"), + scenario("tickets/t01-1-fire-container.yaml")] + state = SessionState( + session_id=uuid4(), scenario_id=cards[0].id, scenario_title=cards[0].title, + level=cards[0].level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS, + ) + prepare_queue(state, cards, max_waiting=1) + station(state, type="card.ack", comment=COMMENT) + station(state, type="station.finish") + return state + + +def handoff_112_to_dds() -> SessionState: + first = scenario("fire-apartment-l2.yaml") + second = scenario("tickets/t01-1-fire-container.yaml") + state = SessionState( + session_id=uuid4(), scenario_id=first.id, scenario_title=first.title, + level=first.level.value, mode=SessionMode.TRAINING, + exercise=Exercise.CARD, handoff_to_dds=True, scenario=first, + required_fields=list(first.required_fields), + pending_dds_scenarios=[second], + ) + state.on_event("card.start") + state.kio = KIO( + address="улица Ленина, 14", description="горит балкон на третьем этаже", + notify=["Служба 101"], + ) + state.on_event("card.submit") + state.dispatch() + prepare_handoff_queue(state, state.pending_dds_scenarios) + first_card = active_id(state) + work_card(state) + station(state, type="card.next", card_id=str(first_card)) + station(state, type="card.ack", comment=COMMENT) + station(state, type="station.finish") + return state + + +def call_with_live_dispatcher() -> SessionState: + first = scenario("fire-apartment-l2.yaml") + state = SessionState( + session_id=uuid4(), scenario_id=first.id, scenario_title=first.title, + level=first.level.value, mode=SessionMode.TRAINING, exercise=Exercise.CALL, + scenario=first, required_fields=list(first.required_fields), + ) + state.on_event("call.incoming") + state.on_event("call.answer") + state.patch_kio({"address": "улица Ленина, 14", "description": "горит квартира", + "signs": list(first.signs)}) + state.on_event("dds.dispatch") + state.dispatch() + work_card(state) + state.ended_at = state.dispatched_at + asyncio.run(finish_module.finish(state.session_id, state)) + return state + + +CASES = { + "dds_several_cards": dds_exercise_several_cards, + "dds_early_finish": dds_exercise_early_finish, + "handoff_112_to_dds": handoff_112_to_dds, + "call_live_dispatcher": call_with_live_dispatcher, +} + + +def test_update_golden_when_requested(): + if not os.environ.get("UPDATE_GOLDEN"): + pytest.skip("эталон пересобирается только явно") + GOLDEN.parent.mkdir(exist_ok=True) + GOLDEN.write_text(json.dumps({name: summarize(build()) for name, build in CASES.items()}, + ensure_ascii=False, indent=1) + "\n") + + +@pytest.mark.parametrize("name", list(CASES)) +def test_finish_scores_match_golden(name): + state = CASES[name]() + + assert summarize(state) == json.loads(GOLDEN.read_text())[name] diff --git a/backend/tests/test_dds_exercise.py b/backend/tests/test_dds_exercise.py index 68ef00e..4b42f1a 100644 --- a/backend/tests/test_dds_exercise.py +++ b/backend/tests/test_dds_exercise.py @@ -110,8 +110,8 @@ def complete_phone_call(station, state, expected_phase): station.send_json( { "type": "phone.brief", - "address": state.dispatched_card.address, - "incident": state.scenario_title, + "address": state.desk.active.dispatched_card.address, + "incident": state.desk.active.scenario.title, "request": "Прошу подтвердить выезд и сообщить о прибытии", } ) @@ -131,9 +131,9 @@ def test_dds_starts_with_prepared_card_without_call(client): try: state = hub.get(session_id) assert state.exercise.value == "dds" - assert state.dispatched_card is not None - assert state.kio.address == "улица Ленина, 14" - assert state.kio.notify + assert state.desk.active.dispatched_card is not None + assert state.desk.active.kio.address == "улица Ленина, 14" + assert state.desk.active.kio.notify assert state.timers.measured_ms(TimerCode.ANSWER) is None with client.websocket_connect(f"/ws/station/{session_id}") as station: card = read_until(station, "card.received") @@ -162,12 +162,12 @@ def test_repeated_ack_and_crew_selection_do_not_duplicate_dds_log(client): station.send_json({"type": "card.ack", "comment": "Основание: карточка передана диспетчеру."}) station.send_json({"type": "crew.select", "crew": crew}) read_until(station, "station.state") - assert sum(action == "card.ack" for action, *_ in state.dds_log) == 1 + assert sum(action == "card.ack" for action, *_ in state.desk.active.dds_log) == 1 station.send_json({"type": "crew.select", "crew": crew}) station.send_json({"type": "zone.decision", "in_zone": True}) read_until(station, "station.state") - assert sum(action == "crew.select" for action, *_ in state.dds_log) == 1 + assert sum(action == "crew.select" for action, *_ in state.desk.active.dds_log) == 1 finally: control.__exit__(None, None, None) @@ -184,14 +184,14 @@ def test_zone_decision_is_one_shot_and_restored_in_station_snapshot(client): accepted = read_until(station, "station.state")["snapshot"] assert accepted["zone_decision"] is True state = hub.get(session_id) - assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1 + assert sum(action == "zone.decision" for action, *_ in state.desk.active.dds_log) == 1 station.send_json({"type": "zone.decision", "in_zone": True}) - assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1 + assert sum(action == "zone.decision" for action, *_ in state.desk.active.dds_log) == 1 station.send_json({"type": "zone.decision", "in_zone": False}) error = read_until(station, "error") assert "уже записано" in error["message"] - assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1 + assert sum(action == "zone.decision" for action, *_ in state.desk.active.dds_log) == 1 station.close() with client.websocket_connect(f"/ws/station/{session_id}") as station: @@ -212,11 +212,11 @@ def test_server_randomly_selects_only_from_instructor_filtered_card_pool( session_id, control = start(client, random_scenario_ids=pool) try: state = hub.get(session_id) - assert state.scenario_id == "t01-1-fire-container" - assert state.dispatched_card is not None - assert [item.id for item in state.dds_scenarios] == pool[::-1] - assert state.dds_next_scenario_index == 2 - assert state.dds_next_arrival_at is None + assert state.desk.active.scenario.id == "t01-1-fire-container" + assert state.desk.active.dispatched_card is not None + assert [item.id for item in state.desk.scenarios] == pool[::-1] + assert state.desk.next_index == 2 + assert state.desk.next_arrival_at is None finally: hub.stop_ticker(session_id) control.__exit__(None, None, None) @@ -276,7 +276,7 @@ def test_instructor_live_registry_shows_owned_dds_session_and_deadline_state(cli assert row["dds_snapshot"]["queue_cards"][0]["service_status"] == "added" assert row["dds_snapshot"]["phone_reports"] == [] state = hub.get(session_id) - live_card = state.dds_live_cards[0] + live_card = state.desk.ordered()[0] live_card.timers.on_event("dds.open") live_card.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 181 overdue_response = client.get("/api/sessions/active") @@ -304,10 +304,10 @@ def test_instructor_live_registry_includes_current_crew_report(client, monkeypat state = hub.get(session_id) with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station: read_until(station, "station.state") - crew = state.crew_options()[0] + crew = state.crew_options(state.desk.active)[0] station.send_json({"type": "crew.select", "crew": crew}) read_until(station, "station.state") - service = state.crew_service(crew) + service = state.crew_service(state.desk.active, crew) station.send_json({ "type": "card.status", "service": service, "status": "accepted", "comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята.", @@ -329,9 +329,9 @@ def test_ticket_dds_card_uses_source_caller_identity_and_phone(client): session_id, control = start(client, scenario_id="t01-1-fire-container") try: state = hub.get(session_id) - assert state.kio.caller_name == "Сидоров Иван Сергеевич" - assert state.kio.caller_number == "916-126-34-71" - assert state.kio.caller_contact == "916-126-34-71" + assert state.desk.active.kio.caller_name == "Сидоров Иван Сергеевич" + assert state.desk.active.kio.caller_number == "916-126-34-71" + assert state.desk.active.kio.caller_contact == "916-126-34-71" with client.websocket_connect(f"/ws/station/{session_id}") as station: card = read_until(station, "card.received")["card"] assert card["caller_name"] == "Сидоров Иван Сергеевич" @@ -345,9 +345,9 @@ def test_ticket_with_multiple_witnesses_does_not_guess_caller_name(client): session_id, control = start(client, scenario_id="t20-1-three-fighting") try: state = hub.get(session_id) - assert state.kio.caller_name is None - assert state.kio.caller_number == "916-123-98-78" - assert state.kio.caller_contact == "916-123-98-78" + assert state.desk.active.kio.caller_name is None + assert state.desk.active.kio.caller_number == "916-123-98-78" + assert state.desk.active.kio.caller_contact == "916-123-98-78" finally: control.__exit__(None, None, None) @@ -432,7 +432,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa } ) read_until(station, "station.state") - assert len(state.reply_log) == 1 + assert len(state.desk.active.reply_log) == 1 station.send_json({"type": "station.finish"}) read_until(station, "score.ready") @@ -624,10 +624,10 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client): read_until(station, "station.state")["snapshot"]["phone_pending"] is None ) - assert not hub.get(session_id).phone_reports + assert not hub.get(session_id).desk.active.phone_reports complete_phone_call(station, hub.get(session_id), "dispatched") read_until(station, "station.state") - assert len(hub.get(session_id).phone_reports) == 1 + assert len(hub.get(session_id).desk.active.phone_reports) == 1 station.send_json({"type": "phone.dial"}) read_until(station, "phone.line") read_until(station, "station.state") @@ -635,7 +635,7 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client): {"type": "phone.check", "text": "Здравствуйте, хорошая погода"} ) assert "обстановку" in read_until(station, "error")["message"] - assert len(hub.get(session_id).phone_reports) == 1 + assert len(hub.get(session_id).desk.active.phone_reports) == 1 finally: control.__exit__(None, None, None) @@ -718,7 +718,7 @@ def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client) "comment": "Основание: доклад старшего.\nСведения: карточка принята."} ) snapshot = read_until(station, "station.state")["snapshot"] - state.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61 + state.desk.active.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61 crew = next( option for option in snapshot["crew_options"] @@ -789,13 +789,13 @@ def test_complete_dds_workflow_with_training_calls_and_status_updates(client): read_until(station, "score.ready") score = wait_for(lambda: state.score) assert score["score_auto"] == 100.0 - assert [report.phase for report in state.phone_reports] == [ + assert [report.phase for report in state.desk.active.phone_reports] == [ "dispatched", "arrived", "working", "completed", ] - assert all(report.crew == crew for report in state.phone_reports) + assert all(report.crew == crew for report in state.desk.active.phone_reports) assert not score["findings"] finally: control.__exit__(None, None, None) @@ -828,7 +828,7 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client): assert first_row["active"] is True and second_row["active"] is False first_live = next( item - for item in state.dds_live_cards + for item in state.desk.cards.values() if str(item.card_id) == first_card_id ) assert TimerCode.DDS_WORK not in first_live.timers.timers @@ -860,7 +860,7 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client): assert second["card"]["card_id"] == second_card_id second_live = next( item - for item in state.dds_live_cards + for item in state.desk.cards.values() if str(item.card_id) == second_card_id ) assert TimerCode.DDS_WORK in second_live.timers.timers @@ -907,7 +907,7 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client): read_until(station, "score.ready") state = hub.get(session_id) - assert state.ended and len(state.dds_completed) == 2 + assert state.ended and len(state.desk.completed) == 2 assert len(state.score["card_results"]) == 2 assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2" assert state.score["card_results"][1]["scenario_id"] == "t20-2-stroke" @@ -965,8 +965,8 @@ def test_each_dds_card_uses_its_own_scenario_weights(): from app.domain.events import Exercise, SessionMode from app.scenarios.loader import load_file - from app.session.dds import prepare_card - from app.session.finish import score_current_dds + from app.session.dds import prepare_queue + from app.session.finish import score_dds_card from app.session.state import SessionState root = Path(__file__).resolve().parents[2] / "scenarios" @@ -982,13 +982,11 @@ def test_each_dds_card_uses_its_own_scenario_weights(): level=base.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS, - dds_scenarios=[first, second], ) - prepare_card(state, first) - first_record = score_current_dds(state) - state.dds_card_index = 1 - prepare_card(state, second) - second_record = score_current_dds(state) + prepare_queue(state, [first, second]) + first_card, second_card = state.desk.ordered() + first_record = score_dds_card(state, first_card) + second_record = score_dds_card(state, second_card) assert ( next(item.weight for item in first_record.metrics if item.key == "dds_primary") == 7.0 diff --git a/backend/tests/test_finish_card_status.py b/backend/tests/test_finish_card_status.py index fdf19ae..18568fb 100644 --- a/backend/tests/test_finish_card_status.py +++ b/backend/tests/test_finish_card_status.py @@ -31,8 +31,9 @@ def offline_finish(monkeypatch): def accept_active_card(state: SessionState) -> None: - service = state.notified_services()[0] - state.set_service_status( + card = state.desk.active + service = card.dispatched_card.notify[0] + card.set_service_status( service, ServiceStatus.ACCEPTED, "Основание: доклад старшего.\nСведения: карточка принята.", ) @@ -41,8 +42,7 @@ def accept_active_card(state: SessionState) -> None: def assert_active_card_kept(state: SessionState, active_id, active_status) -> None: assert active_status is not CardStatus.REGISTERED assert state.score["card_status"] == active_status.value - assert state.dds_active_card_id == active_id - assert state.dispatched_card.card_id == active_id + assert state.desk.active_id == active_id assert len(state.score["card_results"]) == 2 @@ -54,8 +54,8 @@ def test_dds_finish_reports_status_of_active_card_not_last_scored(): level=first.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS, ) prepare_queue(state, [first, second]) - assert len(state.dds_live_cards) == 2 - active_id = state.dds_active_card_id + assert len(state.desk.cards) == 2 + active_id = state.desk.active_id accept_active_card(state) active_status = state.station_snapshot().card @@ -78,8 +78,8 @@ def test_handoff_finish_reports_status_of_active_card_not_last_scored(): ) state.dispatch() prepare_handoff_queue(state, state.pending_dds_scenarios) - assert len(state.dds_live_cards) == 2 - active_id = state.dds_active_card_id + assert len(state.desk.cards) == 2 + active_id = state.desk.active_id accept_active_card(state) active_status = state.station_snapshot().card diff --git a/backend/tests/test_scenario_submissions.py b/backend/tests/test_scenario_submissions.py index fbb403b..bd483e8 100644 --- a/backend/tests/test_scenario_submissions.py +++ b/backend/tests/test_scenario_submissions.py @@ -13,11 +13,10 @@ from app.api.http.scenario_submissions import reset_demo_submissions from app.config import get_settings from app.db.models import AuditLog, Group, Scenario, ScenarioSubmission, Trainee from app.domain import ekp -from app.domain.events import Exercise, SessionMode from app.main import app from app.scenarios import store -from app.session.dds import prepare_card -from app.session.state import SessionState +from app.session.dds import build_card +from app.session.timers import SessionTimers @pytest.fixture @@ -182,19 +181,10 @@ def test_submitted_kio_is_kept_intact_and_becomes_the_dds_card_after_approval(cl assert scenario.student_card.fire.object_kind == "мусорный контейнер" assert scenario.student_card.signs == source.signs assert "moderated-kio" in scenario.topics - state = SessionState( - session_id=UUID("00000000-0000-4000-8000-000000000701"), - scenario_id=scenario.id, - scenario_title=scenario.title, - level=scenario.level.value, - mode=SessionMode.TRAINING, - exercise=Exercise.DDS, - ) - prepare_card(state, scenario) - assert state.dispatched_card is not None - assert state.dispatched_card.address == scenario.student_card.address - assert state.dispatched_card.caller_name == "Учебный заявитель" - assert state.dispatched_card.fire.object_kind == "мусорный контейнер" + card = build_card(scenario, 0, SessionTimers().limits) + assert card.dispatched_card.address == scenario.student_card.address + assert card.dispatched_card.caller_name == "Учебный заявитель" + assert card.dispatched_card.fire.object_kind == "мусорный контейнер" assert any( item["id"] == scenario.id and item["source"] == "trainee" for item in client.get("/api/scenarios").json() diff --git a/backend/tests/test_session_checkpoint.py b/backend/tests/test_session_checkpoint.py index 9871457..ad6ca28 100644 --- a/backend/tests/test_session_checkpoint.py +++ b/backend/tests/test_session_checkpoint.py @@ -20,7 +20,7 @@ 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 deliver_due_cards, prepare_handoff_queue, prepare_queue +from app.session.dds import prepare_handoff_queue, prepare_queue from app.session.hub import LEASE_FENCED_MESSAGE, SessionHub from app.session.state import DdsCardRecord, SessionState, now_utc @@ -37,7 +37,6 @@ def dds_state() -> SessionState: mode=SessionMode.TRAINING, exercise=Exercise.DDS, scenario=scenario.model_copy(deep=True), - dds_scenarios=[scenario.model_copy(deep=True)], required_fields=list(scenario.required_fields), trainee_name="Курсант для восстановления", attempt=2, @@ -48,17 +47,18 @@ def dds_state() -> SessionState: ), ) state.timers.limits[TimerCode.DDS_ACK] = 45_000 - prepare_queue(state, state.dds_scenarios) - service = state.notified_services()[0] - state.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу", author="диспетчер") - state.crew_selected = state.crew_options()[0] - state.crew_assignments[service] = state.crew_selected - state.phone_pending = PhoneCallPending( - service=service, crew=state.crew_selected, phase="dispatched" + prepare_queue(state, [scenario.model_copy(deep=True)]) + card = state.desk.active + service = state.card_services(card)[0] + card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу", author="диспетчер") + card.crew_selected = state.crew_options(card)[0] + card.crew_assignments[service] = card.crew_selected + card.phone_pending = PhoneCallPending( + service=service, crew=card.crew_selected, phase="dispatched" ) - state.reply_text = "Сообщение принято, бригада направлена." - state.reply_log.append((now_utc(), state.reply_text)) - state.dds_log.append(("crew.select", now_utc(), state.crew_selected)) + card.reply_text = "Сообщение принято, бригада направлена." + card.reply_log.append((now_utc(), card.reply_text)) + card.dds_log.append(("crew.select", now_utc(), card.crew_selected)) return state @@ -74,16 +74,17 @@ def test_active_dds_session_round_trips_without_losing_work(): assert restored.session_id == before.session_id assert restored.exercise is Exercise.DDS assert restored.criteria.decision_time_limit_seconds == 45 - assert restored.dispatched_card == before.dispatched_card - assert restored.status_log == before.status_log - assert restored.crew_selected == before.crew_selected - assert restored.crew_assignments == before.crew_assignments - assert restored.phone_pending == before.phone_pending - assert restored.reply_text == before.reply_text + card, card_before = restored.desk.active, before.desk.active + assert card.dispatched_card == card_before.dispatched_card + assert card.status_log == card_before.status_log + assert card.crew_selected == card_before.crew_selected + assert card.crew_assignments == card_before.crew_assignments + assert card.phone_pending == card_before.phone_pending + assert card.reply_text == card_before.reply_text assert restored.processed_station_commands == before.processed_station_commands - assert restored.dds_scenarios[0].id == before.scenario_id + assert restored.desk.scenarios[0].id == before.scenario_id # Время простоя backend входит в норматив, а не обнуляет таймер. - timer = next(item for item in restored.timers.snapshot() if item.code is TimerCode.DDS_ACK) + timer = next(item for item in card.timers.snapshot() if item.code is TimerCode.DDS_ACK) assert timer.elapsed_ms >= 1_900 assert timer.limit_ms == 45_000 @@ -112,19 +113,19 @@ def completed_card() -> DdsCardRecord: def test_text_revealed_facts_and_completed_cards_round_trip_whole(): before = dds_state() before.text_revealed_facts = {"address": "улица Ленина, 14", "floor": "5 этаж, уточнено"} - before.dds_completed = [completed_card()] + before.desk.completed = [completed_card()] restored = load_state(dump_state(before), now_utc()) assert restored.text_revealed_facts == before.text_revealed_facts - assert restored.dds_completed == before.dds_completed - assert restored.dds_completed[0].received_at.tzinfo is not None + assert restored.desk.completed == before.desk.completed + assert restored.desk.completed[0].received_at.tzinfo is not None def test_legacy_checkpoint_without_new_keys_gets_defaults(): before = dds_state() before.text_revealed_facts = {"address": "улица Ленина, 14"} - before.dds_completed = [completed_card()] + before.desk.completed = [completed_card()] payload = dump_state(before) del payload["text_revealed_facts"] legacy_keys = {"card_id", "scenario_id", "reply_text", "metrics", @@ -137,8 +138,8 @@ def test_legacy_checkpoint_without_new_keys_gets_defaults(): restored = load_state(payload, now_utc()) assert restored.text_revealed_facts == {} - card = restored.dds_completed[0] - assert card.card_id == before.dds_completed[0].card_id + card = restored.desk.completed[0] + assert card.card_id == before.desk.completed[0].card_id assert (card.title, card.address, card.description, card.incident_type, card.victims_count, card.received_at, card.managed_service) == (None,) * 7 assert card.recipient_services == [] @@ -164,26 +165,25 @@ def test_concurrent_dds_queue_round_trips_with_each_timer_and_status(): level=first.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS, - dds_scenarios=[first, second], ) - prepare_queue(state, state.dds_scenarios) - first_id = state.dispatched_card.card_id - first_service = state.managed_services()[0] - state.set_service_status(first_service, ServiceStatus.ACCEPTED, "Принято в работу") - state.on_event("card.ack") - second_id = state.dds_live_cards[1].card_id - assert state.activate_dds_card(second_id) + prepare_queue(state, [first, second]) + first_card = state.desk.active + first_id = first_card.card_id + first_service = state.card_services(first_card)[0] + first_card.set_service_status(first_service, ServiceStatus.ACCEPTED, "Принято в работу") + first_card.on_event("card.ack") + second_id = state.desk.ordered()[1].card_id + assert state.desk.open(second_id) restored = load_state( dump_state(state), datetime.now(UTC) - timedelta(seconds=2), ) - assert restored.dds_active_card_id == second_id - assert restored.dispatched_card.card_id == second_id - assert len(restored.dds_live_cards) == 2 - first_restored = next(item for item in restored.dds_live_cards if item.card_id == first_id) - second_restored = next(item for item in restored.dds_live_cards if item.card_id == second_id) + assert restored.desk.active_id == second_id + assert len(restored.desk.cards) == 2 + first_restored = restored.desk.cards[first_id] + second_restored = restored.desk.cards[second_id] assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED assert first_restored.timers.measured_ms(TimerCode.DDS_ACK) is not None second_timer = next( @@ -209,27 +209,25 @@ def test_delivering_next_dds_card_does_not_clear_previous_card_state(): level=scenarios[0].level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS, - dds_scenarios=scenarios, ) prepare_queue(state, scenarios, arrival_interval_seconds=60, max_waiting=1) - first_id = state.dds_live_cards[0].card_id - service = state.managed_services()[0] - state.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу") - state.capture_active_dds() + first = state.desk.active + first_id = first.card_id + service = state.card_services(first)[0] + first.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу") - assert deliver_due_cards(state, now_utc() + timedelta(seconds=61)) == 1 - first = next(card for card in state.dds_live_cards if card.card_id == first_id) - assert first.status_log[-1].status is ServiceStatus.ACCEPTED - second = next(card for card in state.dds_live_cards if card.original_index == 1) - assert state.activate_dds_card(second.card_id) + assert state.desk.deliver_due(now_utc() + timedelta(seconds=61)) == 1 + assert state.desk.cards[first_id].status_log[-1].status is ServiceStatus.ACCEPTED + second = next(card for card in state.desk.ordered() if card.original_index == 1) + assert state.desk.open(second.card_id) restored = load_state(dump_state(state), now_utc()) - first_restored = next(card for card in restored.dds_live_cards if card.card_id == first_id) + first_restored = restored.desk.cards[first_id] assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED - assert restored.dds_active_card_id == second.card_id - assert len(restored.dds_live_cards) == 2 - assert restored.dds_next_scenario_index == 2 - assert restored.dds_next_arrival_at is not None + assert restored.desk.active_id == second.card_id + assert len(restored.desk.cards) == 2 + assert restored.desk.next_index == 2 + assert restored.desk.next_arrival_at is not None def test_mixed_handoff_checkpoint_preserves_operator_card_and_generated_queue(): @@ -249,10 +247,10 @@ def test_mixed_handoff_checkpoint_preserves_operator_card_and_generated_queue(): assert restored.operator_kio.address == "улица Ленина, 14" assert restored.operator_scenario.id == first.id - assert restored.dds_scenarios[0].id == first.id - assert restored.dds_scenarios[1].id == second.id - assert len(restored.dds_live_cards) == 2 - assert restored.dds_active_card_id == state.dds_active_card_id + assert restored.desk.scenarios[0].id == first.id + assert restored.desk.scenarios[1].id == second.id + assert len(restored.desk.cards) == 2 + assert restored.desk.active_id == state.desk.active_id def test_checkpoint_storage_failure_fences_and_notifies_all_data_channels(): diff --git a/backend/tests/test_station.py b/backend/tests/test_station.py index 8598a40..70c6010 100644 --- a/backend/tests/test_station.py +++ b/backend/tests/test_station.py @@ -180,7 +180,7 @@ def test_dispatcher_sets_a_status_and_the_card_follows(client): station.send_json({"type": "card.status", "service": "Служба 101", "status": "accepted"}) error = read_until(station, "error") assert "комментар" in error["message"] - assert hub.get(session_id).status_log == [] + assert hub.get(session_id).desk.active.status_log == [] command_id = str(uuid4()) accepted = { "type": "card.status", "service": "Служба 101", "status": "accepted", @@ -196,7 +196,7 @@ def test_dispatcher_sets_a_status_and_the_card_follows(client): station.send_json(accepted) assert read_until(station, "command.ack")["command_id"] == command_id runtime = hub.get(session_id) - assert sum(item.service == "Служба 101" for item in runtime.status_log) == 1 + assert sum(item.service == "Служба 101" for item in runtime.desk.active.status_log) == 1 assert "responding" in state["snapshot"]["available"]["Служба 101"] assert "accepted" not in state["snapshot"]["available"]["Служба 101"] finally: @@ -212,7 +212,7 @@ def test_out_of_order_status_is_rejected_with_a_reason(client): "comment": "Бригада доложила старшему о прибытии."}) error = read_until(station, "error") assert "Принята" in error["message"] - assert hub.get(session_id).status_log == [] + assert hub.get(session_id).desk.active.status_log == [] finally: close_all(contexts)