Complete training workflow and acceptance hardening

This commit is contained in:
andreysk0304 2026-09-26 18:12:27 +03:00 • committed by gglamer
commit 7237265833
243 changed files with 17014 additions and 1500 deletions

View file

@ -94,6 +94,7 @@ def dump_state(state: SessionState) -> dict:
"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,
@ -135,6 +136,9 @@ def dump_state(state: SessionState) -> dict:
state.phone_pending.model_dump(mode="json") if state.phone_pending else None
),
"dds_scenarios": [item.model_dump(mode="json") for item in state.dds_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 state.dds_live_cards],
"dds_active_card_id": (
str(state.dds_active_card_id) if state.dds_active_card_id else None
@ -163,6 +167,7 @@ def dump_state(state: SessionState) -> dict:
"reply_log": [[at.isoformat(), text] for at, text in state.reply_log],
"resolved_outcome": state.resolved_outcome,
"resolve_comment": state.resolve_comment,
"processed_station_commands": state.processed_station_commands[-512:],
}
# В actions/score могут быть datetime/UUID из расчёта; JSONB должен
# получать только стандартные JSON-типы.
@ -250,6 +255,7 @@ def load_state(payload: dict, saved_at: datetime) -> SessionState:
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 []),
@ -296,6 +302,12 @@ def load_state(payload: dict, saved_at: datetime) -> SessionState:
),
dds_scenarios=[Scenario.model_validate(item)
for item in payload.get("dds_scenarios", [])],
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),
dds_live_cards=[_restore_live_card(item, saved_at)
for item in payload.get("dds_live_cards", [])],
dds_active_card_id=(
@ -329,6 +341,7 @@ def load_state(payload: dict, saved_at: datetime) -> SessionState:
for at, text in payload.get("reply_log", [])],
resolved_outcome=payload.get("resolved_outcome"),
resolve_comment=payload.get("resolve_comment", ""),
processed_station_commands=list(payload.get("processed_station_commands") or [])[-512:],
)
if state.dds_live_cards:
# Legacy snapshots had no explicit active ID; newer snapshots may

View file

