refactor: снимок занятия — model_dump сохраняемой части сессии вместо ручного перечисления полей
This commit is contained in:
parent
64313a62fb
commit
72c6a7cfa3
7 changed files with 546 additions and 471 deletions
|
|
@ -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,
|
|
||||||
"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", [])],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
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:],
|
|
||||||
)
|
|
||||||
_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.caller = build_caller(
|
||||||
state.scenario.id,
|
state.scenario.id,
|
||||||
use_pregenerated=state.scenario.tree.pregenerated,
|
use_pregenerated=state.scenario.tree.pregenerated,
|
||||||
)
|
)
|
||||||
embedder = get_embedder()
|
embedder = get_embedder()
|
||||||
if embedder is not None:
|
if embedder is not None:
|
||||||
state.slots = SlotMachine(state.scenario, embedder)
|
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("неподдерживаемая версия снимка занятия")
|
||||||
|
state = SessionState.model_validate(
|
||||||
|
payload, context={"downtime_ms": downtime_ms(saved_at)}
|
||||||
|
)
|
||||||
|
if state.exercise is Exercise.CALL:
|
||||||
|
# Таймеры звонка и диспетчера — одна цепочка 112 → ДДС (см. dispatch):
|
||||||
|
# в снимке это копии, в живой сессии — один объект.
|
||||||
|
for card in state.desk.cards.values():
|
||||||
|
card.timers = state.timers
|
||||||
|
rebuild_live(state)
|
||||||
return state
|
return state
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
"""Единственная точка, где событие двигает таймеры."""
|
"""Единственная точка, где событие двигает таймеры."""
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
"""Только запущенные таймеры: показывать нули по нормативам,
|
"""Только запущенные таймеры: показывать нули по нормативам,
|
||||||
|
|
|
||||||
268
backend/tests/test_checkpoint_model.py
Normal file
268
backend/tests/test_checkpoint_model.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue