lct-12: детерминированная оценка по ГОСТ, таксономия, радар
Каждая метрика — факт против норматива со ссылкой: «94 с при ≤ 75 с,
ГОСТ Р 22.7.03-2021», а не балл. По недобытому факту — отдельная отметка
E1 с эталонным вопросом: в разборе нужен конкретный вопрос, не процент.
Радар — проекция тех же метрик без пересчёта весов, коммуникация без
судьи не рисуется нулём.
Таймер, не остановленный событием, — провал, а не зачёт: время
недоказуемо, операция не завершена. Метрика, которую нечем посчитать
(полнота опроса без модели эмбеддингов), видна как «не посчитано» —
молча выброшенная выглядела бы пройденной.
Найдено противоречие: таймеры опроса (75 с) и оповещения ДДС (60 с)
стартовали на ответе и останавливались передачей в ДДС — один отрезок,
два лимита. Опрос за законные 70 с давал E3, а на экране курсанта
краснел таймер посреди нормального разговора. События «опрос закончен»
в контракте нет, поэтому dds_notify снят с учёта, вопрос записан в
CONTRACT.md.
KIO проверяет присваивание: card.dds = "03" клало в карточку сырую
строку вместо кода ДДС, и падала уже оценка, далеко от места ошибки.
2026-09-17 14:06:16 +03:00
|
|
|
|
"""Детерминированный слой оценки — 60% веса, считается кодом.
|
|
|
|
|
|
|
|
|
|
|
|
Воспроизводится стопроцентно: один и тот же ход занятия даёт один и тот же
|
|
|
|
|
|
результат. Каждая метрика — «факт против норматива со ссылкой», а не балл:
|
|
|
|
|
|
«опрос 94 с при нормативе 75 с (ГОСТ Р 22.7.03-2021)» можно предъявить
|
|
|
|
|
|
и проверить руками (docs/product/DEBRIEF.md).
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
|
feat: классификатор ЕКП — признаки вместо выбора службы (lct-32)
Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.
- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
и пересчитываются при каждой правке признаков; добавленная вручную служба
не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
dds_choice больше не считается — список оповещения производен от признаков,
и штрафовать за него отдельно значит наказать дважды за одну ошибку.
Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.
113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
2026-09-19 20:11:50 +03:00
|
|
|
|
from app.domain import ekp
|
lct-12: детерминированная оценка по ГОСТ, таксономия, радар
Каждая метрика — факт против норматива со ссылкой: «94 с при ≤ 75 с,
ГОСТ Р 22.7.03-2021», а не балл. По недобытому факту — отдельная отметка
E1 с эталонным вопросом: в разборе нужен конкретный вопрос, не процент.
Радар — проекция тех же метрик без пересчёта весов, коммуникация без
судьи не рисуется нулём.
Таймер, не остановленный событием, — провал, а не зачёт: время
недоказуемо, операция не завершена. Метрика, которую нечем посчитать
(полнота опроса без модели эмбеддингов), видна как «не посчитано» —
молча выброшенная выглядела бы пройденной.
Найдено противоречие: таймеры опроса (75 с) и оповещения ДДС (60 с)
стартовали на ответе и останавливались передачей в ДДС — один отрезок,
два лимита. Опрос за законные 70 с давал E3, а на экране курсанта
краснел таймер посреди нормального разговора. События «опрос закончен»
в контракте нет, поэтому dds_notify снят с учёта, вопрос записан в
CONTRACT.md.
KIO проверяет присваивание: card.dds = "03" клало в карточку сырую
строку вместо кода ДДС, и падала уже оценка, далеко от места ошибки.
2026-09-17 14:06:16 +03:00
|
|
|
|
from app.domain.events import CallEndReason, Metric
|
|
|
|
|
|
from app.domain.kio import KIO, missing_fields
|
|
|
|
|
|
from app.domain.taxonomy import ERRORS, Competency, Finding, FindingSource
|
|
|
|
|
|
from app.domain.timers import GOST_REF, NORMATIVES, TimerCode
|
|
|
|
|
|
from app.scenarios.schema import Scenario
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
from app.scoring.taxonomy import METRIC_MAP, METRIC_WEIGHTS
|
lct-12: детерминированная оценка по ГОСТ, таксономия, радар
Каждая метрика — факт против норматива со ссылкой: «94 с при ≤ 75 с,
ГОСТ Р 22.7.03-2021», а не балл. По недобытому факту — отдельная отметка
E1 с эталонным вопросом: в разборе нужен конкретный вопрос, не процент.
Радар — проекция тех же метрик без пересчёта весов, коммуникация без
судьи не рисуется нулём.
Таймер, не остановленный событием, — провал, а не зачёт: время
недоказуемо, операция не завершена. Метрика, которую нечем посчитать
(полнота опроса без модели эмбеддингов), видна как «не посчитано» —
молча выброшенная выглядела бы пройденной.
Найдено противоречие: таймеры опроса (75 с) и оповещения ДДС (60 с)
стартовали на ответе и останавливались передачей в ДДС — один отрезок,
два лимита. Опрос за законные 70 с давал E3, а на экране курсанта
краснел таймер посреди нормального разговора. События «опрос закончен»
в контракте нет, поэтому dds_notify снят с учёта, вопрос записан в
CONTRACT.md.
KIO проверяет присваивание: card.dds = "03" клало в карточку сырую
строку вместо кода ДДС, и падала уже оценка, далеко от места ошибки.
2026-09-17 14:06:16 +03:00
|
|
|
|
from app.session.timers import SessionTimers
|
|
|
|
|
|
|
|
|
|
|
|
SOURCE_BY_CODE = {
|
2026-09-17 21:45:47 +03:00
|
|
|
|
"E6": FindingSource.CHAIN,
|
lct-12: детерминированная оценка по ГОСТ, таксономия, радар
Каждая метрика — факт против норматива со ссылкой: «94 с при ≤ 75 с,
ГОСТ Р 22.7.03-2021», а не балл. По недобытому факту — отдельная отметка
E1 с эталонным вопросом: в разборе нужен конкретный вопрос, не процент.
Радар — проекция тех же метрик без пересчёта весов, коммуникация без
судьи не рисуется нулём.
Таймер, не остановленный событием, — провал, а не зачёт: время
недоказуемо, операция не завершена. Метрика, которую нечем посчитать
(полнота опроса без модели эмбеддингов), видна как «не посчитано» —
молча выброшенная выглядела бы пройденной.
Найдено противоречие: таймеры опроса (75 с) и оповещения ДДС (60 с)
стартовали на ответе и останавливались передачей в ДДС — один отрезок,
два лимита. Опрос за законные 70 с давал E3, а на экране курсанта
краснел таймер посреди нормального разговора. События «опрос закончен»
в контракте нет, поэтому dds_notify снят с учёта, вопрос записан в
CONTRACT.md.
KIO проверяет присваивание: card.dds = "03" клало в карточку сырую
строку вместо кода ДДС, и падала уже оценка, далеко от места ошибки.
2026-09-17 14:06:16 +03:00
|
|
|
|
"E1": FindingSource.SLOTS,
|
|
|
|
|
|
"E2": FindingSource.GROUND_TRUTH,
|
|
|
|
|
|
"E3": FindingSource.TIMERS,
|
|
|
|
|
|
"E5": FindingSource.KIO,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class GostResult:
|
|
|
|
|
|
metrics: list[Metric] = field(default_factory=list)
|
|
|
|
|
|
findings: list[Finding] = field(default_factory=list)
|
|
|
|
|
|
#: Метрики, которые посчитать было нечем. Не штрафуют, но видны в отчёте:
|
|
|
|
|
|
#: молча выброшенная метрика выглядит как пройденная.
|
|
|
|
|
|
unavailable: list[str] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def score(self) -> float:
|
|
|
|
|
|
"""Доля пройденного веса, 0–100."""
|
|
|
|
|
|
total = sum(metric.weight for metric in self.metrics)
|
|
|
|
|
|
if not total:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
passed = sum(metric.weight for metric in self.metrics if metric.passed)
|
|
|
|
|
|
return round(100 * passed / total, 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seconds(ms: int) -> str:
|
|
|
|
|
|
return f"{round(ms / 1000)} с"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_address(text: str | None) -> set[str]:
|
|
|
|
|
|
"""Слова адреса без служебных: «ул. Ленина д. 14» и «улица Ленина, 14»
|
|
|
|
|
|
должны совпасть, иначе курсанта штрафуют за сокращение."""
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return set()
|
|
|
|
|
|
noise = {"улица", "ул", "дом", "д", "проспект", "пр", "переулок", "пер", "г", "город", "москва"}
|
|
|
|
|
|
words = re.findall(r"[\w-]+", text.lower().replace("ё", "е"))
|
|
|
|
|
|
return {word for word in words if word not in noise}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _Builder:
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
|
self.result = GostResult()
|
|
|
|
|
|
|
|
|
|
|
|
def add(self, key: str, title: str, fact: str, norm: str, passed: bool, ref: str | None = None,
|
|
|
|
|
|
finding: str | None = None) -> None:
|
|
|
|
|
|
self.result.metrics.append(
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
Metric(key=key, title=title, fact=fact, norm=norm, ref=ref, passed=passed,
|
|
|
|
|
|
weight=METRIC_WEIGHTS.get(key, 1.0))
|
lct-12: детерминированная оценка по ГОСТ, таксономия, радар
Каждая метрика — факт против норматива со ссылкой: «94 с при ≤ 75 с,
ГОСТ Р 22.7.03-2021», а не балл. По недобытому факту — отдельная отметка
E1 с эталонным вопросом: в разборе нужен конкретный вопрос, не процент.
Радар — проекция тех же метрик без пересчёта весов, коммуникация без
судьи не рисуется нулём.
Таймер, не остановленный событием, — провал, а не зачёт: время
недоказуемо, операция не завершена. Метрика, которую нечем посчитать
(полнота опроса без модели эмбеддингов), видна как «не посчитано» —
молча выброшенная выглядела бы пройденной.
Найдено противоречие: таймеры опроса (75 с) и оповещения ДДС (60 с)
стартовали на ответе и останавливались передачей в ДДС — один отрезок,
два лимита. Опрос за законные 70 с давал E3, а на экране курсанта
краснел таймер посреди нормального разговора. События «опрос закончен»
в контракте нет, поэтому dds_notify снят с учёта, вопрос записан в
CONTRACT.md.
KIO проверяет присваивание: card.dds = "03" клало в карточку сырую
строку вместо кода ДДС, и падала уже оценка, далеко от места ошибки.
2026-09-17 14:06:16 +03:00
|
|
|
|
)
|
|
|
|
|
|
if passed:
|
|
|
|
|
|
return
|
|
|
|
|
|
code, competency = METRIC_MAP[key]
|
|
|
|
|
|
self.result.findings.append(
|
|
|
|
|
|
Finding(
|
|
|
|
|
|
code=code,
|
|
|
|
|
|
source=SOURCE_BY_CODE[code.value],
|
|
|
|
|
|
summary=finding or f"{ERRORS[code].title}: {title.lower()}",
|
|
|
|
|
|
fact=fact,
|
|
|
|
|
|
norm=norm,
|
|
|
|
|
|
ref=ref,
|
|
|
|
|
|
competency=competency,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def timer(self, key: str, code: TimerCode, timers: SessionTimers, limit_ms: int,
|
|
|
|
|
|
not_stopped: str) -> None:
|
|
|
|
|
|
normative = NORMATIVES[code]
|
|
|
|
|
|
norm = f"≤ {_seconds(limit_ms)}"
|
|
|
|
|
|
measured = timers.measured_ms(code)
|
|
|
|
|
|
if measured is None:
|
|
|
|
|
|
# Таймер не остановлен событием — время недоказуемо, и это провал:
|
|
|
|
|
|
# норматив не выполнен, пока операция не завершена.
|
|
|
|
|
|
self.add(key, normative.title, not_stopped, norm, passed=False, ref=GOST_REF)
|
|
|
|
|
|
return
|
|
|
|
|
|
self.add(
|
|
|
|
|
|
key,
|
|
|
|
|
|
normative.title,
|
|
|
|
|
|
f"{_seconds(measured)}",
|
|
|
|
|
|
norm,
|
|
|
|
|
|
passed=measured <= limit_ms,
|
|
|
|
|
|
ref=GOST_REF,
|
|
|
|
|
|
finding=f"{normative.title}: {_seconds(measured)} при нормативе {_seconds(limit_ms)}",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def evaluate(
|
|
|
|
|
|
*,
|
|
|
|
|
|
scenario: Scenario,
|
|
|
|
|
|
kio: KIO,
|
|
|
|
|
|
timers: SessionTimers,
|
|
|
|
|
|
revealed_facts: list[str] | 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[str] | None = None,
|
lct-12: детерминированная оценка по ГОСТ, таксономия, радар
Каждая метрика — факт против норматива со ссылкой: «94 с при ≤ 75 с,
ГОСТ Р 22.7.03-2021», а не балл. По недобытому факту — отдельная отметка
E1 с эталонным вопросом: в разборе нужен конкретный вопрос, не процент.
Радар — проекция тех же метрик без пересчёта весов, коммуникация без
судьи не рисуется нулём.
Таймер, не остановленный событием, — провал, а не зачёт: время
недоказуемо, операция не завершена. Метрика, которую нечем посчитать
(полнота опроса без модели эмбеддингов), видна как «не посчитано» —
молча выброшенная выглядела бы пройденной.
Найдено противоречие: таймеры опроса (75 с) и оповещения ДДС (60 с)
стартовали на ответе и останавливались передачей в ДДС — один отрезок,
два лимита. Опрос за законные 70 с давал E3, а на экране курсанта
краснел таймер посреди нормального разговора. События «опрос закончен»
в контракте нет, поэтому dds_notify снят с учёта, вопрос записан в
CONTRACT.md.
KIO проверяет присваивание: card.dds = "03" клало в карточку сырую
строку вместо кода ДДС, и падала уже оценка, далеко от места ошибки.
2026-09-17 14:06:16 +03:00
|
|
|
|
end_reason: CallEndReason | None = None,
|
2026-09-17 21:45:47 +03:00
|
|
|
|
bounced_fields: list[str] | None = None,
|
lct-12: детерминированная оценка по ГОСТ, таксономия, радар
Каждая метрика — факт против норматива со ссылкой: «94 с при ≤ 75 с,
ГОСТ Р 22.7.03-2021», а не балл. По недобытому факту — отдельная отметка
E1 с эталонным вопросом: в разборе нужен конкретный вопрос, не процент.
Радар — проекция тех же метрик без пересчёта весов, коммуникация без
судьи не рисуется нулём.
Таймер, не остановленный событием, — провал, а не зачёт: время
недоказуемо, операция не завершена. Метрика, которую нечем посчитать
(полнота опроса без модели эмбеддингов), видна как «не посчитано» —
молча выброшенная выглядела бы пройденной.
Найдено противоречие: таймеры опроса (75 с) и оповещения ДДС (60 с)
стартовали на ответе и останавливались передачей в ДДС — один отрезок,
два лимита. Опрос за законные 70 с давал E3, а на экране курсанта
краснел таймер посреди нормального разговора. События «опрос закончен»
в контракте нет, поэтому dds_notify снят с учёта, вопрос записан в
CONTRACT.md.
KIO проверяет присваивание: card.dds = "03" клало в карточку сырую
строку вместо кода ДДС, и падала уже оценка, далеко от места ошибки.
2026-09-17 14:06:16 +03:00
|
|
|
|
) -> GostResult:
|
|
|
|
|
|
"""Посчитать детерминированный слой по завершённому занятию.
|
|
|
|
|
|
|
|
|
|
|
|
`revealed_facts` — из слот-автомата. None означает, что автомата не было
|
|
|
|
|
|
(нет модели эмбеддингов): полнота опроса тогда не считается и не штрафует.
|
|
|
|
|
|
"""
|
|
|
|
|
|
build = _Builder()
|
|
|
|
|
|
truth = scenario.ground_truth
|
|
|
|
|
|
|
|
|
|
|
|
# ── нормативы времени, E3 ──
|
|
|
|
|
|
build.timer("answer_time", TimerCode.ANSWER, timers, timers.limits[TimerCode.ANSWER],
|
|
|
|
|
|
"вызов не принят")
|
|
|
|
|
|
build.timer("interview_time", TimerCode.INTERVIEW, timers, timers.limits[TimerCode.INTERVIEW],
|
|
|
|
|
|
"опрос не завершён передачей в ДДС — время не зафиксировано")
|
|
|
|
|
|
build.result.unavailable.append(
|
|
|
|
|
|
"dds_notify_time: нет события конца опроса, от которого отсчитывать 60 с "
|
|
|
|
|
|
"(см. docs/arch/CONTRACT.md, коды таймеров)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if end_reason is CallEndReason.DROPPED:
|
|
|
|
|
|
callback = timers.timers.get(TimerCode.CALLBACK)
|
|
|
|
|
|
attempts = callback.attempt if callback and callback.started_at is not None else 0
|
|
|
|
|
|
limit = NORMATIVES[TimerCode.CALLBACK]
|
|
|
|
|
|
build.add(
|
|
|
|
|
|
"callback", limit.title,
|
|
|
|
|
|
f"попыток дозвона: {attempts}" if attempts else "обратного дозвона не было",
|
|
|
|
|
|
f"не более {limit.attempts} попыток по {_seconds(limit.limit_ms)}",
|
|
|
|
|
|
passed=0 < attempts <= limit.attempts,
|
|
|
|
|
|
ref=GOST_REF,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ── полнота опроса, E1 ──
|
|
|
|
|
|
if revealed_facts is None:
|
|
|
|
|
|
build.result.unavailable.append(
|
|
|
|
|
|
"checklist_completeness: нет слот-автомата (не скачана модель эмбеддингов)"
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
required = truth.required_facts
|
|
|
|
|
|
got = [fact_id for fact_id in required if fact_id in revealed_facts]
|
|
|
|
|
|
build.result.metrics.append(
|
|
|
|
|
|
Metric(
|
|
|
|
|
|
key="checklist_completeness",
|
|
|
|
|
|
title="Полнота опроса",
|
|
|
|
|
|
fact=f"добыто {len(got)} из {len(required)} обязательных фактов",
|
|
|
|
|
|
norm="все обязательные факты",
|
|
|
|
|
|
ref="чек-лист сценария",
|
|
|
|
|
|
passed=len(got) == len(required),
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
weight=METRIC_WEIGHTS["checklist_completeness"],
|
lct-12: детерминированная оценка по ГОСТ, таксономия, радар
Каждая метрика — факт против норматива со ссылкой: «94 с при ≤ 75 с,
ГОСТ Р 22.7.03-2021», а не балл. По недобытому факту — отдельная отметка
E1 с эталонным вопросом: в разборе нужен конкретный вопрос, не процент.
Радар — проекция тех же метрик без пересчёта весов, коммуникация без
судьи не рисуется нулём.
Таймер, не остановленный событием, — провал, а не зачёт: время
недоказуемо, операция не завершена. Метрика, которую нечем посчитать
(полнота опроса без модели эмбеддингов), видна как «не посчитано» —
молча выброшенная выглядела бы пройденной.
Найдено противоречие: таймеры опроса (75 с) и оповещения ДДС (60 с)
стартовали на ответе и останавливались передачей в ДДС — один отрезок,
два лимита. Опрос за законные 70 с давал E3, а на экране курсанта
краснел таймер посреди нормального разговора. События «опрос закончен»
в контракте нет, поэтому dds_notify снят с учёта, вопрос записан в
CONTRACT.md.
KIO проверяет присваивание: card.dds = "03" клало в карточку сырую
строку вместо кода ДДС, и падала уже оценка, далеко от места ошибки.
2026-09-17 14:06:16 +03:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
# По отметке на каждый недобытый факт: в разборе нужен конкретный
|
|
|
|
|
|
# пропущенный вопрос, а не процент.
|
|
|
|
|
|
questions = {item.fact: item.question for item in scenario.checklist if item.fact}
|
|
|
|
|
|
for fact_id in required:
|
|
|
|
|
|
if fact_id in revealed_facts:
|
|
|
|
|
|
continue
|
|
|
|
|
|
question = questions.get(fact_id)
|
|
|
|
|
|
build.result.findings.append(
|
|
|
|
|
|
Finding(
|
|
|
|
|
|
code=METRIC_MAP["checklist_completeness"][0],
|
|
|
|
|
|
source=FindingSource.SLOTS,
|
|
|
|
|
|
summary=f"Не добыт обязательный факт {fact_id}",
|
|
|
|
|
|
fact="вопрос не прозвучал",
|
|
|
|
|
|
norm=f"эталонный вопрос: «{question}»" if question else "обязательный факт сценария",
|
|
|
|
|
|
ref="чек-лист сценария",
|
|
|
|
|
|
competency=Competency.INTERVIEW,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ── классификация и маршрутизация, E2 ──
|
feat: классификатор ЕКП — признаки вместо выбора службы (lct-32)
Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.
- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
и пересчитываются при каждой правке признаков; добавленная вручную служба
не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
dds_choice больше не считается — список оповещения производен от признаков,
и штрафовать за него отдельно значит наказать дважды за одну ошибку.
Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.
113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
2026-09-19 20:11:50 +03:00
|
|
|
|
#
|
|
|
|
|
|
# Признаки — то, что оператор действительно делает в боевом АРМ-112: службу
|
|
|
|
|
|
# он не выбирает, её считает система по ЕКП (docs/spec/DATASET.md). Поэтому
|
|
|
|
|
|
# размеченный признаками сценарий оценивается по ним, а не по выбору ДДС.
|
|
|
|
|
|
if truth.incident_code is not None:
|
|
|
|
|
|
found = ekp.incident(truth.incident_code)
|
|
|
|
|
|
expected = " · ".join(found.signs) if found else truth.incident_code
|
|
|
|
|
|
actual = " · ".join(kio.signs) if kio.signs else "не проставлены"
|
|
|
|
|
|
build.add(
|
|
|
|
|
|
"incident_signs", "Признаки происшествия",
|
|
|
|
|
|
actual, expected,
|
|
|
|
|
|
passed=kio.incident_code == truth.incident_code,
|
|
|
|
|
|
ref=f"классификатор ЕКП {ekp.reference().version}",
|
|
|
|
|
|
finding=(
|
|
|
|
|
|
f"Признаки «{actual}» дают {kio.incident_code or 'пустой код'}, "
|
|
|
|
|
|
f"верные — «{expected}» ({truth.incident_code})"
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
# Список оповещения производен: при верных признаках он верен всегда,
|
|
|
|
|
|
# и штрафовать за него отдельно — штрафовать дважды за одно.
|
|
|
|
|
|
elif truth.incident_type is not None:
|
lct-12: детерминированная оценка по ГОСТ, таксономия, радар
Каждая метрика — факт против норматива со ссылкой: «94 с при ≤ 75 с,
ГОСТ Р 22.7.03-2021», а не балл. По недобытому факту — отдельная отметка
E1 с эталонным вопросом: в разборе нужен конкретный вопрос, не процент.
Радар — проекция тех же метрик без пересчёта весов, коммуникация без
судьи не рисуется нулём.
Таймер, не остановленный событием, — провал, а не зачёт: время
недоказуемо, операция не завершена. Метрика, которую нечем посчитать
(полнота опроса без модели эмбеддингов), видна как «не посчитано» —
молча выброшенная выглядела бы пройденной.
Найдено противоречие: таймеры опроса (75 с) и оповещения ДДС (60 с)
стартовали на ответе и останавливались передачей в ДДС — один отрезок,
два лимита. Опрос за законные 70 с давал E3, а на экране курсанта
краснел таймер посреди нормального разговора. События «опрос закончен»
в контракте нет, поэтому dds_notify снят с учёта, вопрос записан в
CONTRACT.md.
KIO проверяет присваивание: card.dds = "03" клало в карточку сырую
строку вместо кода ДДС, и падала уже оценка, далеко от места ошибки.
2026-09-17 14:06:16 +03:00
|
|
|
|
actual = kio.incident_type.value if kio.incident_type else "не указан"
|
|
|
|
|
|
build.add(
|
|
|
|
|
|
"incident_type", "Тип происшествия",
|
|
|
|
|
|
actual, truth.incident_type.value,
|
|
|
|
|
|
passed=kio.incident_type == truth.incident_type,
|
|
|
|
|
|
ref="классификатор происшествий",
|
|
|
|
|
|
finding=f"Тип происшествия {actual}, верный — {truth.incident_type.value}",
|
|
|
|
|
|
)
|
feat: классификатор ЕКП — признаки вместо выбора службы (lct-32)
Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.
- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
и пересчитываются при каждой правке признаков; добавленная вручную служба
не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
dds_choice больше не считается — список оповещения производен от признаков,
и штрафовать за него отдельно значит наказать дважды за одну ошибку.
Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.
113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
2026-09-19 20:11:50 +03:00
|
|
|
|
if truth.dds is not None and truth.incident_code is None:
|
lct-12: детерминированная оценка по ГОСТ, таксономия, радар
Каждая метрика — факт против норматива со ссылкой: «94 с при ≤ 75 с,
ГОСТ Р 22.7.03-2021», а не балл. По недобытому факту — отдельная отметка
E1 с эталонным вопросом: в разборе нужен конкретный вопрос, не процент.
Радар — проекция тех же метрик без пересчёта весов, коммуникация без
судьи не рисуется нулём.
Таймер, не остановленный событием, — провал, а не зачёт: время
недоказуемо, операция не завершена. Метрика, которую нечем посчитать
(полнота опроса без модели эмбеддингов), видна как «не посчитано» —
молча выброшенная выглядела бы пройденной.
Найдено противоречие: таймеры опроса (75 с) и оповещения ДДС (60 с)
стартовали на ответе и останавливались передачей в ДДС — один отрезок,
два лимита. Опрос за законные 70 с давал E3, а на экране курсанта
краснел таймер посреди нормального разговора. События «опрос закончен»
в контракте нет, поэтому dds_notify снят с учёта, вопрос записан в
CONTRACT.md.
KIO проверяет присваивание: card.dds = "03" клало в карточку сырую
строку вместо кода ДДС, и падала уже оценка, далеко от места ошибки.
2026-09-17 14:06:16 +03:00
|
|
|
|
actual = kio.dds.value if kio.dds else "не выбрана"
|
|
|
|
|
|
build.add(
|
|
|
|
|
|
"dds_choice", "Выбор ДДС",
|
|
|
|
|
|
actual, truth.dds.value,
|
|
|
|
|
|
passed=kio.dds == truth.dds,
|
|
|
|
|
|
ref="классификатор ДДС",
|
|
|
|
|
|
finding=f"Карточка ушла в ДДС {actual}, верная — {truth.dds.value}",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
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
|
|
|
|
# ── подтверждение места происшествия, E1 ──
|
|
|
|
|
|
#
|
|
|
|
|
|
# Адрес заявителя не всегда адрес происшествия: он называет тот, где стоит
|
|
|
|
|
|
# сам, или ориентир вместо дома. В билетах заказчика это основной приём
|
|
|
|
|
|
# усложнения (docs/spec/TICKETS.md), и оператор обязан переспросить.
|
|
|
|
|
|
refinable = [fact.id for fact in scenario.facts if fact.refine_on]
|
|
|
|
|
|
if refinable and refined_facts is not None:
|
|
|
|
|
|
confirmed = [fact_id for fact_id in refinable if fact_id in refined_facts]
|
|
|
|
|
|
build.add(
|
|
|
|
|
|
"address_refined", "Место происшествия подтверждено",
|
|
|
|
|
|
f"уточнено {len(confirmed)} из {len(refinable)}", "уточнены все",
|
|
|
|
|
|
passed=len(confirmed) == len(refinable),
|
|
|
|
|
|
ref="памятка АРМ-112: адрес заявителя не всегда адрес происшествия",
|
|
|
|
|
|
finding="Оператор не переспросил про место происшествия — записан адрес,"
|
|
|
|
|
|
" который назвал заявитель",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
lct-12: детерминированная оценка по ГОСТ, таксономия, радар
Каждая метрика — факт против норматива со ссылкой: «94 с при ≤ 75 с,
ГОСТ Р 22.7.03-2021», а не балл. По недобытому факту — отдельная отметка
E1 с эталонным вопросом: в разборе нужен конкретный вопрос, не процент.
Радар — проекция тех же метрик без пересчёта весов, коммуникация без
судьи не рисуется нулём.
Таймер, не остановленный событием, — провал, а не зачёт: время
недоказуемо, операция не завершена. Метрика, которую нечем посчитать
(полнота опроса без модели эмбеддингов), видна как «не посчитано» —
молча выброшенная выглядела бы пройденной.
Найдено противоречие: таймеры опроса (75 с) и оповещения ДДС (60 с)
стартовали на ответе и останавливались передачей в ДДС — один отрезок,
два лимита. Опрос за законные 70 с давал E3, а на экране курсанта
краснел таймер посреди нормального разговора. События «опрос закончен»
в контракте нет, поэтому dds_notify снят с учёта, вопрос записан в
CONTRACT.md.
KIO проверяет присваивание: card.dds = "03" клало в карточку сырую
строку вместо кода ДДС, и падала уже оценка, далеко от места ошибки.
2026-09-17 14:06:16 +03:00
|
|
|
|
# ── карточка, E5 ──
|
|
|
|
|
|
if truth.address:
|
|
|
|
|
|
written = kio.address or " ".join(filter(None, [kio.street, kio.building]))
|
|
|
|
|
|
expected = _normalize_address(truth.address)
|
|
|
|
|
|
build.add(
|
|
|
|
|
|
"address", "Адрес",
|
|
|
|
|
|
written or "не заполнен", truth.address,
|
|
|
|
|
|
passed=bool(expected) and expected <= _normalize_address(written),
|
|
|
|
|
|
ref="ground_truth сценария",
|
|
|
|
|
|
finding=f"Адрес в карточке «{written or 'пусто'}», верный — «{truth.address}»",
|
|
|
|
|
|
)
|
|
|
|
|
|
if truth.victims is not None:
|
|
|
|
|
|
actual = "не указано" if kio.victims_count is None else str(kio.victims_count)
|
|
|
|
|
|
build.add(
|
|
|
|
|
|
"victims_count", "Число пострадавших",
|
|
|
|
|
|
actual, str(truth.victims),
|
|
|
|
|
|
passed=kio.victims_count == truth.victims,
|
|
|
|
|
|
ref="ground_truth сценария",
|
|
|
|
|
|
finding=f"Пострадавших в карточке {actual}, верно — {truth.victims}",
|
|
|
|
|
|
)
|
|
|
|
|
|
if scenario.required_fields:
|
|
|
|
|
|
empty = missing_fields(kio, scenario.required_fields)
|
|
|
|
|
|
build.add(
|
|
|
|
|
|
"required_fields", "Обязательные поля КИО",
|
|
|
|
|
|
"все заполнены" if not empty else f"пусто: {', '.join(empty)}",
|
|
|
|
|
|
f"заполнены: {', '.join(scenario.required_fields)}",
|
|
|
|
|
|
passed=not empty,
|
|
|
|
|
|
ref="ГОСТ Р 22.7.03-2021, структура КИО",
|
|
|
|
|
|
finding=f"Не заполнены обязательные поля: {', '.join(empty)}" if empty else None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-17 21:45:47 +03:00
|
|
|
|
# ── цепочка 112 → ДДС, E6 ──
|
|
|
|
|
|
if bounced_fields:
|
|
|
|
|
|
build.add(
|
|
|
|
|
|
"dds_chain", "Карточка принята ДДС",
|
|
|
|
|
|
f"возвращена на уточнение: {', '.join(bounced_fields)}",
|
|
|
|
|
|
"карточка пригодна для выезда",
|
|
|
|
|
|
passed=False,
|
|
|
|
|
|
ref="цепочка 112 → ДДС",
|
|
|
|
|
|
finding=f"Диспетчер вернул карточку: не заполнено {', '.join(bounced_fields)} — выезд сорван",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
lct-12: детерминированная оценка по ГОСТ, таксономия, радар
Каждая метрика — факт против норматива со ссылкой: «94 с при ≤ 75 с,
ГОСТ Р 22.7.03-2021», а не балл. По недобытому факту — отдельная отметка
E1 с эталонным вопросом: в разборе нужен конкретный вопрос, не процент.
Радар — проекция тех же метрик без пересчёта весов, коммуникация без
судьи не рисуется нулём.
Таймер, не остановленный событием, — провал, а не зачёт: время
недоказуемо, операция не завершена. Метрика, которую нечем посчитать
(полнота опроса без модели эмбеддингов), видна как «не посчитано» —
молча выброшенная выглядела бы пройденной.
Найдено противоречие: таймеры опроса (75 с) и оповещения ДДС (60 с)
стартовали на ответе и останавливались передачей в ДДС — один отрезок,
два лимита. Опрос за законные 70 с давал E3, а на экране курсанта
краснел таймер посреди нормального разговора. События «опрос закончен»
в контракте нет, поэтому dds_notify снят с учёта, вопрос записан в
CONTRACT.md.
KIO проверяет присваивание: card.dds = "03" клало в карточку сырую
строку вместо кода ДДС, и падала уже оценка, далеко от места ошибки.
2026-09-17 14:06:16 +03:00
|
|
|
|
return build.result
|