491 lines
25 KiB
Python
491 lines
25 KiB
Python
"""Состояние живой сессии: карточка, транскрипт, таймеры, режим, попытка.
|
||
|
||
Живёт в памяти процесса — поэтому воркер uvicorn ровно один: с двумя
|
||
преподаватель подключился бы к другому процессу, чем курсант, и увидел
|
||
пустой экран (docs/arch/STACK.md).
|
||
"""
|
||
|
||
import time
|
||
from datetime import datetime
|
||
from typing import Any
|
||
from uuid import UUID
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
|
||
|
||
from app.dialog.caller import TemplateCaller
|
||
from app.dialog.persona import PersonaState
|
||
from app.dialog.slots import SlotMachine
|
||
from app.domain.events import (
|
||
CallEndReason,
|
||
Exercise,
|
||
LessonCriteria,
|
||
Mood,
|
||
SessionMode,
|
||
SessionSnapshot,
|
||
Speaker,
|
||
TranscriptEntry,
|
||
)
|
||
from app.domain.kio import KIO, ResponseStatus, apply_patch
|
||
from app.domain.statuses import (
|
||
NEXT,
|
||
CardStatus,
|
||
DdsCardSummary,
|
||
DdsQueueCard,
|
||
ServiceStatus,
|
||
StationSnapshot,
|
||
card_status,
|
||
current,
|
||
)
|
||
from app.domain.timers import TimerCode
|
||
from app.scenarios.schema import Scenario
|
||
from app.session.dds import DdsCardRecord, DdsDesk, DdsLiveCard
|
||
from app.session.timers import SessionTimers, now_utc
|
||
|
||
__all__ = ["DdsCardRecord", "DdsLiveCard", "PersistedSession", "SessionState", "now_utc"]
|
||
|
||
#: Сколько последних команд станции помнит защита от повтора.
|
||
MAX_STATION_COMMANDS = 512
|
||
|
||
|
||
class PersistedSession(BaseModel):
|
||
"""Сохраняемая часть занятия — ровно то, что пишется в снимок.
|
||
|
||
Снимок — `model_dump` этой модели, загрузка — `model_validate`, поэтому
|
||
поле сессии нельзя завести, не решив, сохраняемое оно или живое: подкласс
|
||
обязан пометить каждое своё поле `Field(exclude=True)`, иначе класс не
|
||
создастся (см. `__pydantic_init_subclass__`).
|
||
"""
|
||
|
||
session_id: UUID
|
||
scenario_id: str
|
||
scenario_title: str
|
||
level: str
|
||
mode: SessionMode
|
||
#: Преподаватель, создавший занятие; чужой пульт не может им управлять.
|
||
owner_login: str | None = None
|
||
#: Monotonic DB ownership generation; stale processes may not persist writes.
|
||
backend_fencing_epoch: int = 0
|
||
exercise: Exercise = Exercise.CALL
|
||
#: После заполнения КИО занятие продолжится на АРМ ДДС, а не завершится.
|
||
handoff_to_dds: bool = False
|
||
required_fields: list[str] = Field(default_factory=list)
|
||
trainee_name: str | None = None
|
||
#: Чьё это занятие. Проставляется при запуске, когда курсант известен
|
||
#: по учётной записи: по нему разбор закрывается от чужих (lct-23).
|
||
trainee_id: UUID | None = None
|
||
#: Конкретная ДДС обучающегося. Она меняет статусы только своей службы;
|
||
#: остальные адресаты карточки показываются информационно.
|
||
dds_service: str | None = None
|
||
attempt: int = 1
|
||
criteria: LessonCriteria = Field(default_factory=LessonCriteria)
|
||
|
||
kio: KIO = Field(default_factory=KIO)
|
||
transcript: list[TranscriptEntry] = Field(default_factory=list)
|
||
timers: SessionTimers = Field(default_factory=SessionTimers)
|
||
hints_shown: list[str] = Field(default_factory=list)
|
||
#: Когда именно подсказывали — в разборе видно, какой пункт и на какой минуте.
|
||
hints_log: list[tuple[str, datetime]] = Field(default_factory=list)
|
||
notes: list[dict] = Field(default_factory=list)
|
||
directives: list[str] = Field(default_factory=list)
|
||
|
||
# Своя копия сценария на занятие: директивы преподавателя правят факты
|
||
# и эталон, и правка в одной группе не должна протекать в остальные.
|
||
scenario: Scenario | None = None
|
||
|
||
# Аудио курсанта. До голосового контура (lct-06) кадры только считаются —
|
||
# этого достаточно, чтобы доказать, что звук доходит от микрофона до сервера.
|
||
audio_frames: int = 0
|
||
bad_frames: int = 0
|
||
|
||
# Самооценка сдана — до неё курсант не видит автооценку.
|
||
self_assessed: bool = False
|
||
self_assessment: dict | None = None
|
||
score: dict | None = None
|
||
|
||
started_at: datetime | None = None
|
||
ended_at: datetime | None = None
|
||
end_reason: CallEndReason | None = None
|
||
#: Пульт остановил время занятия; таймеры и очередь ДДС заморожены (lct-39).
|
||
paused: bool = False
|
||
#: Момент постановки на паузу — им же считается длительность простоя при снятии.
|
||
paused_at: datetime | None = None
|
||
#: Суммарная длительность пауз — для разбора, чтобы время было объяснимо.
|
||
total_paused_ms: int = 0
|
||
#: Карточка оператора 112, замороженная при передаче в ДДС.
|
||
dispatched_card: KIO | None = None
|
||
dispatched_at: datetime | None = None
|
||
#: Поля, из-за которых диспетчер вернул карточку, — основание E6.
|
||
bounced_fields: list[str] = Field(default_factory=list)
|
||
#: Готовые карточки связки 112→ДДС ждут, пока курсант не сдаст свою.
|
||
pending_dds_scenarios: list[Scenario] = Field(default_factory=list)
|
||
#: Исходная часть упражнения 112→ДДС сохраняется отдельно от карточек пульта.
|
||
operator_kio: KIO | None = None
|
||
operator_scenario: Scenario | None = None
|
||
#: Работа диспетчера живёт только здесь — в карточках пульта.
|
||
desk: DdsDesk = Field(default_factory=DdsDesk)
|
||
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
|
||
resolved_outcome: str | None = None
|
||
resolve_comment: str = ""
|
||
#: Recently committed DDS command IDs; included in the durable checkpoint so
|
||
#: a lost WebSocket acknowledgement cannot apply an operation twice.
|
||
processed_station_commands: list[str] = Field(default_factory=list)
|
||
text_revealed_facts: dict[str, str] = Field(default_factory=dict)
|
||
|
||
@field_validator("processed_station_commands")
|
||
@classmethod
|
||
def _recent_commands(cls, value: list[str]) -> list[str]:
|
||
return value[-MAX_STATION_COMMANDS:]
|
||
|
||
@field_serializer("processed_station_commands")
|
||
def _dump_recent_commands(self, value: list[str]) -> list[str]:
|
||
return value[-MAX_STATION_COMMANDS:]
|
||
|
||
@classmethod
|
||
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
|
||
super().__pydantic_init_subclass__(**kwargs)
|
||
undecided = [name for name, info in cls.model_fields.items()
|
||
if name not in PersistedSession.model_fields and not info.exclude]
|
||
if undecided:
|
||
raise TypeError(
|
||
f"{cls.__name__}: поля {undecided} — сохраняемое поле объявляется в "
|
||
"PersistedSession, живое — с Field(exclude=True)"
|
||
)
|
||
|
||
|
||
class SessionState(PersistedSession):
|
||
"""Живое занятие: сохраняемая часть плюс runtime-объекты процесса.
|
||
|
||
Живые поля в снимок не попадают и пересобираются при загрузке
|
||
(`checkpoint.rebuild_live`). Прогресс звонящего (`slots`, `persona`) пока
|
||
тоже живой: после failover звонящий начинает с чистого листа — голос P2,
|
||
а потеря редкая. Чтобы сохранять его, достаточно добавить в
|
||
`PersistedSession` поле `CallerProgress` и передать его в `rebuild_live`.
|
||
"""
|
||
|
||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||
|
||
#: Runtime-only: set when this process loses or cannot confirm DB ownership.
|
||
lease_fenced: bool = Field(default=False, exclude=True)
|
||
# Звонящий. Автомата нет, если не скачана модель эмбеддингов:
|
||
# занятие идёт, подсказки откатываются на порядок чек-листа.
|
||
slots: SlotMachine | None = Field(default=None, exclude=True)
|
||
persona: PersonaState | None = Field(default=None, exclude=True)
|
||
caller: TemplateCaller | None = Field(default=None, exclude=True)
|
||
# Голосовой контур звонка. Нет — если голос выключен или моделей нет:
|
||
# тогда кадры микрофона только считаются.
|
||
voice: object | None = Field(default=None, exclude=True)
|
||
recorder: object | None = Field(default=None, exclude=True)
|
||
recording_path: str | None = Field(default=None, exclude=True)
|
||
|
||
def persisted(self) -> PersistedSession:
|
||
"""Сохраняемая часть без копирования — то, что уходит в снимок."""
|
||
return PersistedSession.model_construct(
|
||
**{name: getattr(self, name) for name in PersistedSession.model_fields}
|
||
)
|
||
|
||
def on_event(self, event_type: str) -> None:
|
||
"""Единственная точка, где событие двигает таймеры."""
|
||
self.timers.on_event(event_type)
|
||
|
||
def pause(self) -> None:
|
||
"""Заморозить таймеры занятия и карточек пульта; очередь не выдаёт следующую."""
|
||
if self.paused:
|
||
return
|
||
now = time.monotonic()
|
||
self.timers.pause(now)
|
||
for card in self.desk.cards.values():
|
||
card.timers.pause(now)
|
||
self.paused = True
|
||
self.paused_at = now_utc()
|
||
|
||
def resume(self) -> None:
|
||
"""Снять паузу: таймеры продолжают с прежней отметки, очередь сдвигается на простой."""
|
||
if not self.paused:
|
||
return
|
||
now = time.monotonic()
|
||
self.timers.resume(now)
|
||
for card in self.desk.cards.values():
|
||
card.timers.resume(now)
|
||
if self.paused_at is not None:
|
||
idle = now_utc() - self.paused_at
|
||
self.total_paused_ms += max(0, int(idle.total_seconds() * 1000))
|
||
if self.desk.next_arrival_at is not None:
|
||
self.desk.next_arrival_at += idle
|
||
self.paused = False
|
||
self.paused_at = None
|
||
|
||
def append(self, speaker: Speaker, text: str, mood: Mood | None = None) -> TranscriptEntry:
|
||
entry = TranscriptEntry(
|
||
ref=f"u{len(self.transcript) + 1}",
|
||
speaker=speaker,
|
||
text=text,
|
||
at=now_utc(),
|
||
mood=mood,
|
||
)
|
||
self.transcript.append(entry)
|
||
return entry
|
||
|
||
def patch_kio(self, fields: dict[str, Any]) -> KIO:
|
||
self.kio = apply_patch(self.kio, fields)
|
||
return self.kio
|
||
|
||
def dispatch(self, service: str | None = None) -> KIO:
|
||
"""Карточка замораживается снимком: оператор не должен иметь
|
||
возможности дописать задним числом поле, которое забыл."""
|
||
if service is not None:
|
||
self.kio = apply_patch(self.kio, {"dds": service})
|
||
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]:
|
||
"""Список оповещения переданной карточки. До передачи — пусто:
|
||
отмечаться диспетчеру пока не по чему."""
|
||
card = self.dispatched_card or self.kio
|
||
return list(card.notify)
|
||
|
||
def _managed_services_for(self, recipients: list[str]) -> list[str]:
|
||
"""Служба, от имени которой работает этот обучающийся.
|
||
|
||
Старые занятия и demo без профиля безопасно закрепляются за первым
|
||
адресатом. Обобщённая учебная служба «ДДС района» соответствует
|
||
территориальному органу, а не всем получателям карточки сразу.
|
||
"""
|
||
if not recipients:
|
||
return []
|
||
wanted = (self.dds_service or "").strip().casefold()
|
||
if wanted:
|
||
exact = next((item for item in recipients if item.casefold() == wanted), None)
|
||
if exact:
|
||
return [exact]
|
||
contained = next(
|
||
(item for item in recipients
|
||
if wanted in item.casefold() or item.casefold() in wanted),
|
||
None,
|
||
)
|
||
if contained:
|
||
return [contained]
|
||
if "район" in wanted:
|
||
district = next(
|
||
(item for item in recipients if "территориальн" in item.casefold()), None
|
||
)
|
||
if district:
|
||
return [district]
|
||
return []
|
||
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 card_services(self, card: DdsLiveCard) -> list[str]:
|
||
"""Службы карточки пульта, от имени которых работает обучающийся."""
|
||
return self._managed_services_for(list(card.dispatched_card.notify))
|
||
|
||
@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, card: DdsLiveCard) -> list[str]:
|
||
return self._crew_options_for(self.card_services(card))
|
||
|
||
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 station_snapshot(self) -> StationSnapshot:
|
||
"""Что видит диспетчер: службы, их статусы и доступные переходы."""
|
||
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)
|
||
log = list(card.status_log) if card is not None else []
|
||
statuses = {service: current(log, service) for service in services}
|
||
now_mono = time.monotonic()
|
||
overdue = False
|
||
if card is not None:
|
||
# Через таймер, а не через `dispatched_at`: часы разошлись бы с
|
||
# паузой, которая держит `current_ms` на месте (lct-39).
|
||
ack_timer = card.timers.timers.get(TimerCode.DDS_ACK)
|
||
limit = card.timers.limits[TimerCode.DDS_ACK]
|
||
elapsed_ms = ack_timer.current_ms(now_mono) if ack_timer else 0
|
||
overdue = not (ack_timer.stopped if ack_timer else False) and elapsed_ms > limit
|
||
queue_cards: list[DdsQueueCard] = []
|
||
for item in (desk.ordered() if self.dds_phase else []):
|
||
recipients_for_card = list(item.dispatched_card.notify)
|
||
managed = self._managed_services_for(recipients_for_card)
|
||
service = managed[0] if managed else None
|
||
timer = item.timers.timers.get(TimerCode.DDS_ACK)
|
||
elapsed_ms = timer.current_ms(now_mono) if timer else 0
|
||
stopped = timer.stopped if timer else False
|
||
limit_ms = item.timers.limits[TimerCode.DDS_ACK]
|
||
queue_cards.append(DdsQueueCard(
|
||
card_id=item.card_id,
|
||
scenario_id=item.scenario.id,
|
||
title=item.scenario.title,
|
||
address=item.dispatched_card.address,
|
||
description=item.dispatched_card.description,
|
||
incident_type=(item.dispatched_card.incident_type.value
|
||
if item.dispatched_card.incident_type else None),
|
||
victims_count=item.dispatched_card.victims_count,
|
||
received_at=item.dispatched_at,
|
||
managed_service=service,
|
||
service_status=(current(item.status_log, service)
|
||
if service else ServiceStatus.ADDED),
|
||
card_status=card_status(
|
||
item.status_log, managed,
|
||
notify_deadline_passed=not stopped and elapsed_ms > limit_ms,
|
||
),
|
||
elapsed_ms=elapsed_ms,
|
||
limit_ms=limit_ms,
|
||
timer_stopped=stopped,
|
||
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(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(card.dds_log)
|
||
if action == "zone.decision"), None)
|
||
if card is not None else None
|
||
),
|
||
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 desk.completed],
|
||
queue_cards=queue_cards,
|
||
pending_cards_count=max(
|
||
0, len(desk.scenarios) - len(desk.completed)
|
||
- (1 if card is not None else 0),
|
||
),
|
||
next_arrival_in_seconds=(
|
||
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=desk.max_waiting,
|
||
paused=self.paused,
|
||
)
|
||
|
||
def card_received_event(self):
|
||
"""Снимок карточки для станции ДДС."""
|
||
from app.domain.events import CardReceived
|
||
|
||
card = self.desk.active
|
||
return CardReceived(
|
||
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.desk.card_index > 0)
|
||
else self.trainee_name or "оператор 112"),
|
||
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
|
||
def ended(self) -> bool:
|
||
return self.ended_at is not None
|
||
|
||
def end(self, reason: CallEndReason) -> datetime:
|
||
"""Закрыть занятие; возвращает момент окончания для журнала.
|
||
|
||
Занятие могут завершить прямо на паузе (преподаватель нажал «Завершить»,
|
||
не возобновив): снять флаг и досчитать простой сейчас, иначе курсант
|
||
навсегда останется за баннером «Пауза, ждите преподавателя», а разбор
|
||
потеряет последний, ещё не закрытый интервал паузы (lct-39).
|
||
"""
|
||
self.resume()
|
||
self.ended_at = now_utc()
|
||
self.end_reason = reason
|
||
return self.ended_at
|
||
|
||
def snapshot(self) -> SessionSnapshot:
|
||
"""Полное состояние. Монитор в классе включают посреди занятия —
|
||
он обязан показать текущее, а не ждать следующего события."""
|
||
card = self.shown_card()
|
||
return SessionSnapshot(
|
||
session_id=self.session_id,
|
||
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=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.shown_timers().snapshot(),
|
||
hints_used=len(self.hints_shown),
|
||
ended=self.ended,
|
||
paused=self.paused,
|
||
)
|