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-26 18:12:27 +03:00
|
|
|
|
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
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.domain.timers import TimerCode
|
2026-09-17 21:24:34 +03:00
|
|
|
|
from app.scenarios import store
|
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.card import evaluate_card
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.scoring.competency import radar
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
|
|
|
|
|
from app.scoring.gost import GostResult, evaluate
|
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-26 18:12:27 +03:00
|
|
|
|
from app.scoring.taxonomy import METRIC_WEIGHTS
|
|
|
|
|
|
from app.scoring.timing import time_credit
|
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-21 19:56:13 +03:00
|
|
|
|
from app.session.state import DdsCardRecord, now_utc
|
2026-09-17 21:24:34 +03:00
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 19:56:13 +03:00
|
|
|
|
def score_current_dds(state) -> DdsCardRecord:
|
|
|
|
|
|
"""Оценить активную карточку отдельно, до выдачи следующей."""
|
|
|
|
|
|
number = state.dds_card_index + 1
|
2026-09-24 01:10:49 +03:00
|
|
|
|
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
|
2026-09-26 18:12:27 +03:00
|
|
|
|
dds_decision = state.scenario.dds_decision
|
2026-09-21 19:56:13 +03:00
|
|
|
|
findings = evaluate_dispatcher(
|
|
|
|
|
|
entries=state.status_log,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
services=state.managed_services(),
|
2026-09-26 18:12:27 +03:00
|
|
|
|
crew_assignments=state.crew_assignments,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
deadline_ms=decision_limit_ms,
|
2026-09-21 19:56:13 +03:00
|
|
|
|
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
2026-09-26 18:12:27 +03:00
|
|
|
|
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
|
2026-09-21 19:56:13 +03:00
|
|
|
|
)
|
|
|
|
|
|
weighted = GostResult(metrics=metrics, findings=findings)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
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,
|
|
|
|
|
|
))
|
2026-09-24 01:10:49 +03:00
|
|
|
|
apply_weights(weighted, {**state.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()}
|
|
|
|
|
|
for mark in state.status_log
|
|
|
|
|
|
] + [
|
|
|
|
|
|
{"type": "phone.report", "service": report.service, "crew": report.crew,
|
|
|
|
|
|
"phase": report.phase, "text": report.text, "at": report.at.isoformat()}
|
|
|
|
|
|
for report in state.phone_reports
|
|
|
|
|
|
] + [
|
|
|
|
|
|
{"type": "card.reply", "text": text, "at": at.isoformat()}
|
|
|
|
|
|
for at, text in state.reply_log
|
|
|
|
|
|
] + [
|
|
|
|
|
|
{"type": kind, "text": text or "", "at": at.isoformat()}
|
|
|
|
|
|
for kind, at, text in state.dds_log
|
|
|
|
|
|
]
|
|
|
|
|
|
actions.sort(key=lambda item: item["at"])
|
|
|
|
|
|
return DdsCardRecord(
|
|
|
|
|
|
card_id=state.dispatched_card.card_id,
|
|
|
|
|
|
scenario_id=state.scenario_id,
|
|
|
|
|
|
reply_text=state.reply_text,
|
|
|
|
|
|
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}"})
|
|
|
|
|
|
for metric in weighted.metrics],
|
|
|
|
|
|
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"})
|
|
|
|
|
|
for finding in weighted.findings],
|
|
|
|
|
|
actions=actions,
|
|
|
|
|
|
duration_ms=(max(0, int((now_utc() - state.dispatched_at).total_seconds() * 1000))
|
|
|
|
|
|
if state.dispatched_at else 0),
|
2026-09-26 18:12:27 +03:00
|
|
|
|
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),
|
2026-09-21 19:56:13 +03:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
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-17 21:38:43 +03:00
|
|
|
|
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
|
2026-09-26 18:12:27 +03:00
|
|
|
|
scenario = (state.operator_scenario if state.handoff_to_dds and state.operator_scenario
|
|
|
|
|
|
else state.scenario or store.get(state.scenario_id))
|
2026-09-17 21:24:34 +03:00
|
|
|
|
if scenario is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
cards: list[DdsCardRecord] = []
|
2026-09-21 17:40:54 +03:00
|
|
|
|
if state.exercise is Exercise.CARD:
|
2026-09-26 18:12:27 +03:00
|
|
|
|
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,
|
|
|
|
|
|
))
|
2026-09-21 17:40:54 +03:00
|
|
|
|
elif state.exercise is Exercise.DDS:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
# Все карточки выданы одновременно: при досрочном завершении оцениваем
|
|
|
|
|
|
# каждую, включая не открытую, потому что её норматив уже шёл.
|
2026-09-21 17:40:54 +03:00
|
|
|
|
result = GostResult()
|
2026-09-21 19:56:13 +03:00
|
|
|
|
cards = list(state.dds_completed)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
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)
|
2026-09-21 19:56:13 +03:00
|
|
|
|
cards.append(score_current_dds(state))
|
|
|
|
|
|
state.dds_completed = cards
|
|
|
|
|
|
for card in cards:
|
|
|
|
|
|
result.metrics.extend(card.metrics)
|
|
|
|
|
|
result.findings.extend(card.findings)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
else:
|
|
|
|
|
|
result = evaluate(
|
|
|
|
|
|
scenario=scenario,
|
|
|
|
|
|
kio=state.kio,
|
|
|
|
|
|
timers=state.timers,
|
|
|
|
|
|
revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None,
|
|
|
|
|
|
refined_facts=list(state.slots.refined) if state.slots else None,
|
|
|
|
|
|
end_reason=state.end_reason,
|
|
|
|
|
|
bounced_fields=state.bounced_fields,
|
|
|
|
|
|
resolved_outcome=state.resolved_outcome,
|
|
|
|
|
|
dispatched=state.dispatched_card is not None,
|
|
|
|
|
|
)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
# Грамматика относится к свободному описанию оператора 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,
|
|
|
|
|
|
))
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
|
|
|
|
|
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
|
|
|
|
|
# участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
|
2026-09-26 18:12:27 +03:00
|
|
|
|
if state.dispatched_card is not None and state.exercise is Exercise.CALL:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
|
2026-09-21 17:40:54 +03:00
|
|
|
|
dispatcher_findings = evaluate_dispatcher(
|
|
|
|
|
|
entries=state.status_log,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
services=state.managed_services(),
|
2026-09-26 18:12:27 +03:00
|
|
|
|
crew_assignments=state.crew_assignments,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
deadline_ms=decision_limit_ms,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
2026-09-26 18:12:27 +03:00
|
|
|
|
reply_text=state.reply_text,
|
|
|
|
|
|
expected_decision=scenario.dds_decision.expected,
|
|
|
|
|
|
expected_decision_reason=scenario.dds_decision.reason,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
)
|
|
|
|
|
|
result.findings.extend(dispatcher_findings)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
result.metrics.extend(dispatcher_metrics(
|
|
|
|
|
|
state, decision_limit_ms, scenario.dds_decision.expected,
|
|
|
|
|
|
scenario.dds_decision.reason,
|
|
|
|
|
|
))
|
2026-09-24 01:10:49 +03:00
|
|
|
|
# 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.
|
2026-09-21 19:56:13 +03:00
|
|
|
|
if state.exercise is not Exercise.DDS:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
apply_weights(result, {**scenario.score_weights, **state.criteria.score_weights})
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-09-17 21:42:12 +03:00
|
|
|
|
# Сводка числами: по ней считается дельта между попытками в профиле.
|
|
|
|
|
|
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
|
|
|
|
|
|
required = scenario.ground_truth.required_facts
|
|
|
|
|
|
revealed = [fact.id for fact in state.slots.revealed_facts()] if state.slots else []
|
|
|
|
|
|
codes: dict[str, int] = {}
|
|
|
|
|
|
for finding in result.findings:
|
|
|
|
|
|
codes[finding.code.value] = codes.get(finding.code.value, 0) + 1
|
|
|
|
|
|
|
2026-09-17 21:24:34 +03:00
|
|
|
|
state.score = {
|
|
|
|
|
|
"score_auto": result.score,
|
2026-09-17 21:42:12 +03:00
|
|
|
|
"summary": {
|
|
|
|
|
|
"interview_ms": state.timers.measured_ms(TimerCode.INTERVIEW),
|
2026-09-26 18:12:27 +03:00
|
|
|
|
"card_fill_ms": state.timers.measured_ms(TimerCode.CARD_FILL),
|
2026-09-17 21:42:12 +03:00
|
|
|
|
"facts_got": len([fact for fact in required if fact in revealed]),
|
|
|
|
|
|
"facts_required": len(required),
|
|
|
|
|
|
"hints": len(state.hints_shown),
|
|
|
|
|
|
"codes": codes,
|
|
|
|
|
|
},
|
2026-09-17 21:24:34 +03:00
|
|
|
|
"metrics": [metric.model_dump() for metric in result.metrics],
|
|
|
|
|
|
"findings": [finding.model_dump(mode="json") for finding in result.findings],
|
|
|
|
|
|
"competencies": [item.model_dump() for item in radar(result.metrics)],
|
|
|
|
|
|
"unavailable": result.unavailable,
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
# Статус карточки — готовая красная метка, понятная любому диспетчеру.
|
|
|
|
|
|
"card_status": state.station_snapshot().card.value,
|
2026-09-21 19:56:13 +03:00
|
|
|
|
"card_results": [
|
|
|
|
|
|
{"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,
|
2026-09-26 18:12:27 +03:00
|
|
|
|
"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,
|
2026-09-21 19:56:13 +03:00
|
|
|
|
"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
|
2026-09-26 18:12:27 +03:00
|
|
|
|
] if state.exercise is Exercise.DDS or state.handoff_to_dds else [],
|
2026-09-17 21:24:34 +03:00
|
|
|
|
}
|
2026-09-26 17:12:29 +00:00
|
|
|
|
# Модель только поясняет уже посчитанные провалы и балл не трогает; без
|
|
|
|
|
|
# запущенной модели разбор выходит со статусом «недоступно», а не ждёт её.
|
|
|
|
|
|
state.score["ai_coaching"] = (await coach(result.metrics)).model_dump(mode="json")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
# Полный разбор хранится вместе с оценкой: PDF/CSV и история должны
|
|
|
|
|
|
# переживать перезапуск backend, а не зависеть от объекта в hub._sessions.
|
|
|
|
|
|
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
2026-09-17 21:24:34 +03:00
|
|
|
|
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
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
|
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-24 01:10:49 +03:00
|
|
|
|
async def refresh_archived_report(session_id: UUID, state) -> None:
|
|
|
|
|
|
"""Дополнить сохранённый разбор самооценкой, пришедшей после hangup."""
|
|
|
|
|
|
if state.score is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
scenario = state.scenario or store.get(state.scenario_id)
|
|
|
|
|
|
if scenario is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
|
|
|
|
|
if hub.journal:
|
|
|
|
|
|
await hub.journal.score_snapshot(session_id, state.score)
|
|
|
|
|
|
|
|
|
|
|
|
|
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))
|