refactor: пульт ДДС владеет живыми карточками, сессия не копирует их поля

Связка 112→ДДС больше не подменяет таймеры оператора таймерами первой
карточки ДДС, поэтому время заполнения карточки 112 снова попадает в оценку.
This commit is contained in:
gglamer 2026-09-26 21:38:09 +00:00
commit 5a3761a830
24 changed files with 2093 additions and 762 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 без очереди ДДС лимит преподавателя больше нигде не виден.

View file

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

View file

@ -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 с) не запускается ничем, и это сознательно. Стартуй он