lct-hack/backend/app/session/state.py
gglamer 5a3761a830 refactor: пульт ДДС владеет живыми карточками, сессия не копирует их поля
Связка 112→ДДС больше не подменяет таймеры оператора таймерами первой
карточки ДДС, поэтому время заполнения карточки 112 снова попадает в оценку.
2026-09-26 21:38:09 +00:00

391 lines
19 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.

"""Состояние живой сессии: карточка, транскрипт, таймеры, режим, попытка.
Живёт в памяти процесса — поэтому воркер uvicorn ровно один: с двумя
преподаватель подключился бы к другому процессу, чем курсант, и увидел
пустой экран (docs/arch/STACK.md).
"""
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from uuid import UUID
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", "SessionState", "now_utc"]
@dataclass
class SessionState:
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
#: Runtime-only: set when this process loses or cannot confirm DB ownership.
lease_fenced: bool = False
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
slots: SlotMachine | None = None
persona: PersonaState | None = None
caller: TemplateCaller | None = None
# Голосовой контур звонка. Нет — если голос выключен или моделей нет:
# тогда кадры микрофона только считаются.
voice: object | None = None
recorder: object | None = None
recording_path: str | 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
#: Карточка оператора 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)
def on_event(self, event_type: str) -> None:
"""Единственная точка, где событие двигает таймеры."""
self.timers.on_event(event_type)
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}
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 (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,
)
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 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,
)