lct-hack/backend/app/scoring/gost.py
Ivan Gerasimov b8dc39fd7d feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.

- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
  и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
  должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
  Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.

Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.

Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.

161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00

313 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Детерминированный слой оценки — 60% веса, считается кодом.
Воспроизводится стопроцентно: один и тот же ход занятия даёт один и тот же
результат. Каждая метрика — «факт против норматива со ссылкой», а не балл:
«опрос 94 с при нормативе 75 с (ГОСТ Р 22.7.03-2021)» можно предъявить
и проверить руками (docs/product/DEBRIEF.md).
"""
import re
from dataclasses import dataclass, field
from app.domain import ekp
from app.domain.classifiers import OUTCOME_LABELS, Outcome
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
from app.scoring.taxonomy import METRIC_MAP, METRIC_WEIGHTS
from app.session.timers import SessionTimers
SOURCE_BY_CODE = {
"E6": FindingSource.CHAIN,
"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(
Metric(key=key, title=title, fact=fact, norm=norm, ref=ref, passed=passed,
weight=METRIC_WEIGHTS.get(key, 1.0))
)
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,
refined_facts: list[str] | None = None,
resolved_outcome: str | None = None,
dispatched: bool = False,
end_reason: CallEndReason | None = None,
bounced_fields: list[str] | None = None,
) -> 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),
weight=METRIC_WEIGHTS["checklist_completeness"],
)
)
# По отметке на каждый недобытый факт: в разборе нужен конкретный
# пропущенный вопрос, а не процент.
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 ──
#
# Признаки — то, что оператор действительно делает в боевом АРМ-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:
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}",
)
if truth.dds is not None and truth.incident_code is None:
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}",
)
# ── исход вызова, E2 ──
#
# Не каждый звонок заканчивается карточкой: «поругался с продавцом» — справка,
# вызов из другого субъекта передаётся туда (docs/spec/TICKETS.md). Курсант,
# заведший карточку на такой вызов, сделал лишнюю работу и занял расчёт.
expected_outcome = scenario.outcome
if expected_outcome is not Outcome.CARD:
actual = resolved_outcome or ("card" if dispatched else "ничего не решено")
build.add(
"outcome", "Исход вызова",
OUTCOME_LABELS.get(Outcome(actual), actual) if actual in set(Outcome) else actual,
OUTCOME_LABELS[expected_outcome],
passed=actual == expected_outcome.value,
ref="классификатор происшествий, группа 23 «Прочие происшествия»",
finding=(
f"Вызов закрыт как «{actual}», правильно — "
f"«{OUTCOME_LABELS[expected_outcome]}»"
),
)
# ── подтверждение места происшествия, 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="Оператор не переспросил про место происшествия — записан адрес,"
" который назвал заявитель",
)
# ── карточка, E5 ──
#
# Там, где карточка не заводится, пустые поля — не ошибка, а правильное
# поведение: штрафовать за них значит учить заполнять лишнее.
if expected_outcome is not Outcome.CARD:
return build.result
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,
)
# ── цепочка 112 → ДДС, E6 ──
if bounced_fields:
build.add(
"dds_chain", "Карточка принята ДДС",
f"возвращена на уточнение: {', '.join(bounced_fields)}",
"карточка пригодна для выезда",
passed=False,
ref="цепочка 112 → ДДС",
finding=f"Диспетчер вернул карточку: не заполнено {', '.join(bounced_fields)} — выезд сорван",
)
return build.result