2026-09-17 21:24:34 +03:00
|
|
|
|
"""Завершение занятия: посчитать оценку и положить её в журнал.
|
|
|
|
|
|
|
|
|
|
|
|
Детерминированный слой считается сразу по завершении звонка (lct-12).
|
|
|
|
|
|
Курсанту `score.ready` уходит **только после самооценки**: сначала он сверяет
|
|
|
|
|
|
своё ощущение с объективной картиной, и расхождение — отдельный материал
|
|
|
|
|
|
для преподавателя (docs/product/DEBRIEF.md). Преподаватель и монитор получают
|
|
|
|
|
|
событие сразу: им ждать нечего.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
import asyncio
|
2026-09-17 21:24:34 +03:00
|
|
|
|
import logging
|
2026-09-26 18:12:27 +03:00
|
|
|
|
import time
|
2026-09-17 21:24:34 +03:00
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
2026-09-27 18:06:49 +03:00
|
|
|
|
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded, SessionPaused
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from app.domain.statuses import ServiceStatus, current
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.domain.timers import TimerCode
|
2026-09-26 17:12:29 +00:00
|
|
|
|
from app.scoring.ai_coach import coach
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
2026-09-27 08:06:05 +00:00
|
|
|
|
from app.scoring.gost import GostResult
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from app.scoring.grammar import assess
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.scoring.report import build as build_report
|
2026-09-27 08:06:05 +00:00
|
|
|
|
from app.scoring.timing import DDS_WORK_TIME, time_metric
|
2026-09-21 19:56:13 +03:00
|
|
|
|
from app.scoring.weights import apply_weights
|
2026-09-17 21:24:34 +03:00
|
|
|
|
from app.session.hub import hub
|
2026-09-27 08:06:05 +00:00
|
|
|
|
from app.session.score import ScoringSnapshot, score_session, scoring_scenario
|
2026-09-26 21:38:09 +00:00
|
|
|
|
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
|
2026-09-27 07:40:31 +00:00
|
|
|
|
from app.session.store import (
|
2026-09-27 16:13:21 +03:00
|
|
|
|
FindingAdded,
|
|
|
|
|
|
FindingReviewed,
|
2026-09-27 07:40:31 +00:00
|
|
|
|
LessonEnded,
|
2026-09-27 18:06:49 +03:00
|
|
|
|
LessonResumed,
|
2026-09-27 07:40:31 +00:00
|
|
|
|
ScoreArchived,
|
|
|
|
|
|
ScoreCalculated,
|
|
|
|
|
|
ScoreOverridden,
|
2026-09-27 16:13:21 +03:00
|
|
|
|
apply_finding_change,
|
2026-09-27 07:40:31 +00:00
|
|
|
|
apply_score_override,
|
|
|
|
|
|
)
|
2026-09-17 21:24:34 +03:00
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 21:38:09 +00:00
|
|
|
|
def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
|
|
|
|
|
"""Оценить одну карточку пульта; активная карточка при этом не меняется.
|
|
|
|
|
|
|
|
|
|
|
|
Из сессии берутся только служба обучающегося и критерии занятия.
|
|
|
|
|
|
"""
|
|
|
|
|
|
number = card.original_index + 1
|
|
|
|
|
|
services = state.card_services(card)
|
|
|
|
|
|
decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK]
|
|
|
|
|
|
dds_decision = card.scenario.dds_decision
|
2026-09-27 16:26:10 +03:00
|
|
|
|
reaction_ms = state.criteria.dds_report_reaction_limit_seconds * 1000
|
2026-09-21 19:56:13 +03:00
|
|
|
|
findings = evaluate_dispatcher(
|
2026-09-26 21:38:09 +00:00
|
|
|
|
entries=card.status_log,
|
|
|
|
|
|
services=services,
|
|
|
|
|
|
crew_assignments=card.crew_assignments,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
deadline_ms=decision_limit_ms,
|
2026-09-26 21:38:09 +00:00
|
|
|
|
elapsed_ms=card.timers.measured_ms(TimerCode.DDS_ACK),
|
|
|
|
|
|
reply_text=card.reply_text,
|
2026-09-26 18:12:27 +03:00
|
|
|
|
expected_decision=dds_decision.expected,
|
|
|
|
|
|
expected_decision_reason=dds_decision.reason,
|
2026-09-27 16:26:10 +03:00
|
|
|
|
phone_reports=card.phone_reports,
|
|
|
|
|
|
report_reaction_ms=reaction_ms,
|
2026-09-26 18:12:27 +03:00
|
|
|
|
)
|
|
|
|
|
|
metrics = dispatcher_metrics(
|
2026-09-26 21:38:09 +00:00
|
|
|
|
card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
|
2026-09-27 16:26:10 +03:00
|
|
|
|
services=services, report_reaction_ms=reaction_ms,
|
2026-09-21 19:56:13 +03:00
|
|
|
|
)
|
|
|
|
|
|
weighted = GostResult(metrics=metrics, findings=findings)
|
2026-09-26 21:38:09 +00:00
|
|
|
|
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]
|
|
|
|
|
|
work_timer = card.timers.timers.get(TimerCode.DDS_WORK)
|
|
|
|
|
|
work_elapsed_ms = card.timers.measured_ms(TimerCode.DDS_WORK)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
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())
|
2026-09-26 21:38:09 +00:00
|
|
|
|
terminal = bool(services) and all(
|
|
|
|
|
|
current(card.status_log, service) in {
|
2026-09-26 18:12:27 +03:00
|
|
|
|
ServiceStatus.COMPLETED,
|
|
|
|
|
|
ServiceStatus.DECLINED,
|
|
|
|
|
|
ServiceStatus.REFUSED,
|
|
|
|
|
|
}
|
2026-09-26 21:38:09 +00:00
|
|
|
|
for service in services
|
2026-09-26 18:12:27 +03:00
|
|
|
|
)
|
2026-09-27 08:06:05 +00:00
|
|
|
|
work_metric, work_finding = time_metric(
|
|
|
|
|
|
DDS_WORK_TIME, work_elapsed_ms, work_limit_ms, completed=terminal
|
|
|
|
|
|
)
|
|
|
|
|
|
weighted.metrics.append(work_metric)
|
|
|
|
|
|
if work_finding is not None:
|
|
|
|
|
|
weighted.findings.append(work_finding)
|
2026-09-26 21:38:09 +00:00
|
|
|
|
apply_weights(weighted, {**card.scenario.score_weights, **state.criteria.score_weights})
|
2026-09-21 19:56:13 +03:00
|
|
|
|
actions = [
|
|
|
|
|
|
{"type": "card.status", "service": mark.service, "status": mark.status.value,
|
|
|
|
|
|
"comment": mark.comment, "at": mark.at.isoformat()}
|
2026-09-26 21:38:09 +00:00
|
|
|
|
for mark in card.status_log
|
2026-09-21 19:56:13 +03:00
|
|
|
|
] + [
|
|
|
|
|
|
{"type": "phone.report", "service": report.service, "crew": report.crew,
|
|
|
|
|
|
"phase": report.phase, "text": report.text, "at": report.at.isoformat()}
|
2026-09-26 21:38:09 +00:00
|
|
|
|
for report in card.phone_reports
|
2026-09-21 19:56:13 +03:00
|
|
|
|
] + [
|
|
|
|
|
|
{"type": "card.reply", "text": text, "at": at.isoformat()}
|
2026-09-26 21:38:09 +00:00
|
|
|
|
for at, text in card.reply_log
|
2026-09-21 19:56:13 +03:00
|
|
|
|
] + [
|
|
|
|
|
|
{"type": kind, "text": text or "", "at": at.isoformat()}
|
2026-09-26 21:38:09 +00:00
|
|
|
|
for kind, at, text in card.dds_log
|
2026-09-21 19:56:13 +03:00
|
|
|
|
]
|
|
|
|
|
|
actions.sort(key=lambda item: item["at"])
|
|
|
|
|
|
return DdsCardRecord(
|
2026-09-26 21:38:09 +00:00
|
|
|
|
card_id=card.card_id,
|
|
|
|
|
|
scenario_id=card.scenario.id,
|
|
|
|
|
|
reply_text=card.reply_text,
|
2026-09-27 16:13:21 +03:00
|
|
|
|
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}",
|
|
|
|
|
|
"card": number})
|
2026-09-21 19:56:13 +03:00
|
|
|
|
for metric in weighted.metrics],
|
2026-09-27 16:13:21 +03:00
|
|
|
|
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}",
|
|
|
|
|
|
"card": number})
|
2026-09-21 19:56:13 +03:00
|
|
|
|
for finding in weighted.findings],
|
|
|
|
|
|
actions=actions,
|
2026-09-26 21:38:09 +00:00
|
|
|
|
duration_ms=max(0, int((now_utc() - card.dispatched_at).total_seconds() * 1000)),
|
|
|
|
|
|
title=card.scenario.title,
|
|
|
|
|
|
address=card.dispatched_card.address,
|
|
|
|
|
|
description=card.dispatched_card.description,
|
|
|
|
|
|
incident_type=(card.dispatched_card.incident_type.value
|
|
|
|
|
|
if card.dispatched_card.incident_type else None),
|
|
|
|
|
|
victims_count=card.dispatched_card.victims_count,
|
|
|
|
|
|
received_at=card.dispatched_at,
|
|
|
|
|
|
managed_service=services[0] if services else None,
|
|
|
|
|
|
recipient_services=list(card.dispatched_card.notify),
|
2026-09-21 19:56:13 +03:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 21:12:00 +00:00
|
|
|
|
def score_live_dds_cards(state) -> list[DdsCardRecord]:
|
2026-09-26 21:38:09 +00:00
|
|
|
|
"""Все карточки занятия: завершённые и оценка ещё открытых.
|
2026-09-26 21:12:00 +00:00
|
|
|
|
|
2026-09-26 21:38:09 +00:00
|
|
|
|
Открытые карточки оцениваются по месту, пульт не переключается: отчёт
|
|
|
|
|
|
берёт card_status у карточки, активной у курсанта при завершении.
|
2026-09-26 21:12:00 +00:00
|
|
|
|
"""
|
2026-09-26 21:38:09 +00:00
|
|
|
|
desk = state.desk
|
|
|
|
|
|
cards = list(desk.completed)
|
|
|
|
|
|
for live in desk.ordered():
|
|
|
|
|
|
if not desk.is_completed(live.card_id):
|
|
|
|
|
|
cards.append(score_dds_card(state, live))
|
|
|
|
|
|
desk.completed = cards
|
2026-09-26 21:12:00 +00:00
|
|
|
|
return cards
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 21:24:34 +03:00
|
|
|
|
async def finish(session_id: UUID, state) -> None:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if state.recorder is not None:
|
|
|
|
|
|
path = await asyncio.to_thread(state.recorder.finalize)
|
|
|
|
|
|
state.recording_path = str(path) if path else None
|
2026-09-27 08:06:05 +00:00
|
|
|
|
# Все карточки выданы одновременно: при досрочном завершении оцениваем
|
|
|
|
|
|
# каждую, включая не открытую, потому что её норматив уже шёл.
|
|
|
|
|
|
if state.exercise is Exercise.DDS or (state.handoff_to_dds and state.desk.scenarios):
|
|
|
|
|
|
score_live_dds_cards(state)
|
2026-09-17 21:38:43 +03:00
|
|
|
|
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
|
2026-09-27 08:06:05 +00:00
|
|
|
|
snapshot = ScoringSnapshot.of(state)
|
|
|
|
|
|
if snapshot is None:
|
2026-09-17 21:24:34 +03:00
|
|
|
|
return
|
2026-09-27 08:06:05 +00:00
|
|
|
|
report = await score_session(snapshot, grammar=assess)
|
|
|
|
|
|
state.score = report.payload(card_status=state.station_snapshot().card.value)
|
2026-09-26 17:12:29 +00:00
|
|
|
|
# Модель только поясняет уже посчитанные провалы и балл не трогает; без
|
|
|
|
|
|
# запущенной модели разбор выходит со статусом «недоступно», а не ждёт её.
|
2026-09-27 08:06:05 +00:00
|
|
|
|
state.score["ai_coaching"] = (await coach(report.result.metrics)).model_dump(mode="json")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
# Полный разбор хранится вместе с оценкой: PDF/CSV и история должны
|
2026-09-26 23:17:04 +00:00
|
|
|
|
# переживать перезапуск backend, а не зависеть от живого объекта в реестре хаба.
|
2026-09-27 08:06:05 +00:00
|
|
|
|
state.score["full_report"] = build_report(
|
|
|
|
|
|
session_id, state, snapshot.scenario
|
|
|
|
|
|
).model_dump(mode="json")
|
|
|
|
|
|
log.info("сессия %s: оценка %.1f, отметок %d",
|
|
|
|
|
|
session_id, report.result.score, len(report.result.findings))
|
2026-09-17 21:24:34 +03:00
|
|
|
|
|
2026-09-26 22:48:24 +00:00
|
|
|
|
# Оценка и аудит уходят в commit операции, завершившей занятие; ScoreReady
|
2026-09-27 07:40:31 +00:00
|
|
|
|
# рассылает `end_session` и ждёт того же commit — без записи итог не выдаётся.
|
2026-09-27 08:06:05 +00:00
|
|
|
|
hub.record(session_id, ScoreCalculated(report.result.score, state.score))
|
2026-09-27 07:40:31 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ends_on_station(state) -> bool:
|
|
|
|
|
|
"""Итог занятия видит пульт ДДС: упражнение ДДС или связка, где карточка
|
|
|
|
|
|
уже передана диспетчеру. Иначе конец звонка узнаёт курсант 112."""
|
|
|
|
|
|
return state.exercise is Exercise.DDS or (
|
|
|
|
|
|
state.handoff_to_dds and state.dispatched_card is not None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
|
|
|
|
|
|
"""Единственный переход занятия в «завершено» — для станции, пульта и звонка.
|
|
|
|
|
|
|
|
|
|
|
|
Вызывается внутри уже открытой `hub.operation`: строки журнала и события
|
|
|
|
|
|
уходят её commit, сверка `(node_id, epoch)` остаётся за вызывающим.
|
|
|
|
|
|
Порядок один для всех причин: таймеры фиксируются событием до оценки,
|
|
|
|
|
|
журнал пишется до оценки, рассылка — после, `ScoreReady` — последним.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if state.ended:
|
|
|
|
|
|
return
|
2026-09-27 18:06:49 +03:00
|
|
|
|
was_paused = state.paused
|
|
|
|
|
|
paused_ms_before = state.total_paused_ms
|
2026-09-27 07:40:31 +00:00
|
|
|
|
ended_at = state.end(reason)
|
2026-09-27 18:06:49 +03:00
|
|
|
|
if was_paused:
|
|
|
|
|
|
hub.record(session_id, LessonResumed(
|
|
|
|
|
|
at=ended_at, author="система", role="system",
|
|
|
|
|
|
paused_ms=state.total_paused_ms - paused_ms_before,
|
|
|
|
|
|
))
|
2026-09-27 07:40:31 +00:00
|
|
|
|
if state.exercise is Exercise.CARD and state.dispatched_card is None:
|
|
|
|
|
|
state.on_event("card.end")
|
|
|
|
|
|
# Норматив отработки фиксируется событием, а не текущим значением часов.
|
|
|
|
|
|
state.desk.finish_work()
|
|
|
|
|
|
|
|
|
|
|
|
if state.voice is not None:
|
|
|
|
|
|
await state.voice.close()
|
|
|
|
|
|
hub.stop_ticker(session_id)
|
|
|
|
|
|
|
|
|
|
|
|
hub.record(session_id, LessonEnded(ended_at, reason.value))
|
|
|
|
|
|
await finish(session_id, state)
|
|
|
|
|
|
|
|
|
|
|
|
hub.to_observers(session_id, SessionEnded(reason=reason))
|
|
|
|
|
|
on_station = _ends_on_station(state)
|
|
|
|
|
|
if on_station:
|
|
|
|
|
|
hub.to_station(session_id, SessionEnded(reason=reason))
|
|
|
|
|
|
else:
|
|
|
|
|
|
hub.to_trainee(session_id, CallEnded(reason=reason))
|
2026-09-27 22:23:12 +03:00
|
|
|
|
if was_paused:
|
|
|
|
|
|
# Снятие паузы — после сообщения о конце: иначе клиент дошлёт отложенные
|
|
|
|
|
|
# на паузе правки карточки в уже закрытое занятие.
|
|
|
|
|
|
hub.broadcast(session_id, SessionPaused(paused=False))
|
|
|
|
|
|
hub.to_station(session_id, SessionPaused(paused=False))
|
2026-09-27 07:40:31 +00:00
|
|
|
|
if state.score is None:
|
|
|
|
|
|
return
|
2026-09-17 21:24:34 +03:00
|
|
|
|
hub.to_observers(session_id, ScoreReady(session_id=session_id))
|
|
|
|
|
|
await release_score(session_id, state)
|
2026-09-27 07:40:31 +00:00
|
|
|
|
if on_station:
|
|
|
|
|
|
hub.to_station(session_id, ScoreReady(session_id=session_id))
|
2026-09-17 21:24:34 +03:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
async def refresh_archived_report(session_id: UUID, state) -> None:
|
|
|
|
|
|
"""Дополнить сохранённый разбор самооценкой, пришедшей после hangup."""
|
|
|
|
|
|
if state.score is None:
|
|
|
|
|
|
return
|
2026-09-26 19:02:24 +00:00
|
|
|
|
scenario = scoring_scenario(state)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if scenario is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
2026-09-26 22:48:24 +00:00
|
|
|
|
hub.record(session_id, ScoreArchived(state.score))
|
2026-09-24 01:10:49 +03:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 21:24:34 +03:00
|
|
|
|
async def release_score(session_id: UUID, state) -> None:
|
|
|
|
|
|
"""Отдать оценку курсанту, когда самооценка сдана."""
|
2026-09-21 17:40:54 +03:00
|
|
|
|
if state.score is not None and (state.self_assessed or state.exercise is Exercise.CARD):
|
2026-09-17 21:24:34 +03:00
|
|
|
|
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
|
2026-09-26 22:48:24 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def override_score(state, override: ScoreOverridden) -> None:
|
|
|
|
|
|
"""Правка балла преподавателем — одна операция для HTTP и WS.
|
|
|
|
|
|
|
|
|
|
|
|
Вызывается внутри `hub.operation`: строка `Score`, архивный разбор и аудит
|
|
|
|
|
|
пишутся тем же commit, что и снимок с исправленной оценкой.
|
|
|
|
|
|
"""
|
|
|
|
|
|
hub.record(state.session_id, override)
|
|
|
|
|
|
state.score = apply_score_override(state.score, override)
|
|
|
|
|
|
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))
|
2026-09-27 16:13:21 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def change_findings(state, change: FindingReviewed | FindingAdded) -> None:
|
|
|
|
|
|
"""Решение преподавателя по отметке — тем же путём, что правка итога.
|
|
|
|
|
|
|
|
|
|
|
|
Вызывается внутри `hub.operation`: отметки, пересчитанный балл, архивный
|
|
|
|
|
|
разбор и строка аудита уходят одним commit.
|
|
|
|
|
|
"""
|
|
|
|
|
|
state.score = apply_finding_change(state.score, change)
|
|
|
|
|
|
hub.record(state.session_id, change)
|
|
|
|
|
|
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))
|