refactor: снимок занятия — model_dump сохраняемой части сессии вместо ручного перечисления полей

This commit is contained in:
gglamer 2026-09-26 22:13:35 +00:00
commit 72c6a7cfa3
7 changed files with 546 additions and 471 deletions

View file

@ -1,374 +1,70 @@
"""Переносимый JSON-снимок незавершённого занятия. """Переносимый JSON-снимок незавершённого занятия.
Снимок хранится в PostgreSQL после каждого подтверждённого действия. Он не Снимок хранится в PostgreSQL после каждого подтверждённого действия. Это
содержит сокеты, аудиобуферы или объекты моделей: после перезапуска процесса `model_dump` сохраняемой части сессии (`PersistedSession`): сокеты, аудиобуферы
они создаются заново, а учебные данные, таймеры и состояние ДДС восстанавливаются. и объекты моделей в неё не входят и после перезапуска процесса собираются
заново (`rebuild_live`).
Снимки прежних версий не читаются: снимок живёт только у незавершённого
занятия, а плохой снимок реестр и восстановление узла логируют и пропускают.
""" """
import time from datetime import datetime
from datetime import UTC, datetime
from uuid import UUID
from fastapi.encoders import jsonable_encoder from pydantic import BaseModel, Field
from app.dialog.factory import build_caller from app.dialog.factory import build_caller
from app.dialog.persona import PersonaState from app.dialog.persona import PersonaProgress, PersonaState
from app.dialog.runtime import get_embedder from app.dialog.runtime import get_embedder
from app.dialog.slots import SlotMachine from app.dialog.slots import SlotMachine, SlotProgress
from app.domain.events import ( from app.domain.events import Exercise
CallEndReason, from app.session.state import SessionState
Exercise, from app.session.timers import downtime_ms
LessonCriteria,
Metric,
SessionMode,
TranscriptEntry,
)
from app.domain.kio import KIO
from app.domain.statuses import (
PhoneCallPending,
PhoneLineRecord,
PhoneReportRecord,
StatusEntry,
)
from app.domain.taxonomy import Finding
from app.domain.timers import TimerCode
from app.scenarios.schema import Scenario
from app.session.state import DdsCardRecord, DdsLiveCard, SessionState, now_utc
from app.session.timers import SessionTimers, Timer
CHECKPOINT_VERSION = 1 CHECKPOINT_VERSION = 2
def _dump_timers(timers: SessionTimers, now: float) -> dict: class CallerProgress(BaseModel):
return { """Прогресс голосового звонящего. В снимок пока не входит — см. `SessionState`."""
"limits": {code.value: limit for code, limit in timers.limits.items()},
"items": {
code.value: {
"elapsed_ms": timer.current_ms(now),
"attempt": timer.attempt,
"stopped": timer.stopped,
}
for code, timer in timers.timers.items()
},
}
slots: SlotProgress = Field(default_factory=SlotProgress)
def _dump_live_card(item: DdsLiveCard, now: float) -> dict: persona: PersonaProgress = Field(default_factory=PersonaProgress)
return {
"original_index": item.original_index,
"scenario": item.scenario.model_dump(mode="json"),
"kio": item.kio.model_dump(mode="json"),
"dispatched_card": item.dispatched_card.model_dump(mode="json"),
"dispatched_at": item.dispatched_at.isoformat(),
"timers": _dump_timers(item.timers, now),
"bounced_fields": item.bounced_fields,
"dds_log": [[action, at.isoformat(), detail] for action, at, detail in item.dds_log],
"status_log": [entry.model_dump(mode="json") for entry in item.status_log],
"crew_selected": item.crew_selected,
"crew_assignments": item.crew_assignments,
"phone_reports": [entry.model_dump(mode="json") for entry in item.phone_reports],
"phone_lines": [entry.model_dump(mode="json") for entry in item.phone_lines],
"phone_pending": item.phone_pending.model_dump(mode="json") if item.phone_pending else None,
"reply_text": item.reply_text,
"reply_log": [[at.isoformat(), text] for at, text in item.reply_log],
}
def dump_state(state: SessionState) -> dict: def dump_state(state: SessionState) -> dict:
"""Сериализовать только данные, необходимые для точного продолжения. """Сериализовать сохраняемую часть. Чистое чтение."""
return {"version": CHECKPOINT_VERSION, **state.model_dump(mode="json")}
Чистое чтение. Верхние ключи работы диспетчера повторяют активную
карточку пульта — так их писал формат до `DdsDesk`. def rebuild_live(state: SessionState, caller: CallerProgress | None = None) -> None:
"""Собрать runtime-объекты звонящего; без прогресса — с чистого листа.
Карточка и оценка при этом остаются прежними.
""" """
desk = state.desk if state.exercise is not Exercise.CALL or state.scenario is None:
active = desk.active return
now = time.monotonic() progress = caller or CallerProgress()
payload = { state.persona = PersonaState(state.scenario.persona, progress=progress.persona)
"version": CHECKPOINT_VERSION, state.caller = build_caller(
"session_id": str(state.session_id), state.scenario.id,
"scenario_id": state.scenario_id, use_pregenerated=state.scenario.tree.pregenerated,
"scenario_title": state.scenario_title,
"level": state.level,
"mode": state.mode.value,
"owner_login": state.owner_login,
"backend_fencing_epoch": state.backend_fencing_epoch,
"exercise": state.exercise.value,
"handoff_to_dds": state.handoff_to_dds,
"required_fields": state.required_fields,
"trainee_name": state.trainee_name,
"trainee_id": str(state.trainee_id) if state.trainee_id else None,
"dds_service": state.dds_service,
"attempt": state.attempt,
"criteria": state.criteria.model_dump(mode="json"),
"kio": state.kio.model_dump(mode="json"),
"transcript": [entry.model_dump(mode="json") for entry in state.transcript],
"timers": _dump_timers(state.timers, now),
"hints_shown": state.hints_shown,
"hints_log": [[item, at.isoformat()] for item, at in state.hints_log],
"notes": state.notes,
"directives": state.directives,
"scenario": state.scenario.model_dump(mode="json") if state.scenario else None,
"audio_frames": state.audio_frames,
"bad_frames": state.bad_frames,
"self_assessed": state.self_assessed,
"self_assessment": state.self_assessment,
"score": state.score,
"started_at": state.started_at.isoformat() if state.started_at else None,
"ended_at": state.ended_at.isoformat() if state.ended_at else None,
"end_reason": state.end_reason.value if state.end_reason else None,
"dispatched_card": (
state.dispatched_card.model_dump(mode="json") if state.dispatched_card else None
),
"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 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": (
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 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 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": (
desk.next_arrival_at.isoformat() if desk.next_arrival_at else None
),
"dds_completed": [
{
"card_id": str(item.card_id),
"scenario_id": item.scenario_id,
"reply_text": item.reply_text,
"metrics": [metric.model_dump(mode="json") for metric in item.metrics],
"findings": [finding.model_dump(mode="json") for finding in item.findings],
"actions": item.actions,
"duration_ms": item.duration_ms,
"title": item.title,
"address": item.address,
"description": item.description,
"incident_type": item.incident_type,
"victims_count": item.victims_count,
"received_at": item.received_at.isoformat() if item.received_at else None,
"managed_service": item.managed_service,
"recipient_services": item.recipient_services,
}
for item in desk.completed
],
"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,
"processed_station_commands": state.processed_station_commands[-512:],
}
# В actions/score могут быть datetime/UUID из расчёта; JSONB должен
# получать только стандартные JSON-типы.
return jsonable_encoder(payload)
def _dt(value: str | None) -> datetime | None:
return datetime.fromisoformat(value) if value else None
def _restore_timers(payload: dict, saved_at: datetime) -> SessionTimers:
raw = payload.get("timers") or {}
limits = {
TimerCode(code): int(limit)
for code, limit in (raw.get("limits") or {}).items()
}
restored = SessionTimers(limits=limits or SessionTimers().limits)
now_mono = time.monotonic()
now_wall = datetime.now(UTC)
if saved_at.tzinfo is None:
saved_at = saved_at.replace(tzinfo=UTC)
downtime_ms = max(0, int((now_wall - saved_at).total_seconds() * 1000))
for raw_code, item in (raw.get("items") or {}).items():
code = TimerCode(raw_code)
stopped = bool(item.get("stopped"))
elapsed = max(0, int(item.get("elapsed_ms", 0)))
total = elapsed if stopped else elapsed + downtime_ms
restored.timers[code] = Timer(
code=code,
started_at=None if stopped else now_mono - total / 1000,
elapsed_ms=elapsed if stopped else 0,
attempt=max(1, int(item.get("attempt", 1))),
stopped=stopped,
)
return restored
def _restore_live_card(item: dict, saved_at: datetime) -> DdsLiveCard:
return DdsLiveCard(
original_index=int(item["original_index"]),
scenario=Scenario.model_validate(item["scenario"]),
kio=KIO.model_validate(item["kio"]),
dispatched_card=KIO.model_validate(item["dispatched_card"]),
dispatched_at=datetime.fromisoformat(item["dispatched_at"]),
timers=_restore_timers({"timers": item["timers"]}, saved_at),
bounced_fields=list(item.get("bounced_fields") or []),
dds_log=[(action, datetime.fromisoformat(at), detail)
for action, at, detail in item.get("dds_log", [])],
status_log=[StatusEntry.model_validate(entry)
for entry in item.get("status_log", [])],
crew_selected=item.get("crew_selected"),
crew_assignments=dict(item.get("crew_assignments") or {}),
phone_reports=[PhoneReportRecord.model_validate(entry)
for entry in item.get("phone_reports", [])],
phone_lines=[PhoneLineRecord.model_validate(entry)
for entry in item.get("phone_lines", [])],
phone_pending=(PhoneCallPending.model_validate(item["phone_pending"])
if item.get("phone_pending") else None),
reply_text=item.get("reply_text", ""),
reply_log=[(datetime.fromisoformat(at), text)
for at, text in item.get("reply_log", [])],
) )
embedder = get_embedder()
if embedder is not None:
def _restore_completed(item: dict) -> DdsCardRecord: state.slots = SlotMachine(state.scenario, embedder, progress=progress.slots)
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: def load_state(payload: dict, saved_at: datetime) -> SessionState:
"""Восстановить состояние; неизвестная версия отклоняется явно.""" """Восстановить состояние; неизвестная версия отклоняется явно."""
if payload.get("version") != CHECKPOINT_VERSION: if payload.get("version") != CHECKPOINT_VERSION:
raise ValueError("неподдерживаемая версия снимка занятия") raise ValueError("неподдерживаемая версия снимка занятия")
scenario = Scenario.model_validate(payload["scenario"]) if payload.get("scenario") else None state = SessionState.model_validate(
state = SessionState( payload, context={"downtime_ms": downtime_ms(saved_at)}
session_id=UUID(payload["session_id"]),
scenario_id=payload["scenario_id"],
scenario_title=payload["scenario_title"],
level=payload["level"],
mode=SessionMode(payload["mode"]),
owner_login=payload.get("owner_login"),
backend_fencing_epoch=int(payload.get("backend_fencing_epoch", 0)),
exercise=Exercise(payload["exercise"]),
handoff_to_dds=bool(payload.get("handoff_to_dds")),
required_fields=list(payload.get("required_fields") or []),
trainee_name=payload.get("trainee_name"),
trainee_id=UUID(payload["trainee_id"]) if payload.get("trainee_id") else None,
dds_service=payload.get("dds_service"),
attempt=int(payload.get("attempt", 1)),
criteria=LessonCriteria.model_validate(payload.get("criteria") or {}),
kio=KIO.model_validate(payload.get("kio") or {}),
transcript=[TranscriptEntry.model_validate(item) for item in payload.get("transcript", [])],
timers=_restore_timers(payload, saved_at),
hints_shown=list(payload.get("hints_shown") or []),
hints_log=[(item, datetime.fromisoformat(at))
for item, at in payload.get("hints_log", [])],
notes=list(payload.get("notes") or []),
directives=list(payload.get("directives") or []),
scenario=scenario,
audio_frames=int(payload.get("audio_frames", 0)),
bad_frames=int(payload.get("bad_frames", 0)),
self_assessed=bool(payload.get("self_assessed")),
self_assessment=payload.get("self_assessment"),
score=payload.get("score"),
started_at=_dt(payload.get("started_at")),
ended_at=_dt(payload.get("ended_at")),
end_reason=(CallEndReason(payload["end_reason"]) if payload.get("end_reason") else None),
dispatched_card=(
KIO.model_validate(payload["dispatched_card"])
if payload.get("dispatched_card") else None
),
dispatched_at=_dt(payload.get("dispatched_at")),
bounced_fields=list(payload.get("bounced_fields") or []),
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),
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:],
) )
_restore_desk(state, payload, saved_at) if state.exercise is Exercise.CALL:
# Голосовые runtime-объекты не сериализуются. Их безопасно собрать заново; # Таймеры звонка и диспетчера — одна цепочка 112 → ДДС (см. dispatch):
# карточка и оценка при этом остаются прежними. # в снимке это копии, в живой сессии — один объект.
if state.exercise is Exercise.CALL and state.scenario is not None: for card in state.desk.cards.values():
state.persona = PersonaState(state.scenario.persona) card.timers = state.timers
state.caller = build_caller( rebuild_live(state)
state.scenario.id,
use_pregenerated=state.scenario.tree.pregenerated,
)
embedder = get_embedder()
if embedder is not None:
state.slots = SlotMachine(state.scenario, embedder)
return state return state

