lct-hack/backend/app/session/dds.py

887 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Пульт ДДС: одновременно выданные учебные карточки одного занятия.
Живое состояние карточки существует в одном экземпляре — в `DdsLiveCard`.
Пульт хранит карточки и id активной; переключение меняет id и ничего не
копирует. Правила команд станции — `DdsDesk.apply`: сокет только разбирает
команду, сохраняет итог и рассылает события.
"""
import re
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from uuid import UUID, uuid4
from pydantic import BaseModel, Field
from app.domain import ekp
from app.domain.events import Exercise, Metric, PhoneLine, PhoneReport, StationState
from app.domain.kio import KIO, ResponseStatus, apply_patch
from app.domain.statuses import (
ADJACENT_AUTHOR,
ADJACENT_CALL_AUTHOR,
PRIMARY,
SERVICE_STATUS_LABELS,
PhoneCallPending,
PhoneLineRecord,
PhoneReportRecord,
ServiceStatus,
StatusEntry,
StatusError,
check,
current,
)
from app.domain.taxonomy import Finding
from app.domain.timers import TimerCode
from app.scenarios.schema import Scenario
from app.scoring.address import address_matches
from app.scoring.adjacent import active_ms
from app.session.timers import SessionTimers, now_utc
if TYPE_CHECKING:
from app.session.state import SessionState
class DdsCardRecord(BaseModel):
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
class DdsLiveCard(BaseModel):
"""Изолированное живое состояние одной одновременно выданной карточки."""
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)
#: Принятие карточки курсантом — старт таймлайна смежных служб и вводной.
adjacent_started_at: datetime | None = None
#: Сколько записей отсортированного таймлайна уже выдано (или пропущено).
adjacent_next_index: int = 0
#: Вводная «бригада просит смежников»: когда пришла и чем закрыта.
crew_request_at: datetime | None = None
adjacent_reply_at: datetime | None = None
#: Курсант передал бригаде «смежная служба реагирует» (иначе — уточнит).
adjacent_reply_claimed: bool | None = None
#: Первый звонок диспетчеру смежной службы из вводной.
adjacent_call_at: datetime | None = None
@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), ""
)
caller_fact = next(
(
fact.value
for fact in scenario.facts
if fact.id in {"f_caller", "f_applicant"}
),
"",
)
caller_phone = next(
(
match.group(0).strip()
for match in re.finditer(
r"(?<!\d)(?:\+?7|8)?(?:[\s().-]*\d){10,11}(?!\d)", caller_fact
)
if len(re.sub(r"\D", "", match.group(0))) in {10, 11}
),
None,
)
caller_names = re.findall(
r"(?<![А-ЯЁа-яё])([А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ][а-яё-]+){1,2})(?![А-ЯЁа-яё])",
caller_fact,
)
# Some ticket facts name multiple witnesses; don't silently attribute the
# contact number to the first person when the source doesn't disambiguate.
caller_name = caller_names[0] if len(caller_names) == 1 else None
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
service = truth.dds.value if truth.dds else 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.
kio = scenario.student_card.model_copy(
deep=True,
update={
"card_id": uuid4(),
"registered_at": now_utc(),
"response_status": ResponseStatus.REGISTERED,
"dispatch_order_at": None,
"arrival_at": None,
},
)
else:
kio = KIO(
registered_at=now_utc(),
caller_number=caller_phone,
caller_name=caller_name,
caller_contact=caller_phone,
address=truth.address or address_fact or None,
floor=floor.group(1) if floor else None,
incident_type=truth.incident_type,
incident_code=truth.incident_code,
incident_group=(
ekp.incident(truth.incident_code).group
if truth.incident_code and ekp.incident(truth.incident_code)
else None
),
dds=truth.dds,
signs=list(scenario.signs),
notify=scenario.notify_services(),
victims_count=truth.victims,
description="; ".join(fact.value for fact in scenario.facts[:3])
or scenario.first_line,
)
if service:
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=scenario,
kio=kio,
dispatched_card=kio.model_copy(deep=True),
dispatched_at=now_utc(),
timers=timers,
)
#: Команды, которым не нужна активная карточка пульта.
CARDLESS_COMMANDS = {"card.open", "station.finish", "card.bounce"}
#: Доклады бригады идут строго по порядку; перед каждым звонком служба
#: должна стоять не раньше нужного статуса.
REPORT_PHASES = ("dispatched", "arrived", "working", "completed")
REQUIRED_STATUS = {
"dispatched": ServiceStatus.ACCEPTED,
"arrived": ServiceStatus.RESPONDING,
"working": ServiceStatus.ARRIVED,
"completed": ServiceStatus.WORKING,
}
STATUS_AT_OR_AFTER = {
"dispatched": {ServiceStatus.ACCEPTED, ServiceStatus.RESPONDING,
ServiceStatus.ARRIVED, ServiceStatus.WORKING},
"arrived": {ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING},
"working": {ServiceStatus.ARRIVED, ServiceStatus.WORKING},
"completed": {ServiceStatus.WORKING, ServiceStatus.COMPLETED},
}
@dataclass
class DeskOutcome:
"""Итог команды пульта. Отказ ничего не меняет и событий не несёт."""
#: События станции в порядке отправки.
events: list[Any] = field(default_factory=list)
error: str | None = None
#: Состояние изменилось: транспорт сохраняет его и шлёт свежий снимок.
#: Повтор уже учтённой команды проходит молча, без записи.
changed: bool = False
#: Занятие ДДС дошло до конца; завершает его сессия, а не пульт.
finished: bool = False
def _refused(message: str) -> DeskOutcome:
return DeskOutcome(error=message)
def _start_work_timer(card: DdsLiveCard) -> None:
"""Открытие карточки: останавливает 30 с и запускает 3 мин — один раз.
Автоматическая активация первой карточки очереди открытием не считается:
карточка стоит в строке сообщений, пока диспетчер её не откроет. Первое
действие по карточке без явного открытия тоже доказывает, что она открыта.
"""
timer = card.timers.timers.get(TimerCode.DDS_WORK)
if timer is None or timer.started_at is None:
card.on_event("dds.open")
def _first_record(card: DdsLiveCard, comment: str) -> None:
"""Первая запись — ручной статус с текстом; она закрывает 3 минуты (П.5)."""
if comment.strip():
card.on_event("dds.record")
def _line(card: DdsLiveCard, speaker: str, text: str) -> PhoneLine | None:
call = card.phone_pending
if call is None:
return None
line = PhoneLineRecord(service=call.service, crew=call.crew,
speaker=speaker, text=text, at=now_utc())
card.phone_lines.append(line)
card.dds_log.append((f"phone.line.{speaker}", line.at, f"{call.crew}: {text}"))
return PhoneLine(**line.model_dump())
def _incident_matches(card: DdsLiveCard, supplied: str) -> bool:
text = supplied.casefold()
# Описание часто начинается с адреса: его нельзя считать совпадением
# характера происшествия. Берём только название сценария и признаки ЕКП.
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)
def _has_purpose(text: str, stems: tuple[str, ...]) -> bool:
normalized = text.casefold()
return len(text.strip()) >= 8 and any(stem in normalized for stem in stems)
def _report_text(phase: str, crew: str, address: str) -> str:
match phase:
case "dispatched":
return f"{crew}: вызов по адресу {address} принят, выезжаем. О прибытии доложу."
case "arrived":
return f"{crew}: прибыли по адресу {address}. Уточняем обстановку на месте."
case "working":
return f"{crew}: обстановка уточнена, приступили к работам. Сообщим о завершении."
case _:
return f"{crew}: работы завершены. Дальнейшая помощь от нашей бригады не требуется."
#: Фаза входящего вызова бригады с запросом к смежной службе.
ADJACENT_REQUEST_PHASE = "adjacent_request"
#: Ответ бригаде «смежная служба реагирует»: только глаголы самой реакции.
#: Общие слова вроде «будут»/«работы» сюда нельзя — «сведения будут позже»
#: не успокаивание, а классификация напрямую задаёт отметку D6.
_REASSURE_STEMS = ("реагир", "приня", "выех", "выезжа", "направ", "едут",
"прибуд", "отреагир")
#: Ответ «уточню и свяжусь»: курсант не подтверждает реакцию, а берёт запрос на себя.
_DEFER_STEMS = ("свяж", "уточн", "выясн", "позвон", "запрош", "запрос",
"ожидай", "жди", "вызову", "сообщу", "передам позже")
#: Отрицание реакции сильнее её глагола: «не реагируют, разберусь» — не
#: успокаивание, хотя основа «реагир» в реплике есть.
_SILENT_MARKERS = ("не реагир", "не приня", "не отреагир", "молч",
"нет реакц", "не отмет", "пока нет", "не видно")
def classify_reply(text: str) -> bool | None:
"""Что курсант передал бригаде: True — «смежники реагируют», False —
«уточню и свяжусь», None — не разобрать (команда отклоняется).
Утверждение реакции сильнее обещания уточнить: «служба реагирует,
сообщу о прибытии» — успокаивание, и проверяться должно оно.
"""
if _has_purpose(text, _SILENT_MARKERS):
return False
if _has_purpose(text, _REASSURE_STEMS):
return True
if _has_purpose(text, _DEFER_STEMS):
return False
return None
#: Запрос смежной службе должен называть, что от неё требуется по карточке.
_ADJACENT_REQUEST_STEMS = ("обесточ", "отключ", "перекр", "реагир", "выезд",
"выех", "направ", "приступ", "помощ", "прошу",
"требу", "статус", "обстанов", "карточк", "бригад")
def deliver_adjacent_due(state: "SessionState", now: datetime | None = None) -> bool:
"""Выдать созревшие отметки смежных служб и вводные по всем живым карточкам.
Вызывается тикером хаба вне паузы; сроки считаются по активному времени
от принятия карточки, поэтому простой сдвигает таймлайн, а не съедает его.
"""
now = now or now_utc()
changed = False
for card in state.desk.ordered():
changed |= _deliver_card_adjacent(state, card, now)
return changed
def _deliver_card_adjacent(state: "SessionState", card: DdsLiveCard, now: datetime) -> bool:
if card.adjacent_started_at is None:
return False
managed = set(state.card_services(card))
elapsed_ms = active_ms(card.adjacent_started_at, now, state.pause_intervals)
timeline = sorted(card.scenario.adjacent_timeline, key=lambda item: item.after_seconds)
changed = False
while card.adjacent_next_index < len(timeline):
entry = timeline[card.adjacent_next_index]
if elapsed_ms < entry.after_seconds * 1000:
break
card.adjacent_next_index += 1
if entry.service in managed:
continue # службу курсанта симуляция не подменяет
try:
card.set_service_status(entry.service, entry.status, entry.comment,
author=ADJACENT_AUTHOR)
changed = True
except StatusError:
# Звонок курсанта уже сдвинул статус службы дальше таймлайна.
continue
request = card.scenario.crew_request
# Вводная ждёт, пока карточка открыта на пульте: входящий вызов фоновой
# карточки курсант не видит, и лимит тикал бы без шанса среагировать.
if (request is not None and card.crew_request_at is None
and request.service not in managed
and elapsed_ms >= request.after_seconds * 1000
and card.phone_pending is None and managed
and card.card_id == state.desk.active_id):
own_service = state.card_services(card)[0]
crew = card.crew_assignments.get(own_service) or f"{own_service} — бригада №1"
card.phone_pending = PhoneCallPending(
service=own_service, crew=crew, phase=ADJACENT_REQUEST_PHASE,
incoming=True, adjacent_service=request.service,
)
card.phone_lines.append(PhoneLineRecord(
service=own_service, crew=crew, speaker="crew", text=request.text, at=now,
))
card.dds_log.append(("adjacent.request", now, request.service))
card.crew_request_at = now
changed = True
return changed
def _finish_phone_call(card: DdsLiveCard) -> list[Any]:
call = card.phone_pending
assert call is not None
text = _report_text(call.phase, call.crew, card.dispatched_card.address or "из карточки")
line = _line(card, "crew", text)
report = PhoneReportRecord(service=call.service, crew=call.crew,
phase=call.phase, text=text, at=now_utc())
card.phone_reports.append(report)
card.dds_log.append(("phone.report", now_utc(), f"{call.crew}: {call.phase}"))
card.phone_pending = None
return [line, PhoneReport(**report.model_dump())]
class DdsDesk(BaseModel):
"""Живые карточки занятия, активная из них и очередь поступления."""
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
@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],
)
self.open(following.card_id)
return following
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)
def deliver_due(self, now: datetime | None = None) -> int:
"""Выдать всё, чему настал срок и для чего есть место в ожидании.
Активная карточка не считается ожидающей. Каждая карточка запускает
собственный 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 apply(self, command: Any, session: "SessionState") -> DeskOutcome:
"""Применить команду станции к пульту: правила карточки без транспорта.
Из сессии берутся упражнение, службы обучающегося и снимки для
станции. Отказ возвращается до первой записи — состояние не меняется.
"""
if session.ended:
return _refused("Занятие уже завершено")
card = self.active
if card is None and command.type not in CARDLESS_COMMANDS:
return _refused("Нет активной карточки")
dds_lesson = session.exercise is Exercise.DDS or session.handoff_to_dds
outcome = DeskOutcome(changed=True)
match command.type:
case "card.ack":
# Подтверждение приёма — это статус «Принята» у главной службы.
# Кнопка осталась ради живой цепочки 112 → ДДС (lct-20), где
# диспетчер один и выбирать службу не из чего.
if not command.comment.strip():
return _refused("Для подтверждения приёма добавьте комментарий с основанием")
if any(action == "card.ack" for action, _at, _detail in card.dds_log):
return DeskOutcome()
card.dds_log.append(("card.ack", now_utc(), None))
_start_work_timer(card)
services = session.card_services(card)
if services:
try:
card.set_service_status(
services[0], ServiceStatus.ACCEPTED, command.comment,
author="диспетчер",
)
session.consecutive_refusals = 0
_first_record(card, command.comment)
if card.adjacent_started_at is None:
card.adjacent_started_at = now_utc()
except StatusError:
pass # статус уже стоит: повторное нажатие ничего не меняет
case "card.status":
if command.service not in session.card_services(card):
return _refused("Можно менять статусы только своей ДДС")
try:
check(card.status_log, command.service, command.status, command.comment)
except StatusError as exc:
return _refused(str(exc))
_start_work_timer(card)
card.set_service_status(
command.service, command.status, command.comment, author="диспетчер"
)
_first_record(card, command.comment)
if command.status is ServiceStatus.ACCEPTED and card.adjacent_started_at is None:
card.adjacent_started_at = now_utc()
if command.status in PRIMARY:
# Сигнал реестра "повторные отказы" считает подряд идущие
# отказы, а не сумму за занятие.
session.consecutive_refusals = (
session.consecutive_refusals + 1
if command.status is ServiceStatus.DECLINED
else 0
)
case "crew.select":
if command.crew not in session.crew_options(card):
return _refused("Выберите бригаду из списка доступных")
service = session.crew_service(card, command.crew)
assigned = card.crew_assignments.get(service)
if assigned and assigned != command.crew and any(
report.service == service for report in card.phone_reports
):
return _refused("После первого доклада бригаду этой службы менять нельзя")
if card.phone_pending is not None:
return _refused("Завершите текущий разговор перед сменой бригады")
if card.crew_selected == command.crew and assigned == command.crew:
return DeskOutcome()
card.crew_selected = command.crew
card.crew_assignments[service] = command.crew
card.dds_log.append(("crew.select", now_utc(), command.crew))
case "phone.dial":
if card.phone_pending is not None:
return _refused("Разговор уже идёт: передайте сведения или завершите звонок")
crew = card.crew_selected
service = session.crew_service(card, crew) if crew else None
if service is None:
return _refused("Сначала выберите бригаду")
previous = [report for report in card.phone_reports if report.service == service]
if len(previous) >= len(REPORT_PHASES):
return _refused("Все доклады этой бригады уже получены")
phase = REPORT_PHASES[len(previous)]
required = REQUIRED_STATUS[phase]
if current(card.status_log, service) not in STATUS_AT_OR_AFTER[phase]:
return _refused(
f"Перед звонком нужен статус «{SERVICE_STATUS_LABELS[required]}» этой службы"
)
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}, старший группы на связи. Слушаю ваш запрос по карточке.")
outcome.events.append(_line(card, "crew", greeting))
case "phone.brief":
call = card.phone_pending
if call is None or call.phase != "dispatched":
return _refused("Сначала соединитесь со старшим группы для передачи вызова")
if not address_matches(card.dispatched_card.address, command.address):
return _refused("Проверьте адрес: улица и номер дома должны совпадать с карточкой")
if not _incident_matches(card, command.incident):
return _refused("Уточните характер происшествия по данным карточки")
if not _has_purpose(command.request, ("выезд", "выех", "направ", "прибыт",
"реагир", "помощ", "подтверд", "долож")):
return _refused("Сформулируйте задачу: выезд, помощь или доклад бригады")
outcome.events.append(_line(
card, "dispatcher", f"Адрес: {command.address.strip()}. "
f"Происшествие: {command.incident.strip()}. {command.request.strip()}"))
outcome.events.extend(_finish_phone_call(card))
case "phone.check":
call = card.phone_pending
if call is not None and call.incoming:
return _refused("Это входящий вызов бригады: ответьте на её запрос")
if call is None or call.phase == "dispatched":
return _refused("Сначала передайте вызов, затем запросите обстановку")
if not _has_purpose(command.text, ("обстанов", "статус", "прибыл", "доех",
"выех", "работ", "заверш", "мест",
"ход", "сообщ", "долож", "уточн")):
return _refused("Спросите обстановку, прибытие или ход работ по карточке")
outcome.events.append(_line(card, "dispatcher", command.text.strip()))
outcome.events.extend(_finish_phone_call(card))
case "phone.hangup":
if card.phone_pending is None:
return _refused("Нет активного разговора")
if card.phone_pending.incoming:
# Сброс закрыл бы вводную без ответа навсегда: бригада
# повторно не звонит, а метрика исхода уже не пройдёт.
return _refused(
"Ответьте бригаде: подтвердите реакцию смежной службы "
"или скажите, что уточните и свяжетесь с ней"
)
card.dds_log.append(("phone.hangup", now_utc(), card.phone_pending.crew))
card.phone_pending = None
case "phone.reply":
call = card.phone_pending
if call is None or not call.incoming:
return _refused("Нет входящего вызова бригады")
claimed = classify_reply(command.text)
if claimed is None:
return _refused(
"Скажите бригаде, реагирует ли смежная служба, "
"либо что уточните по карточке и свяжетесь с ней"
)
outcome.events.append(_line(card, "dispatcher", command.text.strip()))
confirmation = (
f"Понял вас: {call.adjacent_service} реагирует. Продолжаем работы."
if claimed else
"Понял вас, ожидаем уточнения по смежной службе. Остаёмся на месте."
)
outcome.events.append(_line(card, "crew", confirmation))
reply_at = now_utc()
card.dds_log.append(
("adjacent.reply", reply_at, "reassure" if claimed else "defer")
)
card.adjacent_reply_at = reply_at
card.adjacent_reply_claimed = claimed
card.phone_pending = None
case "adjacent.dial":
if card.phone_pending is not None:
return _refused(
"Сначала ответьте бригаде"
if card.phone_pending.incoming
else "Завершите текущий разговор перед звонком смежной службе"
)
if (command.service not in card.dispatched_card.notify
or command.service in session.card_services(card)):
return _refused("Звонить можно смежной службе из списка оповещения карточки")
if not _has_purpose(command.text, _ADJACENT_REQUEST_STEMS):
return _refused("Сформулируйте запрос: что требуется от смежной службы по карточке")
if any(action == "adjacent.dial" and detail == command.service
for action, _at, detail in card.dds_log):
# Ответ скриптовый и не меняется: повторный звонок только
# замусорил бы журнал и разбор парами одинаковых реплик.
return _refused("Разговор с этой службой уже состоялся; сведения — в журнале")
dialed_at = now_utc()
dispatcher_name = f"{command.service} — диспетчер"
def adjacent_line(speaker: str, text: str) -> PhoneLine:
line = PhoneLineRecord(service=command.service, crew=dispatcher_name,
speaker=speaker, text=text, at=now_utc())
card.phone_lines.append(line)
card.dds_log.append(
(f"phone.line.{speaker}", line.at, f"{dispatcher_name}: {text}")
)
return PhoneLine(**line.model_dump())
outcome.events.append(adjacent_line("dispatcher", command.text.strip()))
status_now = current(card.status_log, command.service)
if status_now in {ServiceStatus.ADDED, ServiceStatus.RECEIVED}:
card.set_service_status(
command.service, ServiceStatus.ACCEPTED,
"Основание: звонок диспетчера по карточке\n"
"Сведения: запрос принят, бригада направляется",
author=ADJACENT_CALL_AUTHOR,
)
answer = "Запрос принят, направляем бригаду. Статус отметим в карточке."
elif status_now in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}:
answer = "По этой карточке оформлен отказ; основание — в комментарии к статусу."
else:
answer = "Мы уже реагируем по карточке, ход работ — в статусах."
outcome.events.append(adjacent_line("crew", answer))
card.dds_log.append(("adjacent.dial", dialed_at, command.service))
request = card.scenario.crew_request
if (request is not None and command.service == request.service
and card.adjacent_call_at is None):
card.adjacent_call_at = dialed_at
case "card.reply":
if not dds_lesson or command.card_id != card.card_id:
return _refused("Ответ относится не к текущей карточке")
# A browser may lose the acknowledgement after the server has
# 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 card.reply_text == command.text:
return DeskOutcome()
card.reply_text = command.text
card.reply_log.append((now_utc(), command.text))
case "card.open":
if not dds_lesson or not self.open(command.card_id):
return _refused("Карточка отсутствует в текущей очереди")
_start_work_timer(self.active)
# CardReceived carries the contents, while StationState carries
# the status journal and current queue. Send both on every switch
# so the newly opened card cannot briefly inherit the previous
# card's status snapshot until the next periodic tick.
outcome.events += [session.card_received_event(),
StationState(snapshot=session.station_snapshot())]
case "card.next":
if not dds_lesson or command.card_id != card.card_id:
return _refused("Следующая карточка недоступна: ID текущей не совпадает")
if self.is_completed(command.card_id):
return _refused("Эта карточка уже завершена")
if card.phone_pending is not None and card.phone_pending.incoming:
# Один клик не должен запирать D6: карточка с висящим
# входящим вызовом оценилась бы как проигнорированная вводная.
return _refused("Сначала ответьте бригаде на входящий вызов")
# Оценка живёт в модуле завершения, который сам зависит от пульта.
from app.session.finish import score_dds_card
# 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 self.complete(card.card_id, score_dds_card(session, card)) is not None:
outcome.events.append(session.card_received_event())
active_before_delivery = self.active_id
self.deliver_due()
if self.active_id and self.active_id != active_before_delivery:
outcome.events.append(session.card_received_event())
outcome.events.append(StationState(snapshot=session.station_snapshot()))
if not self.cards and len(self.completed) >= len(self.scenarios):
self.finish_work()
outcome.finished = True
case "station.finish":
if not dds_lesson:
return _refused("Операторское занятие завершается после звонка 112")
self.finish_work()
outcome.finished = True
case "card.bounce":
# Возврата карточки на этапе принятия нет; об ошибке, которую
# вскрыл доклад бригады с места, ДДС сообщает в 112 по телефону
# (ответ заказчика, П.3).
return _refused(
"Карточку не возвращают в 112 при принятии и не правят на АРМ ДДС. "
"Если ошибка вскрылась по докладу бригады с места — сообщите о ней "
"в 112 по телефону"
)
case "zone.decision":
previous = next(
(detail for action, _at, detail in reversed(card.dds_log)
if action == "zone.decision"),
None,
)
decision = "в зоне" if command.in_zone else "не в зоне"
if previous is not None:
if previous != decision:
return _refused("Решение по зоне уже записано для этой карточки")
return DeskOutcome()
card.on_event("zone.decision")
card.dds_log.append(("zone.decision", now_utc(), decision))
case "crew.dispatched":
card.kio = card.kio.model_copy(update={"dispatch_order_at": command.at})
card.dds_log.append(("crew.dispatched", now_utc(), None))
case "crew.arrived":
card.on_event("crew.arrived")
card.kio = card.kio.model_copy(update={"arrival_at": command.at})
card.dds_log.append(("crew.arrived", now_utc(), None))
return outcome
def finish_work(self) -> None:
"""Конец занятия останавливает трёхминутный таймер у каждой начатой карточки."""
for card in self.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")
def prepare_queue(
state,
scenarios: list[Scenario],
arrival_interval_seconds: int = 0,
max_waiting: int = 3,
) -> None:
"""Упражнение ДДС: очередь готовых карточек с лимитами занятия."""
state.desk.start(
scenarios,
limits=state.timers.limits,
arrival_interval_seconds=arrival_interval_seconds,
max_waiting=max_waiting,
)
def prepare_handoff_queue(
state,
additional_scenarios: list[Scenario],
arrival_interval_seconds: int = 0,
max_waiting: int = 3,
) -> None:
"""Передать созданную курсантом карточку в ДДС перед готовыми карточками."""
if state.dispatched_card is None or state.scenario is None:
return
now = now_utc()
state.operator_kio = state.dispatched_card.model_copy(deep=True)
state.operator_scenario = state.scenario.model_copy(deep=True)
# Карточка курсанта становится первым живым объектом очереди. Её данные
# не переписываются из эталона: именно их будет обрабатывать ДДС.
dds_timers = SessionTimers(limits=dict(state.timers.limits))
dds_timers.on_event("dds.dispatch")
first = DdsLiveCard(
original_index=0,
scenario=state.operator_scenario,
kio=state.operator_kio.model_copy(deep=True),
dispatched_card=state.operator_kio.model_copy(deep=True),
dispatched_at=state.dispatched_at or now,
timers=dds_timers,
)
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
else now
)
if additional_scenarios
else None
)
desk.open(first.card_id)
desk.deliver_due(now)