diff --git a/backend/app/session/checkpoint.py b/backend/app/session/checkpoint.py index a1bc551..2346117 100644 --- a/backend/app/session/checkpoint.py +++ b/backend/app/session/checkpoint.py @@ -1,374 +1,70 @@ """Переносимый JSON-снимок незавершённого занятия. -Снимок хранится в PostgreSQL после каждого подтверждённого действия. Он не -содержит сокеты, аудиобуферы или объекты моделей: после перезапуска процесса -они создаются заново, а учебные данные, таймеры и состояние ДДС восстанавливаются. +Снимок хранится в PostgreSQL после каждого подтверждённого действия. Это +`model_dump` сохраняемой части сессии (`PersistedSession`): сокеты, аудиобуферы +и объекты моделей в неё не входят и после перезапуска процесса собираются +заново (`rebuild_live`). + +Снимки прежних версий не читаются: снимок живёт только у незавершённого +занятия, а плохой снимок реестр и восстановление узла логируют и пропускают. """ -import time -from datetime import UTC, datetime -from uuid import UUID +from datetime import datetime -from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel, Field 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.slots import SlotMachine -from app.domain.events import ( - CallEndReason, - Exercise, - 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 +from app.dialog.slots import SlotMachine, SlotProgress +from app.domain.events import Exercise +from app.session.state import SessionState +from app.session.timers import downtime_ms -CHECKPOINT_VERSION = 1 +CHECKPOINT_VERSION = 2 -def _dump_timers(timers: SessionTimers, now: float) -> dict: - return { - "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() - }, - } +class CallerProgress(BaseModel): + """Прогресс голосового звонящего. В снимок пока не входит — см. `SessionState`.""" - -def _dump_live_card(item: DdsLiveCard, now: float) -> dict: - 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], - } + slots: SlotProgress = Field(default_factory=SlotProgress) + persona: PersonaProgress = Field(default_factory=PersonaProgress) 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 - active = desk.active - now = time.monotonic() - payload = { - "version": CHECKPOINT_VERSION, - "session_id": str(state.session_id), - "scenario_id": state.scenario_id, - "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", [])], + if state.exercise is not Exercise.CALL or state.scenario is None: + return + progress = caller or CallerProgress() + state.persona = PersonaState(state.scenario.persona, progress=progress.persona) + state.caller = build_caller( + state.scenario.id, + use_pregenerated=state.scenario.tree.pregenerated, ) - - -def _restore_completed(item: dict) -> DdsCardRecord: - return DdsCardRecord( - card_id=UUID(item["card_id"]), - scenario_id=item["scenario_id"], - reply_text=item.get("reply_text", ""), - metrics=[Metric.model_validate(metric) for metric in item.get("metrics", [])], - findings=[Finding.model_validate(finding) for finding in item.get("findings", [])], - actions=list(item.get("actions") or []), - duration_ms=int(item.get("duration_ms", 0)), - title=item.get("title"), - address=item.get("address"), - description=item.get("description"), - incident_type=item.get("incident_type"), - victims_count=item.get("victims_count"), - received_at=_dt(item.get("received_at")), - managed_service=item.get("managed_service"), - recipient_services=list(item.get("recipient_services") or []), - ) - - -def _restore_desk(state: SessionState, payload: dict, saved_at: datetime) -> None: - desk = state.desk - live = payload.get("dds_live_cards", []) - desk.scenarios = [Scenario.model_validate(item) for item in payload.get("dds_scenarios", [])] - desk.completed = [_restore_completed(item) for item in payload.get("dds_completed", [])] - desk.limits = dict(state.timers.limits) - desk.card_index = int(payload.get("dds_card_index", 0)) - desk.arrival_interval_seconds = int(payload.get("dds_arrival_interval_seconds", 0)) - desk.max_waiting = int(payload.get("dds_max_waiting", 3)) - desk.next_index = int(payload.get( - "dds_next_scenario_index", - max((item["original_index"] for item in live), default=-1) + 1, - )) - desk.next_arrival_at = _dt(payload.get("dds_next_arrival_at")) - for item in live: - desk.add(_restore_live_card(item, saved_at)) - if desk.cards: - # Legacy snapshots had no explicit active ID; newer snapshots may - # intentionally be between cards while waiting for the next arrival. - raw_id = payload.get("dds_active_card_id") - active_id = UUID(raw_id) if raw_id else None - if active_id is None and "dds_active_card_id" not in payload: - active_id = desk.ordered()[0].card_id - if active_id is not None: - desk.open(active_id) - elif (state.exercise is Exercise.CALL and state.dispatched_card is not None - and state.scenario is not None): - # До пульта живой диспетчер упражнения 112 писал прямо в сессию: - # его работа лежит в верхних ключах снимка. - desk.add(_restore_live_card({ - **payload, - "original_index": 0, - "scenario": payload["scenario"], - "dispatched_card": payload["dispatched_card"], - "dispatched_at": payload.get("dispatched_at") or saved_at.isoformat(), - }, saved_at)) - desk.open(state.dispatched_card.card_id) - if state.exercise is Exercise.CALL: - # Таймеры звонка и диспетчера — одна цепочка 112 → ДДС (см. dispatch). - for card in desk.cards.values(): - card.timers = state.timers - if (desk.scenarios - and desk.next_index < len(desk.scenarios) - and desk.next_arrival_at is None): - # Old checkpoints had no delivery schedule; resume any remaining - # selected scenarios immediately rather than strand the session. - desk.next_arrival_at = now_utc() + embedder = get_embedder() + if embedder is not None: + state.slots = SlotMachine(state.scenario, embedder, progress=progress.slots) def load_state(payload: dict, saved_at: datetime) -> SessionState: """Восстановить состояние; неизвестная версия отклоняется явно.""" if payload.get("version") != CHECKPOINT_VERSION: raise ValueError("неподдерживаемая версия снимка занятия") - scenario = Scenario.model_validate(payload["scenario"]) if payload.get("scenario") else None - state = SessionState( - 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:], + state = SessionState.model_validate( + payload, context={"downtime_ms": downtime_ms(saved_at)} ) - _restore_desk(state, payload, saved_at) - # Голосовые runtime-объекты не сериализуются. Их безопасно собрать заново; - # карточка и оценка при этом остаются прежними. - if state.exercise is Exercise.CALL and state.scenario is not None: - state.persona = PersonaState(state.scenario.persona) - state.caller = build_caller( - state.scenario.id, - use_pregenerated=state.scenario.tree.pregenerated, - ) - embedder = get_embedder() - if embedder is not None: - state.slots = SlotMachine(state.scenario, embedder) + if state.exercise is Exercise.CALL: + # Таймеры звонка и диспетчера — одна цепочка 112 → ДДС (см. dispatch): + # в снимке это копии, в живой сессии — один объект. + for card in state.desk.cards.values(): + card.timers = state.timers + rebuild_live(state) return state diff --git a/backend/app/session/dds.py b/backend/app/session/dds.py index 47cdcfe..1e4110a 100644 --- a/backend/app/session/dds.py +++ b/backend/app/session/dds.py @@ -12,6 +12,8 @@ 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 @@ -37,8 +39,7 @@ if TYPE_CHECKING: from app.session.state import SessionState -@dataclass -class DdsCardRecord: +class DdsCardRecord(BaseModel): card_id: UUID scenario_id: str reply_text: str @@ -53,7 +54,7 @@ class DdsCardRecord: victims_count: int | None = None received_at: datetime | None = None managed_service: str | None = None - recipient_services: list[str] = field(default_factory=list) + recipient_services: list[str] = Field(default_factory=list) @property def score_auto(self) -> float: @@ -63,8 +64,7 @@ class DdsCardRecord: return round(100 * passed / total, 1) if total else 0.0 -@dataclass -class DdsLiveCard: +class DdsLiveCard(BaseModel): """Изолированное живое состояние одной одновременно выданной карточки.""" original_index: int @@ -73,16 +73,16 @@ class DdsLiveCard: 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) + 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) + 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) + reply_log: list[tuple[datetime, str]] = Field(default_factory=list) @property def card_id(self) -> UUID: @@ -297,18 +297,17 @@ def _finish_phone_call(card: DdsLiveCard) -> list[Any]: return [line, PhoneReport(**report.model_dump())] -@dataclass -class DdsDesk: +class DdsDesk(BaseModel): """Живые карточки занятия, активная из них и очередь поступления.""" - scenarios: list[Scenario] = field(default_factory=list) - cards: dict[UUID, DdsLiveCard] = field(default_factory=dict) + 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)) + 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 diff --git a/backend/app/session/state.py b/backend/app/session/state.py index 71a85b2..166e611 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -6,11 +6,12 @@ """ import time -from dataclasses import dataclass, field 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 @@ -40,11 +41,21 @@ 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"] +__all__ = ["DdsCardRecord", "DdsLiveCard", "PersistedSession", "SessionState", "now_utc"] + +#: Сколько последних команд станции помнит защита от повтора. +MAX_STATION_COMMANDS = 512 -@dataclass -class SessionState: +class PersistedSession(BaseModel): + """Сохраняемая часть занятия — ровно то, что пишется в снимок. + + Снимок — `model_dump` этой модели, загрузка — `model_validate`, поэтому + поле сессии нельзя завести, не решив, сохраняемое оно или живое: подкласс + обязан пометить каждое своё поле `Field(exclude=True)`, иначе класс не + создастся (см. `__pydantic_init_subclass__`). + """ + session_id: UUID scenario_id: str scenario_title: str @@ -54,12 +65,10 @@ class SessionState: 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) + required_fields: list[str] = Field(default_factory=list) trainee_name: str | None = None #: Чьё это занятие. Проставляется при запуске, когда курсант известен #: по учётной записи: по нему разбор закрывается от чужих (lct-23). @@ -68,30 +77,20 @@ class SessionState: #: остальные адресаты карточки показываются информационно. dds_service: str | None = None attempt: int = 1 - criteria: LessonCriteria = field(default_factory=LessonCriteria) + 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) + 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) + 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) кадры только считаются — # этого достаточно, чтобы доказать, что звук доходит от микрофона до сервера. @@ -110,21 +109,73 @@ class SessionState: dispatched_card: KIO | None = None dispatched_at: datetime | None = None #: Поля, из-за которых диспетчер вернул карточку, — основание E6. - bounced_fields: list[str] = field(default_factory=list) + bounced_fields: list[str] = Field(default_factory=list) #: Готовые карточки связки 112→ДДС ждут, пока курсант не сдаст свою. - pending_dds_scenarios: list[Scenario] = field(default_factory=list) + 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) + 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) + 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: """Единственная точка, где событие двигает таймеры.""" diff --git a/backend/app/session/timers.py b/backend/app/session/timers.py index 4ca1905..d3338e6 100644 --- a/backend/app/session/timers.py +++ b/backend/app/session/timers.py @@ -9,8 +9,17 @@ """ import time -from dataclasses import dataclass, field 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 @@ -57,14 +66,46 @@ STOPS: dict[str, tuple[TimerCode, ...]] = { } -@dataclass -class Timer: +def downtime_ms(saved_at: datetime) -> int: + """Сколько занятие пролежало в снимке: запущенный таймер считает и это время.""" + 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 started_at: float | None = None elapsed_ms: int = 0 attempt: int = 1 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: if self.stopped: # Повторный запуск после остановки — это новая попытка (обратный дозвон). @@ -85,22 +126,21 @@ class Timer: return int((now - self.started_at) * 1000) -@dataclass -class SessionTimers: +class SessionTimers(BaseModel): """Набор таймеров одной сессии. `limits` приходит из конфига — норматив меняется значением, а не правкой кода.""" - limits: dict[TimerCode, int] = field( + limits: dict[TimerCode, int] = Field( 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: now = time.monotonic() if now is None else now 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, ()): - 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]: """Только запущенные таймеры: показывать нули по нормативам, diff --git a/backend/tests/test_checkpoint_model.py b/backend/tests/test_checkpoint_model.py new file mode 100644 index 0000000..6ec7a19 --- /dev/null +++ b/backend/tests/test_checkpoint_model.py @@ -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 diff --git a/backend/tests/test_dds_desk_checkpoint.py b/backend/tests/test_dds_desk_checkpoint.py index 5a2c294..d70ac9f 100644 --- a/backend/tests/test_dds_desk_checkpoint.py +++ b/backend/tests/test_dds_desk_checkpoint.py @@ -1,13 +1,13 @@ -"""Чекпойнт пульта ДДС: формат прежний, чтения ничего не меняют. +"""Чекпойнт пульта ДДС: занятие продолжается с той же активной карточкой, +чтения ничего не меняют. -`golden/checkpoint_before_desk.json` записан кодом до `DdsDesk`: занятие, -поднятое из такого снимка, продолжается с той же активной карточкой. +Состояния собраны в коде и проходят через снимок: пульт ДДС на три карточки, +упражнение 112 с живым диспетчером и связка 112→ДДС. """ import asyncio -import json from pathlib import Path -from uuid import UUID, uuid4 +from uuid import uuid4 import pytest from pydantic import TypeAdapter @@ -18,19 +18,15 @@ from app.domain.kio import KIO from app.domain.statuses import ServiceStatus from app.domain.timers import TimerCode from app.scenarios.loader import load_file -from app.session import checkpoint 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.dds import prepare_handoff_queue +from app.session.dds import prepare_handoff_queue, prepare_queue from app.session.hub import hub from app.session.state import SessionState from app.session.timers import now_utc LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" - -BEFORE_DESK = json.loads( - (Path(__file__).parent / "golden" / "checkpoint_before_desk.json").read_text() -) _events = TypeAdapter(StationToServer) @@ -47,9 +43,73 @@ def offline_finish(monkeypatch): monkeypatch.setattr(hub, "journal", None) -def restored(name: str, **changes): - payload = {**json.loads(json.dumps(BEFORE_DESK[name])), **changes} - return load_state(payload, now_utc()) +def fire(): + return load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + + +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: @@ -57,43 +117,32 @@ def station(state, **payload) -> None: 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(): - state = restored("dds") - saved = BEFORE_DESK["dds"] +def test_dds_checkpoint_resumes_same_active_card_and_work(): + before = dds_state() + 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.reply_text == "Ответ по второй карточке" first = state.desk.ordered()[0] 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 - 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] == [ - UUID(saved["dds_active_card_id"])] + assert [record.card_id for record in state.desk.completed] == [before.desk.active_id] assert state.desk.active.original_index == 2 assert state.station_snapshot().card_index == 3 -def test_legacy_checkpoint_without_active_id_opens_first_card(): - payload = json.loads(json.dumps(BEFORE_DESK["dds"])) - del payload["dds_active_card_id"] - - 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"] +def test_call_checkpoint_keeps_dispatcher_work_on_shared_timers(): + before = call_state() + state = load_state(dump_state(before), now_utc()) card = state.desk.active 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 card.timers is state.timers 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)) 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"]) -def test_handoff_checkpoint_before_desk_keeps_operator_card_and_queue(): +def test_handoff_checkpoint_keeps_operator_card_and_queue(): state = restored("handoff") 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 -@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"]) 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) active = state.desk.active diff --git a/backend/tests/test_session_checkpoint.py b/backend/tests/test_session_checkpoint.py index ad6ca28..cf20b75 100644 --- a/backend/tests/test_session_checkpoint.py +++ b/backend/tests/test_session_checkpoint.py @@ -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 -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(): payload = dump_state(dds_state()) payload["version"] = 999