View file

@ -12,6 +12,8 @@ from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pydantic import BaseModel, Field
from app.domain import ekp from app.domain import ekp
from app.domain.events import Exercise, Metric, PhoneLine, PhoneReport, StationState from app.domain.events import Exercise, Metric, PhoneLine, PhoneReport, StationState
from app.domain.kio import KIO, ResponseStatus, apply_patch from app.domain.kio import KIO, ResponseStatus, apply_patch
@ -37,8 +39,7 @@ if TYPE_CHECKING:
from app.session.state import SessionState from app.session.state import SessionState
@dataclass class DdsCardRecord(BaseModel):
class DdsCardRecord:
card_id: UUID card_id: UUID
scenario_id: str scenario_id: str
reply_text: str reply_text: str
@ -53,7 +54,7 @@ class DdsCardRecord:
victims_count: int | None = None victims_count: int | None = None
received_at: datetime | None = None received_at: datetime | None = None
managed_service: str | None = None managed_service: str | None = None
recipient_services: list[str] = field(default_factory=list) recipient_services: list[str] = Field(default_factory=list)
@property @property
def score_auto(self) -> float: def score_auto(self) -> float:
@ -63,8 +64,7 @@ class DdsCardRecord:
return round(100 * passed / total, 1) if total else 0.0 return round(100 * passed / total, 1) if total else 0.0
@dataclass class DdsLiveCard(BaseModel):
class DdsLiveCard:
"""Изолированное живое состояние одной одновременно выданной карточки.""" """Изолированное живое состояние одной одновременно выданной карточки."""
original_index: int original_index: int
@ -73,16 +73,16 @@ class DdsLiveCard:
dispatched_card: KIO dispatched_card: KIO
dispatched_at: datetime dispatched_at: datetime
timers: SessionTimers timers: SessionTimers
bounced_fields: list[str] = field(default_factory=list) bounced_fields: list[str] = Field(default_factory=list)
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list) dds_log: list[tuple[str, datetime, str | None]] = Field(default_factory=list)
status_log: list[StatusEntry] = field(default_factory=list) status_log: list[StatusEntry] = Field(default_factory=list)
crew_selected: str | None = None crew_selected: str | None = None
crew_assignments: dict[str, str] = field(default_factory=dict) crew_assignments: dict[str, str] = Field(default_factory=dict)
phone_reports: list[PhoneReportRecord] = field(default_factory=list) phone_reports: list[PhoneReportRecord] = Field(default_factory=list)
phone_lines: list[PhoneLineRecord] = field(default_factory=list) phone_lines: list[PhoneLineRecord] = Field(default_factory=list)
phone_pending: PhoneCallPending | None = None phone_pending: PhoneCallPending | None = None
reply_text: str = "" reply_text: str = ""
reply_log: list[tuple[datetime, str]] = field(default_factory=list) reply_log: list[tuple[datetime, str]] = Field(default_factory=list)
@property @property
def card_id(self) -> UUID: def card_id(self) -> UUID:
@ -297,18 +297,17 @@ def _finish_phone_call(card: DdsLiveCard) -> list[Any]:
return [line, PhoneReport(**report.model_dump())] return [line, PhoneReport(**report.model_dump())]
@dataclass class DdsDesk(BaseModel):
class DdsDesk:
"""Живые карточки занятия, активная из них и очередь поступления.""" """Живые карточки занятия, активная из них и очередь поступления."""
scenarios: list[Scenario] = field(default_factory=list) scenarios: list[Scenario] = Field(default_factory=list)
cards: dict[UUID, DdsLiveCard] = field(default_factory=dict) cards: dict[UUID, DdsLiveCard] = Field(default_factory=dict)
active_id: UUID | None = None active_id: UUID | None = None
#: Номер карточки на экране. После завершения последней из поступивших #: Номер карточки на экране. После завершения последней из поступивших
#: остаётся прежним, пока не придёт следующая. #: остаётся прежним, пока не придёт следующая.
card_index: int = 0 card_index: int = 0
completed: list[DdsCardRecord] = field(default_factory=list) completed: list[DdsCardRecord] = Field(default_factory=list)
limits: dict[TimerCode, int] = field(default_factory=lambda: dict(SessionTimers().limits)) limits: dict[TimerCode, int] = Field(default_factory=lambda: dict(SessionTimers().limits))
arrival_interval_seconds: int = 0 arrival_interval_seconds: int = 0
max_waiting: int = 3 max_waiting: int = 3
next_index: int = 0 next_index: int = 0

