2026-09-17 21:24:34 +03:00
|
|
|
|
"""Завершение занятия: посчитать оценку и положить её в журнал.
|
|
|
|
|
|
|
|
|
|
|
|
Детерминированный слой считается сразу по завершении звонка (lct-12).
|
|
|
|
|
|
Курсанту `score.ready` уходит **только после самооценки**: сначала он сверяет
|
|
|
|
|
|
своё ощущение с объективной картиной, и расхождение — отдельный материал
|
|
|
|
|
|
для преподавателя (docs/product/DEBRIEF.md). Преподаватель и монитор получают
|
|
|
|
|
|
событие сразу: им ждать нечего.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
|
|
|
|
|
from app.domain.events import ScoreReady
|
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
|
|
|
|
from app.domain.timers import NORMATIVES, TimerCode
|
2026-09-17 21:24:34 +03:00
|
|
|
|
from app.scenarios import store
|
|
|
|
|
|
from app.scoring.competency import radar
|
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
|
|
|
|
from app.scoring.dispatcher import evaluate_dispatcher
|
2026-09-17 21:24:34 +03:00
|
|
|
|
from app.scoring.gost import evaluate
|
|
|
|
|
|
from app.session.hub import hub
|
|
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def finish(session_id: UUID, state) -> None:
|
2026-09-17 21:38:43 +03:00
|
|
|
|
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
|
|
|
|
|
|
scenario = state.scenario or store.get(state.scenario_id)
|
2026-09-17 21:24:34 +03:00
|
|
|
|
if scenario is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
feat: уточнение адреса — место происшествия не равно адресу заявителя (lct-35)
Главный предмет проверки в билетах заказчика — адрес: названный заявителем
часто неверен, а настоящий добывается переспросом («ул. Станционная, 28»
оказывается Королёвом). Памятка АРМ-112 называет это обычным делом.
До этой правки оценка работала наоборот: ground_truth.address сравнивался
с единственным значением факта, поэтому курсант, правильно переспросивший
и записавший настоящий адрес, получал расхождение с эталоном, а записавший
ориентир — зачёт.
- Схема: у факта появились refined и refine_on, задаются только вместе.
- Слот-автомат различает уточнение и повтор: повтор раздражает звонящего,
уточнение — нет, оператор спросил о другом и получил другое.
- Звонящий поправляется отдельной репликой, а не повторяет прежнее значение;
офлайн-таблица получила секцию refine.
- Оценка: метрика address_refined (E1, опрос). Сделана отдельной, а не правкой
метрики address: «записал не тот адрес» и «не спросил» — разные навыки
и разные компетенции в радаре.
- Общие пункты чек-листа подключаются по требованию сценария: формулировки
в checklists/common.yaml, сценарий объявляет пункт с тем же id без текста.
Добавлять «уточните адрес» во все сценарии нельзя — неотработанный пункт
штрафует за вопрос, которого сценарий не требовал.
- scenarios/fire-private-house-l3.yaml — первый сценарий из библиотеки
заказчика (билет 3, вызов 1).
Риск механики проверен на живой модели: «Назовите адрес» и «Это точно Москва?»
эмбеддинги не путают, тест это фиксирует.
125 тестов зелёных (12 новых), make typecheck чистый.
2026-09-19 20:26:49 +03:00
|
|
|
|
refined_facts=list(state.slots.refined) if state.slots else None,
|
2026-09-17 21:24:34 +03:00
|
|
|
|
end_reason=state.end_reason,
|
2026-09-17 21:45:47 +03:00
|
|
|
|
bounced_fields=state.bounced_fields,
|
2026-09-17 21:24:34 +03:00
|
|
|
|
)
|
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#статусы-реагирования).
|
|
|
|
|
|
dispatcher_findings = evaluate_dispatcher(
|
|
|
|
|
|
entries=state.status_log,
|
|
|
|
|
|
services=state.notified_services(),
|
|
|
|
|
|
deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
|
|
|
|
|
|
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
|
|
|
|
|
)
|
|
|
|
|
|
result.findings.extend(dispatcher_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),
|
|
|
|
|
|
"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-17 21:24:34 +03:00
|
|
|
|
}
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
hub.to_observers(session_id, ScoreReady(session_id=session_id))
|
|
|
|
|
|
await release_score(session_id, state)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def release_score(session_id: UUID, state) -> None:
|
|
|
|
|
|
"""Отдать оценку курсанту, когда самооценка сдана."""
|
|
|
|
|
|
if state.score is not None and state.self_assessed:
|
|
|
|
|
|
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
|