@ -2,6 +2,7 @@
import re
from datetime import datetime, timedelta
from uuid import uuid4
from app.domain import ekp
from app.domain.kio import KIO, ResponseStatus
@ -18,13 +19,27 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
state.level = scenario.level.value
state.required_fields = list(scenario.required_fields)
truth = scenario.ground_truth
address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "")
caller_fact = next((fact.value for fact in scenario.facts if fact.id in {"f_caller", "f_applicant"}), "")
caller_phone = next((
match.group(0).strip()
for match in re.finditer(r"(?<!\d)(?:\+?7|8)?(?:[\s().-]*\d){10,11}(?!\d)", caller_fact)
if len(re.sub(r"\D", "", match.group(0))) in {10, 11}
), None)
address_fact = next(
(fact.value for fact in scenario.facts if "address" in fact.id), ""
)
caller_fact = next(
(
fact.value
for fact in scenario.facts
if fact.id in {"f_caller", "f_applicant"}
),
"",
)
caller_phone = next(
(
match.group(0).strip()
for match in re.finditer(
r"(?<!\d)(?:\+?7|8)?(?:[\s().-]*\d){10,11}(?!\d)", caller_fact
)
if len(re.sub(r"\D", "", match.group(0))) in {10, 11}
),
None,
)
caller_names = re.findall(
r"(?<![А-ЯЁа-яё])([А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ][а-яё-]+){1,2})(?![А-ЯЁа-яё])",
caller_fact,
@ -34,20 +49,48 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
caller_name = caller_names[0] if len(caller_names) == 1 else None
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
service = truth.dds.value if truth.dds else None
fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"}
state.kio = KIO(
registered_at=now_utc(), caller_number=caller_phone, caller_name=caller_name,
caller_contact=caller_phone,
address=truth.address or address_fact or None,
floor=floor.group(1) if floor else None,
incident_type=truth.incident_type, incident_code=truth.incident_code,
incident_group=(ekp.incident(truth.incident_code).group
if truth.incident_code and ekp.incident(truth.incident_code) else None),
dds=truth.dds, signs=list(scenario.signs),
notify=list(truth.notify) or ([fallback[service]] if service in fallback else []),
victims_count=truth.victims,
description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line,
)
fallback = {
"01": "Служба 101",
"02": "МВД",
"03": "Скорая помощь",
"04": "Аварийная служба",
}
if scenario.student_card is not None:
# A moderated learner-authored KIO is the card itself; do not flatten
# it to title/description and silently discard its structured fields.
state.kio = scenario.student_card.model_copy(
deep=True,
update={
"card_id": uuid4(),
"registered_at": now_utc(),
"response_status": ResponseStatus.REGISTERED,
"dispatch_order_at": None,
"arrival_at": None,
},
)
else:
state.kio = KIO(
registered_at=now_utc(),
caller_number=caller_phone,
caller_name=caller_name,
caller_contact=caller_phone,
address=truth.address or address_fact or None,
floor=floor.group(1) if floor else None,
incident_type=truth.incident_type,
incident_code=truth.incident_code,
incident_group=(
ekp.incident(truth.incident_code).group
if truth.incident_code and ekp.incident(truth.incident_code)
else None
),
dds=truth.dds,
signs=list(scenario.signs),
notify=list(truth.notify)
or ([fallback[service]] if service in fallback else []),
victims_count=truth.victims,
description="; ".join(fact.value for fact in scenario.facts[:3])
or scenario.first_line,
)
state.dispatched_card = None
if service:
state.dispatch(service)
@ -76,7 +119,9 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
state.on_event("dds.dispatch")
def _append_live_card(state: SessionState, scenario: Scenario, index: int) -> DdsLiveCard:
def _append_live_card(
state: SessionState, scenario: Scenario, index: int
) -> DdsLiveCard:
state.dds_card_index = index
prepare_card(state, scenario)
card = DdsLiveCard(
@ -114,9 +159,14 @@ def deliver_due_cards(state: SessionState, now: datetime | None = None) -> int:
delivered = 0
while state.dds_next_scenario_index < len(state.dds_scenarios):
active_id = state.dds_active_card_id if any(
item.card_id == state.dds_active_card_id for item in state.dds_live_cards
) else None
active_id = (
state.dds_active_card_id
if any(
item.card_id == state.dds_active_card_id
for item in state.dds_live_cards
)
else None
)
active_exists = active_id is not None
waiting_count = len(state.dds_live_cards) - int(active_exists)
if waiting_count >= state.dds_max_waiting:
@ -163,3 +213,45 @@ def prepare_queue(
state.dds_next_scenario_index = 0
state.dds_next_arrival_at = now_utc()
deliver_due_cards(state)
def prepare_handoff_queue(
state: SessionState,
additional_scenarios: list[Scenario],
arrival_interval_seconds: int = 0,
max_waiting: int = 3,
) -> None:
"""Передать созданную курсантом карточку в ДДС перед готовыми карточками."""
if state.dispatched_card is None or state.scenario is None:
return
now = now_utc()
state.operator_kio = state.dispatched_card.model_copy(deep=True)
state.operator_scenario = state.scenario.model_copy(deep=True)
# Карточка курсанта становится первым живым объектом очереди. Её данные
# не переписываются из эталона: именно их будет обрабатывать ДДС.
dds_timers = SessionTimers(limits=dict(state.timers.limits))
dds_timers.on_event("dds.dispatch")
first = DdsLiveCard(
original_index=0,
scenario=state.operator_scenario,
kio=state.operator_kio.model_copy(deep=True),
dispatched_card=state.operator_kio.model_copy(deep=True),
dispatched_at=state.dispatched_at or now,
timers=dds_timers,
)
state.dds_live_cards = [first]
state.dds_scenarios = [state.operator_scenario, *additional_scenarios]
state.dds_arrival_interval_seconds = arrival_interval_seconds
state.dds_max_waiting = max_waiting
state.dds_next_scenario_index = 1
state.dds_next_arrival_at = (
(
now + timedelta(seconds=arrival_interval_seconds)
if additional_scenarios and arrival_interval_seconds
else now
)
if additional_scenarios
else None
)
state.activate_dds_card(first.card_id, capture=False)
deliver_due_cards(state)

View file

@ -9,16 +9,22 @@
import asyncio
import logging
import time
from uuid import UUID
from app.domain.events import Exercise, ScoreReady
from app.domain.events import ErrorKind, ErrorEvent, Exercise, Metric, ScoreReady
from app.domain.statuses import ServiceStatus, current
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.timers import TimerCode
from app.scenarios import store
from app.scoring.card import evaluate_card
from app.scoring.competency import radar
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
from app.scoring.gost import GostResult, evaluate
from app.scoring.grammar import assess
from app.scoring.report import build as build_report
from app.scoring.taxonomy import METRIC_WEIGHTS
from app.scoring.timing import time_credit
from app.scoring.weights import apply_weights
from app.session.hub import hub
from app.session.state import DdsCardRecord, now_utc
@ -30,14 +36,67 @@ def score_current_dds(state) -> DdsCardRecord:
"""Оценить активную карточку отдельно, до выдачи следующей."""
number = state.dds_card_index + 1
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
dds_decision = state.scenario.dds_decision
findings = evaluate_dispatcher(
entries=state.status_log,
services=state.managed_services(),
crew_assignments=state.crew_assignments,
deadline_ms=decision_limit_ms,
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
reply_text=state.reply_text,
expected_decision=dds_decision.expected,
expected_decision_reason=dds_decision.reason,
)
metrics = dispatcher_metrics(
state, decision_limit_ms, dds_decision.expected, dds_decision.reason
)
metrics = dispatcher_metrics(state, decision_limit_ms)
weighted = GostResult(metrics=metrics, findings=findings)
work_limit_ms = state.timers.limits[TimerCode.DDS_WORK]
work_timer = state.timers.timers.get(TimerCode.DDS_WORK)
work_elapsed_ms = state.timers.measured_ms(TimerCode.DDS_WORK)
if work_elapsed_ms is None and work_timer is not None and work_timer.started_at is not None:
work_elapsed_ms = work_timer.current_ms(time.monotonic())
terminal = bool(state.managed_services()) and all(
current(state.status_log, service) in {
ServiceStatus.COMPLETED,
ServiceStatus.DECLINED,
ServiceStatus.REFUSED,
}
for service in state.managed_services()
)
work_passed = terminal and work_elapsed_ms is not None and work_elapsed_ms <= work_limit_ms
work_delta_ms = (work_elapsed_ms - work_limit_ms) if work_elapsed_ms is not None else None
if work_elapsed_ms is None:
work_fact = "время обработки не зафиксировано"
elif work_delta_ms and work_delta_ms > 0:
work_fact = f"{round(work_elapsed_ms / 1000)} с (+{round(work_delta_ms / 1000)} с сверх норматива)"
elif work_delta_ms and work_delta_ms < 0:
work_fact = (f"{round(work_elapsed_ms / 1000)} с (на "
f"{round(abs(work_delta_ms) / 1000)} с быстрее норматива)")
else:
work_fact = f"{round(work_elapsed_ms / 1000)} с (точно в норматив)"
if not terminal:
work_fact = f"карточка не завершена; {work_fact}"
weighted.metrics.append(Metric(
key="dds_work_time",
title="Отработка карточки ДДС",
fact=work_fact,
norm=f"завершить за {round(work_limit_ms / 1000)} с",
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
passed=work_passed,
weight=METRIC_WEIGHTS["dds_work_time"],
credit=time_credit(work_elapsed_ms, work_limit_ms) if terminal else 0.0,
))
if not work_passed:
weighted.findings.append(Finding(
code=ErrorCode.E3,
source=FindingSource.TIMERS,
summary="ДДС: норматив времени отработки карточки не выполнен",
fact=work_fact,
norm=f"завершить за {round(work_limit_ms / 1000)} с",
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
competency=Competency.NORMS,
))
apply_weights(weighted, {**state.scenario.score_weights, **state.criteria.score_weights})
actions = [
{"type": "card.status", "service": mark.service, "status": mark.status.value,
@ -66,6 +125,15 @@ def score_current_dds(state) -> DdsCardRecord:
actions=actions,
duration_ms=(max(0, int((now_utc() - state.dispatched_at).total_seconds() * 1000))
if state.dispatched_at else 0),
title=state.scenario.title,
address=state.dispatched_card.address,
description=state.dispatched_card.description,
incident_type=(state.dispatched_card.incident_type.value
if state.dispatched_card.incident_type else None),
victims_count=state.dispatched_card.victims_count,
received_at=state.dispatched_at,
managed_service=(state.managed_services()[0] if state.managed_services() else None),
recipient_services=list(state.dispatched_card.notify),
)
@ -74,12 +142,52 @@ async def finish(session_id: UUID, state) -> None:
path = await asyncio.to_thread(state.recorder.finalize)
state.recording_path = str(path) if path else None
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
scenario = state.scenario or store.get(state.scenario_id)
scenario = (state.operator_scenario if state.handoff_to_dds and state.operator_scenario
else state.scenario or store.get(state.scenario_id))
if scenario is None:
return
cards: list[DdsCardRecord] = []
if state.exercise is Exercise.CARD:
result = evaluate_card(scenario, state.dispatched_card or state.kio)
result = evaluate_card(
scenario, state.operator_kio or state.dispatched_card or state.kio
)
limit_ms = state.timers.limits[TimerCode.CARD_FILL]
elapsed_ms = state.timers.measured_ms(TimerCode.CARD_FILL)
submitted = state.dispatched_card is not None
if elapsed_ms is None:
elapsed_fact = "время не зафиксировано"
else:
delta_ms = elapsed_ms - limit_ms
elapsed_seconds = round(elapsed_ms / 1000)
if delta_ms > 0:
deviation = f"+{round(delta_ms / 1000)} с сверх норматива"
elif delta_ms < 0:
deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива"
else:
deviation = "точно в норматив"
elapsed_fact = f"{elapsed_seconds} с ({deviation})"
passed = submitted and elapsed_ms is not None and elapsed_ms <= limit_ms
result.metrics.append(Metric(
key="card_fill_time",
title="Время заполнения карточки",
fact=elapsed_fact if submitted else f"карточка не сдана; {elapsed_fact}",
norm=f"сдать карточку за {round(limit_ms / 1000)} с",
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
passed=passed,
weight=METRIC_WEIGHTS["card_fill_time"],
credit=time_credit(elapsed_ms, limit_ms) if submitted else 0.0,
))
if not passed:
result.findings.append(Finding(
code=ErrorCode.E3,
source=FindingSource.TIMERS,
summary="Время заполнения карточки: норматив не выполнен",
fact=elapsed_fact if submitted else f"карточка не сдана; {elapsed_fact}",
norm=f"сдать карточку за {round(limit_ms / 1000)} с",
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
competency=Competency.NORMS,
))
elif state.exercise is Exercise.DDS:
# Все карточки выданы одновременно: при досрочном завершении оцениваем
# каждую, включая не открытую, потому что её норматив уже шёл.
@ -107,27 +215,74 @@ async def finish(session_id: UUID, state) -> None:
resolved_outcome=state.resolved_outcome,
dispatched=state.dispatched_card is not None,
)
# Грамматика относится к свободному описанию оператора 112, а не к
# заполнению карточки на стороне ДДС. Учитывается только по явной настройке
# преподавателя и только когда есть текст для проверки.
operator_kio = state.operator_kio if state.handoff_to_dds else None
description = (operator_kio or state.kio).description or ""
if (state.exercise is not Exercise.DDS and state.criteria.require_correct_grammar
and description.strip()):
grammar = await assess(description)
result.metrics.append(Metric(
key="description_grammar",
title="Грамматика описания происшествия",
fact="ошибок не обнаружено" if grammar.passed else "; ".join(grammar.errors),
norm="грамматически корректное описание",
ref="критерий занятия; правила русского языка",
passed=grammar.passed,
weight=1.0,
))
if not grammar.passed:
result.findings.append(Finding(
code=ErrorCode.E4,
source=FindingSource.GRAMMAR,
summary="Грамматическая ошибка в описании происшествия",
fact="; ".join(grammar.errors),
norm="грамматически корректное описание",
ref="критерий занятия; правила русского языка",
competency=Competency.COMMUNICATION,
))
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
# участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
if state.dispatched_card is not None and (
state.exercise is Exercise.CALL or state.handoff_to_dds
):
if state.dispatched_card is not None and state.exercise is Exercise.CALL:
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
dispatcher_findings = evaluate_dispatcher(
entries=state.status_log,
services=state.managed_services(),
crew_assignments=state.crew_assignments,
deadline_ms=decision_limit_ms,
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
reply_text=state.reply_text,
expected_decision=scenario.dds_decision.expected,
expected_decision_reason=scenario.dds_decision.reason,
)
result.findings.extend(dispatcher_findings)
result.metrics.extend(dispatcher_metrics(state, decision_limit_ms))
result.metrics.extend(dispatcher_metrics(
state, decision_limit_ms, scenario.dds_decision.expected,
scenario.dds_decision.reason,
))
# DDS cards were weighted individually in score_current_dds using each
# card's scenario defaults plus the lesson override. Reapplying the first
# scenario's weights here would corrupt the other ticket cards.
if state.exercise is not Exercise.DDS:
apply_weights(result, {**scenario.score_weights, **state.criteria.score_weights})
if state.handoff_to_dds and state.dds_scenarios:
# В связке КИО оценивается относительно эталона и весов упражнения
# 112, а каждая карточка очереди уже взвешена собственным сценарием.
cards = list(state.dds_completed)
state.capture_active_dds()
for live in sorted(state.dds_live_cards, key=lambda item: item.original_index):
if any(item.card_id == live.card_id for item in cards):
continue
state.activate_dds_card(live.card_id, capture=False)
cards.append(score_current_dds(state))
state.dds_completed = cards
for card in cards:
result.metrics.extend(card.metrics)
result.findings.extend(card.findings)
# Сводка числами: по ней считается дельта между попытками в профиле.
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
required = scenario.ground_truth.required_facts
@ -140,6 +295,7 @@ async def finish(session_id: UUID, state) -> None:
"score_auto": result.score,
"summary": {
"interview_ms": state.timers.measured_ms(TimerCode.INTERVIEW),
"card_fill_ms": state.timers.measured_ms(TimerCode.CARD_FILL),
"facts_got": len([fact for fact in required if fact in revealed]),
"facts_required": len(required),
"hints": len(state.hints_shown),
@ -155,18 +311,28 @@ async def finish(session_id: UUID, state) -> None:
{"card_id": str(card.card_id), "scenario_id": card.scenario_id,
"score_auto": card.score_auto, "reply_text": card.reply_text,
"actions": card.actions, "duration_ms": card.duration_ms,
"title": card.title, "address": card.address,
"description": card.description, "incident_type": card.incident_type,
"victims_count": card.victims_count,
"received_at": card.received_at.isoformat() if card.received_at else None,
"managed_service": card.managed_service,
"recipient_services": card.recipient_services,
"metrics": [metric.model_dump(mode="json") for metric in card.metrics],
"findings": [finding.model_dump(mode="json") for finding in card.findings]}
for card in cards
] if state.exercise is Exercise.DDS else [],
] if state.exercise is Exercise.DDS or state.handoff_to_dds else [],
}
# Полный разбор хранится вместе с оценкой: PDF/CSV и история должны
# переживать перезапуск backend, а не зависеть от объекта в hub._sessions.
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))
if hub.journal:
await hub.journal.score(session_id, result.score, state.score)
if hub.journal and not await hub.journal.score(session_id, result.score, state.score):
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.INTERNAL,
message="Не удалось сохранить оценку и аудит; итог не выдан. Обратитесь к преподавателю.",
))
return
hub.to_observers(session_id, ScoreReady(session_id=session_id))
await release_score(session_id, state)