View file

@ -6,11 +6,12 @@
""" """
import time import time
from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
from app.dialog.caller import TemplateCaller from app.dialog.caller import TemplateCaller
from app.dialog.persona import PersonaState from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine from app.dialog.slots import SlotMachine
@ -40,11 +41,21 @@ from app.scenarios.schema import Scenario
from app.session.dds import DdsCardRecord, DdsDesk, DdsLiveCard from app.session.dds import DdsCardRecord, DdsDesk, DdsLiveCard
from app.session.timers import SessionTimers, now_utc from app.session.timers import SessionTimers, now_utc
__all__ = ["DdsCardRecord", "DdsLiveCard", "SessionState", "now_utc"] __all__ = ["DdsCardRecord", "DdsLiveCard", "PersistedSession", "SessionState", "now_utc"]
#: Сколько последних команд станции помнит защита от повтора.
MAX_STATION_COMMANDS = 512
@dataclass class PersistedSession(BaseModel):
class SessionState: """Сохраняемая часть занятия — ровно то, что пишется в снимок.
Снимок — `model_dump` этой модели, загрузка — `model_validate`, поэтому
поле сессии нельзя завести, не решив, сохраняемое оно или живое: подкласс
обязан пометить каждое своё поле `Field(exclude=True)`, иначе класс не
создастся (см. `__pydantic_init_subclass__`).
"""
session_id: UUID session_id: UUID
scenario_id: str scenario_id: str
scenario_title: str scenario_title: str
@ -54,12 +65,10 @@ class SessionState:
owner_login: str | None = None owner_login: str | None = None
#: Monotonic DB ownership generation; stale processes may not persist writes. #: Monotonic DB ownership generation; stale processes may not persist writes.
backend_fencing_epoch: int = 0 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 exercise: Exercise = Exercise.CALL
#: После заполнения КИО занятие продолжится на АРМ ДДС, а не завершится. #: После заполнения КИО занятие продолжится на АРМ ДДС, а не завершится.
handoff_to_dds: bool = False handoff_to_dds: bool = False
required_fields: list[str] = field(default_factory=list) required_fields: list[str] = Field(default_factory=list)
trainee_name: str | None = None trainee_name: str | None = None
#: Чьё это занятие. Проставляется при запуске, когда курсант известен #: Чьё это занятие. Проставляется при запуске, когда курсант известен
#: по учётной записи: по нему разбор закрывается от чужих (lct-23). #: по учётной записи: по нему разбор закрывается от чужих (lct-23).
@ -68,30 +77,20 @@ class SessionState:
#: остальные адресаты карточки показываются информационно. #: остальные адресаты карточки показываются информационно.
dds_service: str | None = None dds_service: str | None = None
attempt: int = 1 attempt: int = 1
criteria: LessonCriteria = field(default_factory=LessonCriteria) criteria: LessonCriteria = Field(default_factory=LessonCriteria)
kio: KIO = field(default_factory=KIO) kio: KIO = Field(default_factory=KIO)
transcript: list[TranscriptEntry] = field(default_factory=list) transcript: list[TranscriptEntry] = Field(default_factory=list)
timers: SessionTimers = field(default_factory=SessionTimers) timers: SessionTimers = Field(default_factory=SessionTimers)
hints_shown: list[str] = field(default_factory=list) hints_shown: list[str] = Field(default_factory=list)
#: Когда именно подсказывали — в разборе видно, какой пункт и на какой минуте. #: Когда именно подсказывали — в разборе видно, какой пункт и на какой минуте.
hints_log: list[tuple[str, datetime]] = field(default_factory=list) hints_log: list[tuple[str, datetime]] = Field(default_factory=list)
notes: list[dict] = field(default_factory=list) notes: list[dict] = Field(default_factory=list)
directives: list[str] = field(default_factory=list) directives: list[str] = Field(default_factory=list)
# Звонящий. Автомата нет, если не скачана модель эмбеддингов:
# занятие идёт, подсказки откатываются на порядок чек-листа.
# Своя копия сценария на занятие: директивы преподавателя правят факты # Своя копия сценария на занятие: директивы преподавателя правят факты
# и эталон, и правка в одной группе не должна протекать в остальные. # и эталон, и правка в одной группе не должна протекать в остальные.
scenario: Scenario | None = None 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) кадры только считаются — # Аудио курсанта. До голосового контура (lct-06) кадры только считаются —
# этого достаточно, чтобы доказать, что звук доходит от микрофона до сервера. # этого достаточно, чтобы доказать, что звук доходит от микрофона до сервера.
@ -110,21 +109,73 @@ class SessionState:
dispatched_card: KIO | None = None dispatched_card: KIO | None = None
dispatched_at: datetime | None = None dispatched_at: datetime | None = None
#: Поля, из-за которых диспетчер вернул карточку, — основание E6. #: Поля, из-за которых диспетчер вернул карточку, — основание E6.
bounced_fields: list[str] = field(default_factory=list) bounced_fields: list[str] = Field(default_factory=list)
#: Готовые карточки связки 112→ДДС ждут, пока курсант не сдаст свою. #: Готовые карточки связки 112→ДДС ждут, пока курсант не сдаст свою.
pending_dds_scenarios: list[Scenario] = field(default_factory=list) pending_dds_scenarios: list[Scenario] = Field(default_factory=list)
#: Исходная часть упражнения 112→ДДС сохраняется отдельно от карточек пульта. #: Исходная часть упражнения 112→ДДС сохраняется отдельно от карточек пульта.
operator_kio: KIO | None = None operator_kio: KIO | None = None
operator_scenario: Scenario | None = None operator_scenario: Scenario | None = None
#: Работа диспетчера живёт только здесь — в карточках пульта. #: Работа диспетчера живёт только здесь — в карточках пульта.
desk: DdsDesk = field(default_factory=DdsDesk) desk: DdsDesk = Field(default_factory=DdsDesk)
#: Чем курсант закрыл вызов, если не карточкой (lct-36). #: Чем курсант закрыл вызов, если не карточкой (lct-36).
resolved_outcome: str | None = None resolved_outcome: str | None = None
resolve_comment: str = "" resolve_comment: str = ""
#: Recently committed DDS command IDs; included in the durable checkpoint so #: Recently committed DDS command IDs; included in the durable checkpoint so
#: a lost WebSocket acknowledgement cannot apply an operation twice. #: a lost WebSocket acknowledgement cannot apply an operation twice.
processed_station_commands: list[str] = field(default_factory=list) processed_station_commands: list[str] = Field(default_factory=list)
text_revealed_facts: dict[str, str] = field(default_factory=dict) 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: def on_event(self, event_type: str) -> None:
"""Единственная точка, где событие двигает таймеры.""" """Единственная точка, где событие двигает таймеры."""

View file

@ -9,8 +9,17 @@
""" """
import time import time
from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any
from pydantic import (
BaseModel,
Field,
SerializerFunctionWrapHandler,
ValidationInfo,
model_serializer,
model_validator,
)
from app.domain.timers import NORMATIVES, TimerCode, TimerSnapshot, state_for from app.domain.timers import NORMATIVES, TimerCode, TimerSnapshot, state_for
@ -57,14 +66,46 @@ STOPS: dict[str, tuple[TimerCode, ...]] = {
} }
@dataclass def downtime_ms(saved_at: datetime) -> int:
class Timer: """Сколько занятие пролежало в снимке: запущенный таймер считает и это время."""
if saved_at.tzinfo is None:
saved_at = saved_at.replace(tzinfo=UTC)
return max(0, int((now_utc() - saved_at).total_seconds() * 1000))
class Timer(BaseModel):
"""`started_at` — monotonic-отметка процесса, в другом процессе она ничего
не значит. Снимок хранит прошедшее время, а загрузка пересчитывает отметку
от своих часов (простой берётся из контекста `downtime_ms`)."""
code: TimerCode code: TimerCode
started_at: float | None = None started_at: float | None = None
elapsed_ms: int = 0 elapsed_ms: int = 0
attempt: int = 1 attempt: int = 1
stopped: bool = False stopped: bool = False
@model_serializer(mode="wrap")
def _dump(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
data = handler(self)
data.pop("started_at")
data["elapsed_ms"] = self.current_ms(time.monotonic())
data["started"] = self.started_at is not None
return data
@model_validator(mode="before")
@classmethod
def _restore(cls, data: Any, info: ValidationInfo) -> Any:
if not isinstance(data, dict) or "started" not in data:
return data
data = dict(data)
elapsed = max(0, int(data.get("elapsed_ms", 0)))
stopped = bool(data.get("stopped"))
downtime = 0 if stopped else (info.context or {}).get("downtime_ms", 0)
started = data.pop("started")
data["started_at"] = time.monotonic() - (elapsed + downtime) / 1000 if started else None
data["elapsed_ms"] = elapsed if stopped else 0
return data
def start(self, now: float) -> None: def start(self, now: float) -> None:
if self.stopped: if self.stopped:
# Повторный запуск после остановки — это новая попытка (обратный дозвон). # Повторный запуск после остановки — это новая попытка (обратный дозвон).
@ -85,22 +126,21 @@ class Timer:
return int((now - self.started_at) * 1000) return int((now - self.started_at) * 1000)
@dataclass class SessionTimers(BaseModel):
class SessionTimers:
"""Набор таймеров одной сессии. `limits` приходит из конфига — """Набор таймеров одной сессии. `limits` приходит из конфига —
норматив меняется значением, а не правкой кода.""" норматив меняется значением, а не правкой кода."""
limits: dict[TimerCode, int] = field( limits: dict[TimerCode, int] = Field(
default_factory=lambda: {code: norm.limit_ms for code, norm in NORMATIVES.items()} default_factory=lambda: {code: norm.limit_ms for code, norm in NORMATIVES.items()}
) )
timers: dict[TimerCode, Timer] = field(default_factory=dict) timers: dict[TimerCode, Timer] = Field(default_factory=dict)
def on_event(self, event_type: str, now: float | None = None) -> None: def on_event(self, event_type: str, now: float | None = None) -> None:
now = time.monotonic() if now is None else now now = time.monotonic() if now is None else now
for code in STARTS.get(event_type, ()): for code in STARTS.get(event_type, ()):
self.timers.setdefault(code, Timer(code)).start(now) self.timers.setdefault(code, Timer(code=code)).start(now)
for code in STOPS.get(event_type, ()): for code in STOPS.get(event_type, ()):
self.timers.setdefault(code, Timer(code)).stop(now) self.timers.setdefault(code, Timer(code=code)).stop(now)
def snapshot(self, now: float | None = None) -> list[TimerSnapshot]: def snapshot(self, now: float | None = None) -> list[TimerSnapshot]:
"""Только запущенные таймеры: показывать нули по нормативам, """Только запущенные таймеры: показывать нули по нормативам,

View file

@ -0,0 +1,268 @@
"""Снимок занятия — это модель сохраняемой части сессии, а не список ключей.
Фикстура заполняет каждое поле значением не по умолчанию: новое поле, которое
не пережило round-trip или осталось дефолтным в фикстуре, роняет тест.
"""
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
import pytest
from pydantic import BaseModel
from app.domain.events import (
CallEndReason,
Exercise,
LessonCriteria,
Metric,
SessionMode,
Speaker,
TranscriptEntry,
)
from app.domain.kio import KIO
from app.domain.statuses import (
PhoneCallPending,
PhoneLineRecord,
PhoneReportRecord,
ServiceStatus,
StatusEntry,
)
from app.domain.taxonomy import ErrorCode, Finding, FindingSource
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
from app.session import timers as timers_module
from app.session.checkpoint import CHECKPOINT_VERSION, dump_state, load_state
from app.session.dds import DdsCardRecord, DdsDesk, DdsLiveCard
from app.session.state import PersistedSession, SessionState
from app.session.timers import SessionTimers, Timer
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
MONO = 1_000_000.0
NOW = datetime(2026, 9, 26, 12, 0, tzinfo=UTC)
AT = NOW - timedelta(minutes=5)
@pytest.fixture(autouse=True)
def frozen_clock(monkeypatch):
"""Часы процесса и стены заморожены: round-trip таймера сравнивается точно."""
monkeypatch.setattr(timers_module.time, "monotonic", lambda: MONO)
monkeypatch.setattr(timers_module, "now_utc", lambda: NOW)
def timers() -> SessionTimers:
limits = {**SessionTimers().limits, TimerCode.DDS_ACK: 45_000}
return SessionTimers(limits=limits, timers={
TimerCode.DDS_ACK: Timer(code=TimerCode.DDS_ACK, started_at=MONO - 12.0, attempt=2),
TimerCode.ANSWER: Timer(code=TimerCode.ANSWER, started_at=MONO - 7.0,
elapsed_ms=7_000, attempt=3, stopped=True),
})
def completed_card() -> DdsCardRecord:
return DdsCardRecord(
card_id=uuid4(),
scenario_id="fire-apartment-l2",
reply_text="Бригада направлена.",
metrics=[Metric(key="dds_ack", title="Приём", fact="20 с", norm="30 с", passed=True)],
findings=[Finding(code=ErrorCode.D1, source=FindingSource.DISPATCHER,
summary="Поздний статус", fact="статус через 5 мин")],
actions=[{"action": "crew.select", "detail": "АЦ-1"}],
duration_ms=61_000,
title="Пожар в квартире",
address="улица Ленина, 14, кв. 5",
description="дым из окна",
incident_type="пожар",
victims_count=2,
received_at=AT,
managed_service="01",
recipient_services=["01", "03"],
)
def full_state() -> SessionState:
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
second = load_file(LIBRARY / "tickets" / "t01-1-fire-container.yaml", LIBRARY)
crew = "01 — бригада №1"
card = DdsLiveCard(
original_index=1,
scenario=second,
kio=KIO(address="улица Мира, 1", description="горит контейнер"),
dispatched_card=KIO(address="улица Мира, 1", description="горит"),
dispatched_at=AT,
timers=timers(),
bounced_fields=["address"],
dds_log=[("crew.select", AT, crew), ("crew.dispatched", AT, None)],
status_log=[StatusEntry(service="01", status=ServiceStatus.ACCEPTED, at=AT,
comment="Принято в работу", author="диспетчер")],
crew_selected=crew,
crew_assignments={"01": crew},
phone_reports=[PhoneReportRecord(service="01", crew=crew, phase="dispatched",
text="Выехали", at=AT)],
phone_lines=[PhoneLineRecord(service="01", crew=crew, speaker="crew",
text="Выехали", at=AT)],
phone_pending=PhoneCallPending(service="01", crew=crew, phase="arrived"),
reply_text="Бригада направлена",
reply_log=[(AT, "Бригада направлена")],
)
desk = DdsDesk(
scenarios=[first, second],
cards={card.card_id: card},
active_id=card.card_id,
card_index=1,
completed=[completed_card()],
limits={**SessionTimers().limits, TimerCode.DDS_ACK: 45_000},
arrival_interval_seconds=60,
max_waiting=2,
next_index=2,
next_arrival_at=NOW + timedelta(seconds=60),
)
return SessionState(
session_id=uuid4(),
scenario_id=first.id,
scenario_title=first.title,
level=first.level.value,
mode=SessionMode.EXAM,
owner_login="teacher",
backend_fencing_epoch=7,
exercise=Exercise.CARD,
handoff_to_dds=True,
required_fields=["address"],
trainee_name="Курсант",
trainee_id=uuid4(),
dds_service="01",
attempt=3,
criteria=LessonCriteria(decision_time_limit_seconds=45, allowed_errors=1),
kio=KIO(address="улица Ленина, 14"),
transcript=[TranscriptEntry(ref="u1", speaker=Speaker.CALLER, text="Алло", at=AT)],
timers=timers(),
hints_shown=["q_address"],
hints_log=[("q_address", AT)],
notes=[{"ref": "u1", "text": "уверенно"}],
directives=["panic_rises"],
scenario=first,
audio_frames=10,
bad_frames=1,
self_assessed=True,
self_assessment={"score": 80},
score={"score_auto": 75.0},
started_at=AT,
ended_at=NOW,
end_reason=CallEndReason.COMPLETE,
dispatched_card=KIO(address="улица Ленина, 14", description="горит балкон"),
dispatched_at=AT,
bounced_fields=["floor"],
pending_dds_scenarios=[second],
operator_kio=KIO(address="улица Ленина, 14"),
operator_scenario=first,
desk=desk,
resolved_outcome="справка",
resolve_comment="передано в другой регион",
processed_station_commands=[str(uuid4())],
text_revealed_facts={"f_address": "улица Ленина, 14"},
)
def test_persisted_part_round_trips_whole():
before = full_state()
restored = load_state(dump_state(before), NOW)
assert restored.persisted() == before.persisted()
def _session_models(value, found: dict[type, list]) -> None:
"""Все экземпляры моделей сессии в сохраняемой части, по классам."""
if isinstance(value, PersistedSession):
found.setdefault(PersistedSession, []).append(value)
for name in PersistedSession.model_fields:
_session_models(getattr(value, name), found)
elif isinstance(value, BaseModel):
if type(value).__module__.startswith("app.session"):
found.setdefault(type(value), []).append(value)
for name in type(value).model_fields:
_session_models(getattr(value, name), found)
elif isinstance(value, dict):
for item in value.values():
_session_models(item, found)
elif isinstance(value, list | tuple):
for item in value:
_session_models(item, found)
def test_fixture_sets_every_persisted_field_to_non_default():
found: dict[type, list] = {}
_session_models(full_state(), found)
assert {PersistedSession, DdsDesk, DdsLiveCard, DdsCardRecord,
SessionTimers, Timer} <= set(found)
for model, instances in found.items():
for name, info in model.model_fields.items():
if info.is_required():
continue
default = info.get_default(call_default_factory=True)
assert any(getattr(item, name) != default for item in instances), (
f"{model.__name__}.{name} в фикстуре по умолчанию — round-trip его не проверяет"
)
def test_running_timer_counts_downtime_stopped_keeps_saved_value():
payload = dump_state(full_state())
restored = load_state(payload, NOW - timedelta(seconds=5))
items = restored.timers.timers
assert items[TimerCode.DDS_ACK].current_ms(MONO) == 12_000 + 5_000
assert items[TimerCode.ANSWER].current_ms(MONO) == 7_000
card = restored.desk.active
assert card.timers.timers[TimerCode.DDS_ACK].current_ms(MONO) == 17_000
def test_snapshot_does_not_carry_process_clock():
timer = dump_state(full_state())["timers"]["timers"][TimerCode.DDS_ACK.value]
assert "started_at" not in timer
assert timer["elapsed_ms"] == 12_000
def test_checkpoint_version_1_is_rejected():
payload = dump_state(full_state())
assert payload["version"] == CHECKPOINT_VERSION == 2
payload["version"] = 1
with pytest.raises(ValueError, match="версия"):
load_state(payload, NOW)
def test_dump_state_does_not_change_state():
state = full_state()
active = state.desk.active
first = dump_state(state)
assert dump_state(state) == first
assert state.desk.active is active
def test_snapshot_keeps_only_persisted_keys():
payload = dump_state(full_state())
assert set(payload) == {"version", *PersistedSession.model_fields}
assert not {"slots", "persona", "caller", "voice", "recorder",
"recording_path", "lease_fenced"} & set(payload)
def test_processed_station_commands_are_trimmed_on_load():
state = full_state()
state.processed_station_commands = [str(index) for index in range(600)]
restored = load_state(dump_state(state), NOW)
assert restored.processed_station_commands == [str(index) for index in range(88, 600)]
def test_live_field_must_be_excluded_from_snapshot():
with pytest.raises(TypeError, match="сохраняемое"):
class Undecided(PersistedSession):
forgotten: int = 0

View file

@ -1,13 +1,13 @@
"""Чекпойнт пульта ДДС: формат прежний, чтения ничего не меняют. """Чекпойнт пульта ДДС: занятие продолжается с той же активной карточкой,
чтения ничего не меняют.
`golden/checkpoint_before_desk.json` записан кодом до `DdsDesk`: занятие, Состояния собраны в коде и проходят через снимок: пульт ДДС на три карточки,
поднятое из такого снимка, продолжается с той же активной карточкой. упражнение 112 с живым диспетчером и связка 112→ДДС.
""" """
import asyncio import asyncio
import json
from pathlib import Path from pathlib import Path
from uuid import UUID, uuid4 from uuid import uuid4
import pytest import pytest
from pydantic import TypeAdapter from pydantic import TypeAdapter
@ -18,19 +18,15 @@ from app.domain.kio import KIO
from app.domain.statuses import ServiceStatus from app.domain.statuses import ServiceStatus
from app.domain.timers import TimerCode from app.domain.timers import TimerCode
from app.scenarios.loader import load_file from app.scenarios.loader import load_file
from app.session import checkpoint
from app.session import finish as finish_module from app.session import finish as finish_module
from app.session import timers as timers_module
from app.session.checkpoint import dump_state, load_state from app.session.checkpoint import dump_state, load_state
from app.session.dds import prepare_handoff_queue from app.session.dds import prepare_handoff_queue, prepare_queue
from app.session.hub import hub from app.session.hub import hub
from app.session.state import SessionState from app.session.state import SessionState
from app.session.timers import now_utc from app.session.timers import now_utc
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
BEFORE_DESK = json.loads(
(Path(__file__).parent / "golden" / "checkpoint_before_desk.json").read_text()
)
_events = TypeAdapter(StationToServer) _events = TypeAdapter(StationToServer)
@ -47,9 +43,73 @@ def offline_finish(monkeypatch):
monkeypatch.setattr(hub, "journal", None) monkeypatch.setattr(hub, "journal", None)
def restored(name: str, **changes): def fire():
payload = {**json.loads(json.dumps(BEFORE_DESK[name])), **changes} return load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
return load_state(payload, now_utc())
def accept_with_crew(state: SessionState, card) -> None:
service = state.card_services(card)[0]
card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу")
card.on_event("card.ack")
card.crew_selected = state.crew_options(card)[0]
card.crew_assignments[service] = card.crew_selected
card.dds_log.append(("crew.select", now_utc(), card.crew_selected))
def dds_state() -> SessionState:
first = fire()
scenarios = [first.model_copy(deep=True, update={"id": f"desk-{index}"})
for index in range(3)]
state = SessionState(
session_id=uuid4(), scenario_id=first.id, scenario_title=first.title,
level=first.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
)
prepare_queue(state, scenarios)
first_card, second = state.desk.ordered()[:2]
accept_with_crew(state, first_card)
state.desk.open(second.card_id)
second.reply_text = "Ответ по второй карточке"
return state
def call_state() -> SessionState:
scenario = fire()
state = SessionState(
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.CALL,
scenario=scenario.model_copy(deep=True),
required_fields=list(scenario.required_fields),
)
state.on_event("call.incoming")
state.on_event("call.answer")
state.kio = KIO(address="улица Ленина, 14", notify=["Служба 101"])
state.dispatch()
state.on_event("dds.dispatch")
accept_with_crew(state, state.desk.active)
return state
def handoff_state() -> SessionState:
first = fire()
second = load_file(LIBRARY / "tickets" / "t01-1-fire-container.yaml", LIBRARY)
state = SessionState(
session_id=uuid4(), scenario_id=first.id, scenario_title=first.title,
level=first.level.value, mode=SessionMode.TRAINING,
exercise=Exercise.CARD, handoff_to_dds=True, scenario=first,
)
state.kio = KIO(address="улица Ленина, 14", description="горит балкон",
notify=["Служба 101"])
state.dispatch()
prepare_handoff_queue(state, [second])
accept_with_crew(state, state.desk.active)
return state
BUILDERS = {"dds": dds_state, "call": call_state, "handoff": handoff_state}
def restored(name: str) -> SessionState:
return load_state(dump_state(BUILDERS[name]()), now_utc())
def station(state, **payload) -> None: def station(state, **payload) -> None:
@ -57,43 +117,32 @@ def station(state, **payload) -> None:
asyncio.run(station_ws._handle(state.session_id, state, _events.validate_python(payload))) asyncio.run(station_ws._handle(state.session_id, state, _events.validate_python(payload)))
def test_dds_checkpoint_before_desk_resumes_same_active_card_and_work(): def test_dds_checkpoint_resumes_same_active_card_and_work():
state = restored("dds") before = dds_state()
saved = BEFORE_DESK["dds"] state = load_state(dump_state(before), now_utc())
assert str(state.desk.active_id) == saved["dds_active_card_id"] assert state.desk.active_id == before.desk.active_id
assert state.desk.active.original_index == 1 assert state.desk.active.original_index == 1
assert state.desk.active.reply_text == "Ответ по второй карточке" assert state.desk.active.reply_text == "Ответ по второй карточке"
first = state.desk.ordered()[0] first = state.desk.ordered()[0]
assert first.status_log[-1].status is ServiceStatus.ACCEPTED assert first.status_log[-1].status is ServiceStatus.ACCEPTED
assert first.crew_selected == saved["dds_live_cards"][0]["crew_selected"] assert first.crew_selected == before.desk.ordered()[0].crew_selected
assert first.timers.measured_ms(TimerCode.DDS_ACK) is not None assert first.timers.measured_ms(TimerCode.DDS_ACK) is not None
station(state, type="card.next", card_id=saved["dds_active_card_id"]) station(state, type="card.next", card_id=str(before.desk.active_id))
assert [record.card_id for record in state.desk.completed] == [ assert [record.card_id for record in state.desk.completed] == [before.desk.active_id]
UUID(saved["dds_active_card_id"])]
assert state.desk.active.original_index == 2 assert state.desk.active.original_index == 2
assert state.station_snapshot().card_index == 3 assert state.station_snapshot().card_index == 3
def test_legacy_checkpoint_without_active_id_opens_first_card(): def test_call_checkpoint_keeps_dispatcher_work_on_shared_timers():
payload = json.loads(json.dumps(BEFORE_DESK["dds"])) before = call_state()
del payload["dds_active_card_id"] state = load_state(dump_state(before), now_utc())
state = load_state(payload, now_utc())
assert state.desk.active.original_index == 0
assert state.station_snapshot().card_index == 1
def test_call_checkpoint_before_desk_moves_dispatcher_work_to_desk_card():
state = restored("call")
saved = BEFORE_DESK["call"]
card = state.desk.active card = state.desk.active
assert card.card_id == state.dispatched_card.card_id assert card.card_id == state.dispatched_card.card_id
assert card.crew_selected == saved["crew_selected"] assert card.crew_selected == before.desk.active.crew_selected
assert [mark.status for mark in card.status_log] == [ServiceStatus.ACCEPTED] assert [mark.status for mark in card.status_log] == [ServiceStatus.ACCEPTED]
assert card.timers is state.timers assert card.timers is state.timers
snapshot = state.station_snapshot() snapshot = state.station_snapshot()
@ -104,11 +153,11 @@ def test_call_checkpoint_before_desk_moves_dispatcher_work_to_desk_card():
asyncio.run(finish_module.finish(state.session_id, state)) asyncio.run(finish_module.finish(state.session_id, state))
crew = next(item for item in state.score["metrics"] if item["key"] == "dds_crew") crew = next(item for item in state.score["metrics"] if item["key"] == "dds_crew")
assert crew["passed"] and crew["fact"] == saved["crew_selected"] assert crew["passed"] and crew["fact"] == card.crew_selected
assert any(item["key"] == "interview_time" for item in state.score["metrics"]) assert any(item["key"] == "interview_time" for item in state.score["metrics"])
def test_handoff_checkpoint_before_desk_keeps_operator_card_and_queue(): def test_handoff_checkpoint_keeps_operator_card_and_queue():
state = restored("handoff") state = restored("handoff")
assert state.operator_kio.address == "улица Ленина, 14" assert state.operator_kio.address == "улица Ленина, 14"
@ -118,14 +167,9 @@ def test_handoff_checkpoint_before_desk_keeps_operator_card_and_queue():
assert state.desk.active.status_log[-1].status is ServiceStatus.ACCEPTED assert state.desk.active.status_log[-1].status is ServiceStatus.ACCEPTED
@pytest.mark.parametrize("name", ["dds", "call", "handoff"])
def test_new_checkpoint_keeps_json_keys_of_format_before_desk(name):
assert set(dump_state(restored(name))) == set(BEFORE_DESK[name])
@pytest.mark.parametrize("name", ["dds", "call", "handoff"]) @pytest.mark.parametrize("name", ["dds", "call", "handoff"])
def test_reads_do_not_change_state(name, monkeypatch): def test_reads_do_not_change_state(name, monkeypatch):
monkeypatch.setattr(checkpoint.time, "monotonic", lambda: 1_000_000.0) monkeypatch.setattr(timers_module.time, "monotonic", lambda: 1_000_000.0)
state = restored(name) state = restored(name)
active = state.desk.active active = state.desk.active

View file

@ -122,29 +122,6 @@ def test_text_revealed_facts_and_completed_cards_round_trip_whole():
assert restored.desk.completed[0].received_at.tzinfo is not None assert restored.desk.completed[0].received_at.tzinfo is not None
def test_legacy_checkpoint_without_new_keys_gets_defaults():
before = dds_state()
before.text_revealed_facts = {"address": "улица Ленина, 14"}
before.desk.completed = [completed_card()]
payload = dump_state(before)
del payload["text_revealed_facts"]
legacy_keys = {"card_id", "scenario_id", "reply_text", "metrics",
"findings", "actions", "duration_ms"}
payload["dds_completed"] = [
{key: value for key, value in item.items() if key in legacy_keys}
for item in payload["dds_completed"]
]
restored = load_state(payload, now_utc())
assert restored.text_revealed_facts == {}
card = restored.desk.completed[0]
assert card.card_id == before.desk.completed[0].card_id
assert (card.title, card.address, card.description, card.incident_type,
card.victims_count, card.received_at, card.managed_service) == (None,) * 7
assert card.recipient_services == []
def test_checkpoint_rejects_unknown_format_version(): def test_checkpoint_rejects_unknown_format_version():
payload = dump_state(dds_state()) payload = dump_state(dds_state())
payload["version"] = 999 payload["version"] = 999