View file

@ -7,13 +7,23 @@
import asyncio
import contextlib
import logging
from contextvars import ContextVar, Token
from collections.abc import AsyncIterator, Iterator
from datetime import UTC, datetime
from typing import Protocol
from uuid import UUID
from pydantic import BaseModel
from app.domain.events import CardReceived, Exercise, StationState, TimerTick
from app.domain.events import (
CardReceived,
ErrorEvent,
ErrorKind,
Exercise,
StationState,
TimerTick,
)
from app.session.state import SessionState
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
@ -21,6 +31,15 @@ from app.session.state import SessionState
QUEUE_SIZE = 256
TICK_SECONDS = 1.0
LEASE_FENCED_MESSAGE = "Занятие передано другому backend-узлу; переподключитесь."
log = logging.getLogger(__name__)
def _current_task():
try:
return asyncio.current_task()
except RuntimeError: # synchronous tests and tooling have no running loop
return None
class Journal(Protocol):
@ -29,21 +48,26 @@ class Journal(Protocol):
async def start_lesson(
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
trainee_id: UUID | None = None,
) -> tuple[int, UUID | None, str | None]: ...
trainee_id: UUID | None = None, owner_login: str | None = None,
backend_node_id: str | None = None,
) -> tuple[int, UUID | None, str | None, int] | None: ...
async def utterance(self, session_id: UUID, entry) -> None: ...
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...
async def self_assessment(self, session_id: UUID, missed: list[str], comment: str, at) -> None: ...
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None: ...
async def self_assessment(
self, session_id: UUID, missed: list[str], comment: str, at
) -> bool: ...
async def score(self, session_id: UUID, score_auto: float, report: dict) -> bool: ...
async def score_snapshot(self, session_id: UUID, report: dict) -> None: ...
async def score_override(
self, session_id: UUID, score_final: float, author: str, comment: str,
) -> None: ...
) -> bool: ...
async def session_started(self, session_id: UUID, at) -> None: ...
async def session_ended(self, session_id: UUID, at, reason: str) -> None: ...
async def checkpoint(self, state: SessionState) -> None: ...
async def restore_active(self) -> list[SessionState]: ...
async def renew(self, session_id: UUID) -> None: ...
async def claim_expired(self, session_id: UUID | None = None) -> list[SessionState]: ...
class SessionHub:
@ -54,6 +78,9 @@ class SessionHub:
self._trainees: dict[UUID, set[asyncio.Queue]] = {}
self._stations: dict[UUID, set[asyncio.Queue]] = {}
self._tickers: dict[UUID, asyncio.Task] = {}
self._event_batch: ContextVar[dict | None] = ContextVar(
f"session-event-batch-{id(self)}", default=None
)
# ── реестр ──
@ -62,12 +89,47 @@ class SessionHub:
return state
def get(self, session_id: UUID) -> SessionState | None:
return self._sessions.get(session_id)
state = self._sessions.get(session_id)
return None if state is not None and state.lease_fenced else state
def is_lease_fenced(self, session_id: UUID) -> bool:
state = self._sessions.get(session_id)
return state is not None and state.lease_fenced
def active_sessions(self, owner_login: str) -> list[SessionState]:
"""Живые занятия только преподавателя-владельца для группового обзора."""
return [
state for state in self._sessions.values()
if not state.ended and not state.lease_fenced and state.owner_login == owner_login
]
def history(
self, *, owner_login: str | None = None, trainee_id: UUID | None = None,
mode: str | None = None, since: datetime | None = None, limit: int = 100,
) -> list[SessionState]:
"""Volatile session history for the explicit no-database demo mode."""
if since is not None and since.tzinfo is None:
since = since.replace(tzinfo=UTC)
states = [
state for state in self._sessions.values()
if not state.lease_fenced
and (owner_login is None or state.owner_login == owner_login)
and (trainee_id is None or state.trainee_id == trainee_id)
and (mode is None or state.mode.value == mode)
and (since is None or (state.started_at is not None and state.started_at >= since))
]
# Hub insertion order is creation order; completed lessons sort by
# their finish time, while unanswered calls retain their start time.
states.sort(
key=lambda state: state.ended_at or state.started_at or datetime.min.replace(tzinfo=UTC),
reverse=True,
)
return states[:max(0, limit)]
def has_active_scenario(self, scenario_id: str) -> bool:
"""Архивирование контента не должно менять уже идущее занятие."""
return any(
not state.ended and (
not state.ended and not state.lease_fenced and (
state.scenario_id == scenario_id
or any(item.id == scenario_id for item in state.dds_scenarios)
)
@ -80,9 +142,100 @@ class SessionHub:
async def checkpoint(self, session_id: UUID) -> None:
"""Зафиксировать подтверждённое состояние, если журнал доступен."""
state = self.get(session_id)
state = self._sessions.get(session_id)
if state is not None and state.lease_fenced:
raise RuntimeError(LEASE_FENCED_MESSAGE)
if state is not None and self.journal is not None:
await self.journal.checkpoint(state)
try:
await self.journal.checkpoint(state)
except Exception:
self._discard_event_batch(session_id)
await self.fence(state)
raise
self._flush_event_batch(session_id)
def begin_event_stream(self, session_id: UUID) -> Token:
"""Stage controller output until each explicit checkpoint in its loop."""
return self._event_batch.set({
"session_id": session_id, "events": [], "committed": False,
"persistent": True, "owner_task": _current_task(),
})
async def end_event_stream(self, token: Token) -> None:
batch = self._event_batch.get()
try:
if batch is not None and batch["events"]:
session_id = batch["session_id"]
batch["events"].clear()
state = self._sessions.get(session_id)
if self.journal is not None and state is not None and not state.lease_fenced:
await self.fence(state)
finally:
self._event_batch.reset(token)
@contextlib.asynccontextmanager
async def durable_transition(self, session_id: UUID):
"""Do not publish state-changing events until its checkpoint commits."""
batch = {
"session_id": session_id, "events": [], "committed": False,
"persistent": False, "owner_task": _current_task(),
}
token: Token = self._event_batch.set(batch)
try:
yield
if not batch["committed"]:
await self.checkpoint(session_id)
else:
self._flush_event_batch(session_id)
except Exception:
self._discard_event_batch(session_id)
state = self._sessions.get(session_id)
if self.journal is not None and state is not None and not state.lease_fenced:
await self.fence(state)
raise
finally:
self._event_batch.reset(token)
def _discard_event_batch(self, session_id: UUID) -> None:
batch = self._event_batch.get()
if batch is not None and batch["session_id"] == session_id:
batch["events"].clear()
def _flush_event_batch(self, session_id: UUID) -> None:
batch = self._event_batch.get()
if batch is None or batch["session_id"] != session_id:
return
pending, batch["events"] = batch["events"], []
batch["committed"] = not batch.get("persistent", False)
for registry, target_session_id, event in pending:
self._put(registry.get(target_session_id, set()), event)
def _send(self, registry: dict[UUID, set[asyncio.Queue]], session_id: UUID,
event: BaseModel) -> None:
batch = self._event_batch.get()
if isinstance(event, ErrorEvent):
self._put(registry.get(session_id, set()), event)
elif (batch is not None and batch["session_id"] == session_id
and batch["owner_task"] is _current_task()):
batch["events"].append((registry, session_id, event))
else:
self._put(registry.get(session_id, set()), event)
async def fence(self, state: SessionState) -> None:
"""Fail closed when durable ownership is lost or cannot be confirmed."""
if state.lease_fenced:
return
state.lease_fenced = True
self.stop_ticker(state.session_id)
if state.voice is not None:
try:
await state.voice.close()
except Exception as exc: # noqa: BLE001 — fencing must still close data channels
log.error("не удалось закрыть голос при fencing занятия %s (%s)",
state.session_id, type(exc).__name__)
event = ErrorEvent(code=ErrorKind.INTERNAL, message=LEASE_FENCED_MESSAGE)
self.broadcast(state.session_id, event)
self.to_station(state.session_id, event)
# ── подписки ──
@ -115,13 +268,16 @@ class SessionHub:
queues.discard(queue)
def to_observers(self, session_id: UUID, event: BaseModel) -> None:
self._put(self._observers.get(session_id, set()), event)
self._send(self._observers, session_id, event)
def to_trainee(self, session_id: UUID, event: BaseModel | bytes) -> None:
self._put(self._trainees.get(session_id, set()), event)
if isinstance(event, BaseModel):
self._send(self._trainees, session_id, event)
else:
self._put(self._trainees.get(session_id, set()), event)
def to_station(self, session_id: UUID, event: BaseModel) -> None:
self._put(self._stations.get(session_id, set()), event)
self._send(self._stations, session_id, event)
def broadcast(self, session_id: UUID, event: BaseModel) -> None:
self.to_trainee(session_id, event)
@ -160,7 +316,7 @@ class SessionHub:
state = self.get(session_id)
if state is None or state.ended:
return
if state.exercise is Exercise.DDS:
if state.exercise is Exercise.DDS or (state.handoff_to_dds and state.dds_scenarios):
from app.session.dds import deliver_due_cards
active_before = state.dds_active_card_id

View file

@ -5,45 +5,99 @@
"""
import logging
from datetime import datetime
from datetime import datetime, timedelta
from uuid import UUID
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.db import repo
from app.db.models import Score, SelfAssessment, Session, User, Utterance
from app.db.models import AuditLog, Score, SelfAssessment, Session, User, Utterance
from app.domain.events import Mood, Speaker, TranscriptEntry
from app.session.checkpoint import dump_state, load_state
from app.session.state import SessionState, now_utc
log = logging.getLogger(__name__)
LEASE_SECONDS = 15
class SessionLeaseLost(RuntimeError):
"""This process no longer owns the durable session generation."""
class DbJournal:
def __init__(self, sessionmaker: async_sessionmaker) -> None:
def __init__(self, sessionmaker: async_sessionmaker, node_id: str | None = None) -> None:
self._sessionmaker = sessionmaker
self._node_id = node_id
self._epochs: dict[UUID, int] = {}
async def _write(self, action, *args, **kwargs) -> None:
async def _fence(self, db, session_id: UUID, expected_epoch: int | None = None) -> None:
"""Renew and fence this write in the same transaction as its mutation."""
if self._node_id is None:
return
epoch = expected_epoch if expected_epoch is not None else self._epochs.get(session_id)
if epoch is None:
raise SessionLeaseLost(f"session {session_id} has no local fencing epoch")
now = now_utc()
result = await db.execute(
update(Session)
.where(
Session.id == session_id,
Session.backend_node_id == self._node_id,
Session.backend_fencing_epoch == epoch,
)
.values(backend_lease_until=now + timedelta(seconds=LEASE_SECONDS))
.returning(Session.id)
)
if result.scalar_one_or_none() is None:
raise SessionLeaseLost(f"session {session_id} owner epoch {epoch} was fenced")
async def _write(
self, action, *args, _fence_session_id: UUID | None = None,
_fence_epoch: int | None = None, _raise_errors: bool = False, **kwargs
) -> None:
"""Ошибка записи не роняет занятие, но и не проглатывается молча:
занятие идёт дальше, в логе остаётся след."""
try:
async with self._sessionmaker() as db:
if _fence_session_id is not None:
await self._fence(db, _fence_session_id, _fence_epoch)
await action(db, *args, **kwargs)
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
log.exception("журнал: запись не удалась")
except SessionLeaseLost:
raise
except Exception as exc: # noqa: BLE001 — журнал не должен ронять живую сессию
session_id = _fence_session_id or kwargs.get("session_id")
log.error("журнал: запись не удалась для сессии %s (%s)",
session_id, type(exc).__name__)
if _raise_errors:
raise
async def start_lesson(
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
trainee_id: UUID | None = None, owner_login: str | None = None,
) -> tuple[int, UUID | None, str | None]:
backend_node_id: str | None = None,
) -> tuple[int, UUID | None, str | None, int] | None:
"""Завести сессию в журнале и вернуть номер попытки и ID курсанта.
Если база недоступна, занятие всё равно идёт: номер попытки
деградирует до первого, и это видно в логе.
Строка сессии и событие аудита фиксируются вместе. При сбое транзакции
занятие не запускается без долговечной истории.
"""
try:
async with self._sessionmaker() as db:
node_id = backend_node_id or self._node_id
def audit_start(transaction, row):
if row.backend_fencing_epoch <= 0:
row.backend_fencing_epoch = 1
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
transaction.add(AuditLog(
actor=owner_login or "system",
role="instructor" if owner_login else "system",
action="lesson.start",
object_id=str(row.id),
detail=f"{scenario_id}, mode {mode}",
))
row = await repo.ensure_session(
db,
session_id=session_id,
@ -52,7 +106,11 @@ class DbJournal:
trainee_name=trainee_name,
trainee_id=trainee_id,
owner_login=owner_login,
backend_node_id=node_id,
before_commit=audit_start,
)
epoch = getattr(row, "backend_fencing_epoch", 0) or 1
self._epochs[session_id] = epoch
service = None
if row.trainee_id is not None:
service = await db.scalar(
@ -60,12 +118,13 @@ class DbJournal:
.where(User.trainee_id == row.trainee_id, User.blocked.is_(False))
.limit(1)
)
return row.attempt, row.trainee_id, service
return row.attempt, row.trainee_id, service, epoch
except PermissionError:
raise
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
log.exception("журнал: сессию завести не удалось")
return 1, trainee_id, None
except Exception as exc: # noqa: BLE001 — журнал не должен ронять живую сессию
log.error("журнал: не удалось завести сессию %s (%s)",
session_id, type(exc).__name__)
return None
async def checkpoint(self, state: SessionState) -> None:
"""Сохранить снимок после подтверждённого действия пользователя."""
@ -78,25 +137,102 @@ class DbJournal:
await db.execute(update(Session).where(Session.id == state.session_id).values(**values))
await db.commit()
await self._write(lambda db: action(db))
if state.backend_fencing_epoch > 0:
self._epochs.setdefault(state.session_id, state.backend_fencing_epoch)
await self._write(
lambda db: action(db), _fence_session_id=state.session_id,
_fence_epoch=state.backend_fencing_epoch or None,
_raise_errors=True,
)
async def restore_active(self) -> list[SessionState]:
async def renew(self, session_id: UUID) -> None:
"""Refresh an owned session lease; concurrent takeover is row-serialized."""
async with self._sessionmaker() as db:
await self._fence(db, session_id)
await db.commit()
async def claim_expired(self, session_id: UUID | None = None) -> list[SessionState]:
"""Atomically fence and restore expired owners on this backend node."""
if self._node_id is None:
return []
now = now_utc()
conditions = [
Session.ended_at.is_(None),
Session.live_state.is_not(None),
Session.checkpoint_at.is_not(None),
(Session.backend_node_id.is_(None) | (Session.backend_node_id != self._node_id)),
(Session.backend_lease_until.is_(None) | (Session.backend_lease_until <= now)),
]
if session_id is not None:
conditions.append(Session.id == session_id)
async with self._sessionmaker() as db:
rows = (await db.scalars(
select(Session).where(*conditions).with_for_update(skip_locked=True).limit(100)
)).all()
for row in rows:
row.backend_node_id = self._node_id
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
row.backend_lease_until = now + timedelta(seconds=LEASE_SECONDS)
if rows:
await db.commit()
if not rows:
return []
return await self.restore_active(bump_owned_epoch=False)
async def restore_active(self, *, bump_owned_epoch: bool = True) -> list[SessionState]:
"""Восстановить только незавершённые сессии с валидным снимком."""
restored: list[SessionState] = []
async with self._sessionmaker() as db:
rows = (await db.scalars(
select(Session).where(
Session.ended_at.is_(None),
Session.live_state.is_not(None),
Session.checkpoint_at.is_not(None),
)
)).all()
active_with_snapshot = (
Session.ended_at.is_(None),
Session.live_state.is_not(None),
Session.checkpoint_at.is_not(None),
)
if self._node_id is not None:
# Adopt legacy unassigned snapshots exactly once. Concurrent
# nodes lock disjoint rows; subsequent restores are owner-only.
unassigned = (await db.scalars(
select(Session)
.where(*active_with_snapshot, Session.backend_node_id.is_(None))
.with_for_update(skip_locked=True)
)).all()
for row in unassigned:
row.backend_node_id = self._node_id
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
if unassigned:
await db.commit()
# A restarted process with the same stable node ID is a new
# owner generation. Bump before exposing any restored state.
owned = (await db.scalars(
select(Session)
.where(*active_with_snapshot, Session.backend_node_id == self._node_id)
.with_for_update(skip_locked=True)
)).all()
if bump_owned_epoch:
for row in owned:
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
if owned:
await db.commit()
rows = (await db.scalars(
select(Session).where(
*active_with_snapshot,
Session.backend_node_id == self._node_id,
)
)).all()
else:
rows = (await db.scalars(
select(Session).where(*active_with_snapshot)
)).all()
for row in rows:
try:
state = load_state(row.live_state, row.checkpoint_at)
if state.session_id != row.id:
raise ValueError("ID снимка не совпадает с записью занятия")
state.owner_login = row.owner_login
state.backend_fencing_epoch = row.backend_fencing_epoch
self._epochs[row.id] = row.backend_fencing_epoch
# Реплики пишутся отдельно сразу после появления. Если
# процесс умер между репликой и общим снимком, отдельный
# журнал не даёт потерять последний фрагмент диалога.
@ -117,8 +253,9 @@ class DbJournal:
for item in utterances
]
restored.append(state)
except Exception: # noqa: BLE001 — один снимок не блокирует весь стенд
log.exception("журнал: снимок занятия %s повреждён", row.id)
except Exception as exc: # noqa: BLE001 — один снимок не блокирует весь стенд
log.error("журнал: снимок занятия %s повреждён (%s)",
row.id, type(exc).__name__)
return restored
async def utterance(self, session_id: UUID, entry) -> None:
@ -130,53 +267,127 @@ class DbJournal:
text=entry.text,
at=entry.at,
mood=entry.mood.value if entry.mood else None,
_fence_session_id=session_id,
)
async def hint(self, session_id: UUID, checklist_id: str, question: str, at: datetime) -> None:
await self._write(
repo.record_hint, session_id=session_id, checklist_id=checklist_id, question=question, at=at
repo.record_hint, _fence_session_id=session_id, session_id=session_id,
checklist_id=checklist_id, question=question, at=at
)
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None:
await self._write(repo.add_note, session_id=session_id, transcript_ref=ref, text=text, author=author)
await self._write(
repo.add_note, _fence_session_id=session_id, session_id=session_id,
transcript_ref=ref, text=text, author=author
)
async def self_assessment(
self, session_id: UUID, missed: list[str], comment: str, at: datetime
) -> None:
async def action(db):
db.add(
SelfAssessment(
) -> bool:
"""Persist trainee reflection and its security audit together."""
try:
async with self._sessionmaker() as db:
session = await db.get(Session, session_id)
if session is None:
return False
actor = "system"
role = "system"
if session.trainee_id is not None:
login = await db.scalar(
select(User.login).where(User.trainee_id == session.trainee_id)
)
if login:
actor, role = login, "trainee"
else:
actor, role = f"trainee:{session.trainee_id}", "trainee"
db.add(SelfAssessment(
session_id=session_id, missed=missed, comment=comment, submitted_at=at
)
)
await db.commit()
))
db.add(AuditLog(
actor=actor,
role=role,
action="self_assessment.submit",
object_id=str(session_id),
detail=f"missed_count={len(missed)}; comment_chars={len(comment)}",
))
await self._fence(db, session_id)
await db.commit()
return True
except SessionLeaseLost:
raise
except Exception as exc: # noqa: BLE001 — do not accept an unaudited reflection
log.error("самооценка и аудит сессии %s не сохранены (%s)",
session_id, type(exc).__name__)
return False
await self._write(lambda db: action(db))
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None:
async def action(db):
db.add(Score(session_id=session_id, score_auto=score_auto, score_final=score_auto, report=report))
await db.commit()
await self._write(lambda db: action(db))
async def score(self, session_id: UUID, score_auto: float, report: dict) -> bool:
"""Persist the initial result and its audit event atomically."""
try:
async with self._sessionmaker() as db:
await self._fence(db, session_id)
db.add(Score(
session_id=session_id, score_auto=score_auto,
score_final=score_auto, report=report,
))
db.add(AuditLog(
actor="system", role="system", action="score.calculate",
object_id=str(session_id), detail=f"score_auto={score_auto}",
))
await db.commit()
return True
except SessionLeaseLost:
raise
except Exception as exc: # noqa: BLE001 — result is not complete until durable
log.error("итоговая оценка и аудит сессии %s не сохранены (%s)",
session_id, type(exc).__name__)
return False
async def score_override(
self, session_id: UUID, score_final: float, author: str, comment: str
) -> None:
"""Сохранить решение преподавателя рядом с неизменной автооценкой."""
async def action(db):
await db.execute(
update(Score)
.where(Score.session_id == session_id)
.values(
score_final=score_final,
overridden_by=author,
override_comment=comment,
) -> bool:
"""Persist a live correction and its security audit as one transaction."""
try:
async with self._sessionmaker() as db:
await self._fence(db, session_id)
score = await db.scalar(
select(Score)
.where(Score.session_id == session_id)
.with_for_update()
)
)
await db.commit()
await self._write(lambda db: action(db))
if score is None:
return False
score.score_final = score_final
score.overridden_by = author
score.override_comment = comment
report = dict(score.report or {})
archived = report.get("full_report")
if isinstance(archived, dict):
archived = dict(archived)
archived.update({
"score_auto": score.score_auto,
"score_final": score_final,
"overridden_by": author,
"override_comment": comment,
})
report["full_report"] = archived
score.report = report
db.add(AuditLog(
actor=author,
role="instructor",
action="score.override",
object_id=str(session_id),
detail=(f"{score.score_auto} → {score_final}; "
f"comment_chars={len(comment)}"),
))
await db.commit()
return True
except SessionLeaseLost:
raise
except Exception as exc: # noqa: BLE001 — do not confirm a correction without its audit
log.error("корректировка оценки и аудит сессии %s не сохранены (%s)",
session_id, type(exc).__name__)
return False
async def score_snapshot(self, session_id: UUID, report: dict) -> None:
"""Обновить полный архивный разбор после самооценки курсанта."""
@ -186,14 +397,14 @@ class DbJournal:
)
await db.commit()
await self._write(lambda db: action(db))
await self._write(lambda db: action(db), _fence_session_id=session_id)
async def session_started(self, session_id: UUID, at: datetime) -> None:
async def action(db):
await db.execute(update(Session).where(Session.id == session_id).values(started_at=at))
await db.commit()
await self._write(lambda db: action(db))
await self._write(lambda db: action(db), _fence_session_id=session_id)
async def session_ended(self, session_id: UUID, at: datetime, reason: str) -> None:
async def action(db):
@ -209,4 +420,4 @@ class DbJournal:
)
await db.commit()
await self._write(lambda db: action(db))
await self._write(lambda db: action(db), _fence_session_id=session_id)

View file

@ -27,8 +27,8 @@ from app.domain.events import (
)
from app.domain.kio import KIO, ResponseStatus, apply_patch
from app.domain.statuses import (
CardStatus,
NEXT,
CardStatus,
DdsCardSummary,
DdsQueueCard,
PhoneCallPending,
@ -62,11 +62,20 @@ class DdsCardRecord:
findings: list[Finding]
actions: list[dict[str, Any]]
duration_ms: int
title: str | None = None
address: str | None = None
description: str | None = None
incident_type: str | None = None
victims_count: int | None = None
received_at: datetime | None = None
managed_service: str | None = None
recipient_services: list[str] = field(default_factory=list)
@property
def score_auto(self) -> float:
total = sum(metric.weight for metric in self.metrics)
passed = sum(metric.weight for metric in self.metrics if metric.passed)
passed = sum(metric.weight * (metric.credit if metric.credit is not None
else float(metric.passed)) for metric in self.metrics)
return round(100 * passed / total, 1) if total else 0.0
@ -106,6 +115,10 @@ class SessionState:
mode: SessionMode
#: Преподаватель, создавший занятие; чужой пульт не может им управлять.
owner_login: str | None = None
#: Monotonic DB ownership generation; stale processes may not persist writes.
backend_fencing_epoch: int = 0
#: Runtime-only: set when this process loses or cannot confirm DB ownership.
lease_fenced: bool = False
exercise: Exercise = Exercise.CALL
#: После заполнения КИО занятие продолжится на АРМ ДДС, а не завершится.
handoff_to_dds: bool = False
@ -169,6 +182,11 @@ class SessionState:
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
phone_pending: PhoneCallPending | None = None
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
dds_live_cards: list[DdsLiveCard] = field(default_factory=list)
dds_active_card_id: UUID | None = None
dds_card_index: int = 0
@ -183,6 +201,10 @@ class SessionState:
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
resolved_outcome: str | None = None
resolve_comment: str = ""
#: Recently committed DDS command IDs; included in the durable checkpoint so
#: a lost WebSocket acknowledgement cannot apply an operation twice.
processed_station_commands: list[str] = field(default_factory=list)
text_revealed_facts: dict[str, str] = field(default_factory=dict)
def on_event(self, event_type: str) -> None:
"""Единственная точка, где событие двигает таймеры."""
@ -391,6 +413,11 @@ class SessionState:
log=list(self.status_log),
crew_options=(self.crew_options() if has_active_dds_card or not self.dds_scenarios else []),
crew_selected=self.crew_selected if has_active_dds_card else None,
zone_decision=(
next((detail == "в зоне" for action, _at, detail in reversed(self.dds_log)
if action == "zone.decision"), None)
if has_active_dds_card else None
),
phone_reports=list(self.phone_reports) if has_active_dds_card else [],
phone_lines=list(self.phone_lines) if has_active_dds_card else [],
phone_pending=self.phone_pending if has_active_dds_card else None,
@ -422,6 +449,7 @@ class SessionState:
return CardReceived(
card=self.dispatched_card,
from_operator=("учебный сценарий" if self.exercise is Exercise.DDS
or (self.handoff_to_dds and self.dds_card_index > 0)
else self.trainee_name or "оператор 112"),
at=self.dispatched_at or now_utc(),
card_index=self.dds_card_index + 1,

View file

@ -25,6 +25,8 @@ STARTS: dict[str, tuple[TimerCode, ...]] = {
"call.incoming": (TimerCode.ANSWER,),
"call.answer": (TimerCode.INTERVIEW,),
"dds.dispatch": (TimerCode.DDS_ACK, TimerCode.CLOSE),
"dds.open": (TimerCode.DDS_WORK,),
"card.start": (TimerCode.CARD_FILL,),
"card.received": (TimerCode.ZONE_CHECK,),
"call.dropped": (TimerCode.CALLBACK,),
"callback.dial": (TimerCode.CALLBACK,),
@ -38,6 +40,10 @@ STOPS: dict[str, tuple[TimerCode, ...]] = {
# карточки: норматив опроса не должен тикать после решения (lct-36).
"call.resolve": (TimerCode.INTERVIEW,),
"card.ack": (TimerCode.DDS_ACK,),
"card.submit": (TimerCode.CARD_FILL,),
"card.end": (TimerCode.CARD_FILL,),
"dds.complete": (TimerCode.DDS_WORK,),
"dds.finish": (TimerCode.DDS_WORK,),
"zone.decision": (TimerCode.ZONE_CHECK,),
"crew.arrived": (TimerCode.CLOSE,),
"call.started": (TimerCode.CALLBACK,),