Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -4,14 +4,17 @@
|
|||
перечислены поимённо и с реальными примерами (docs/spec/DATASET.md). Формулировки
|
||||
не переписаны: преподаватель, который эту памятку читал, должен узнать их в разборе.
|
||||
|
||||
Детерминированно считаются D1, D2, D3, D4 и D6. D5 — полнота комментария — мягкий
|
||||
критерий, его место у судьи (lct-13): «не принята: не обслуживаем» без указания,
|
||||
куда передана информация, формально неотличимо от полного комментария.
|
||||
Детерминированно считаются D1–D6. D5 проверяет структуру отдельного доклада и
|
||||
получателя сведений; пропуск по каждому комментарию виден отдельно. К каждой ручной
|
||||
отметке обязательны непустые поля «Основание» и «Сведения»; их наличие входит в
|
||||
числовую метрику `dds_reply`, но не подтверждает истинность текста. Это не
|
||||
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
|
||||
"""
|
||||
|
||||
from app.domain.statuses import (
|
||||
COMMENT_REQUIRED,
|
||||
PRIMARY,
|
||||
REFUSAL_COMMENT_REQUIRED,
|
||||
SERVICE_STATUS_LABELS,
|
||||
ServiceStatus,
|
||||
StatusEntry,
|
||||
|
|
@ -33,16 +36,61 @@ def _finding(code: ErrorCode, summary: str, fact: str, norm: str | None = None)
|
|||
fact=fact,
|
||||
norm=norm,
|
||||
ref="памятка «Работа на АРМ-112», раздел «Статусы реагирования»",
|
||||
competency=Competency.CARD,
|
||||
competency=(
|
||||
Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_RECIPIENT_TERMS = (
|
||||
"бригад",
|
||||
"старш",
|
||||
"дежурн",
|
||||
"диспетчер",
|
||||
"оператор",
|
||||
"заявител",
|
||||
"пострадавш",
|
||||
"мвд",
|
||||
"полици",
|
||||
"мчс",
|
||||
"скорая",
|
||||
"медицинск",
|
||||
"аварийн",
|
||||
"служба 101",
|
||||
"служба 102",
|
||||
"служба 103",
|
||||
"служба 104",
|
||||
)
|
||||
|
||||
|
||||
def _has_recipient(text: str, service: str, crew: str | None) -> bool:
|
||||
value = text.casefold()
|
||||
if any(term in value for term in _RECIPIENT_TERMS):
|
||||
return True
|
||||
candidates = [service, crew or ""]
|
||||
return any(len(item.strip()) >= 3 and item.strip().casefold() in value for item in candidates)
|
||||
|
||||
|
||||
def _has_basis_and_information(text: str) -> bool:
|
||||
"""Require two explicit fields; this checks structure, not factual truth."""
|
||||
fields: dict[str, str] = {}
|
||||
for line in text.splitlines():
|
||||
label, separator, value = line.partition(":")
|
||||
if separator and label.strip().casefold() in {"основание", "сведения"}:
|
||||
fields[label.strip().casefold()] = value.strip()
|
||||
return bool(fields.get("основание") and fields.get("сведения"))
|
||||
|
||||
|
||||
def evaluate_dispatcher(
|
||||
*,
|
||||
entries: list[StatusEntry],
|
||||
services: list[str],
|
||||
crew_assignments: dict[str, str] | None = None,
|
||||
deadline_ms: int,
|
||||
elapsed_ms: int | None,
|
||||
reply_text: str = "",
|
||||
expected_decision: str = "accept",
|
||||
expected_decision_reason: str | None = None,
|
||||
) -> list[Finding]:
|
||||
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
|
||||
|
||||
|
|
@ -51,6 +99,7 @@ def evaluate_dispatcher(
|
|||
компетенция» неправомерен.
|
||||
"""
|
||||
findings: list[Finding] = []
|
||||
crew_assignments = crew_assignments or {}
|
||||
if not services:
|
||||
return findings
|
||||
|
||||
|
|
@ -58,8 +107,63 @@ def evaluate_dispatcher(
|
|||
marks = [entry for entry in entries if entry.service == service]
|
||||
latest = current(entries, service)
|
||||
|
||||
# D1 — первичного статуса нет вовсе.
|
||||
if not any(mark.status in PRIMARY for mark in marks):
|
||||
# D5 — мягкая проверка памятки: если диспетчер оставил комментарий,
|
||||
# в нём должен быть назван получатель сведений. Статусы и внешние
|
||||
# звонки не используются как выдуманное доказательство.
|
||||
missing_comment_statuses = [
|
||||
SERVICE_STATUS_LABELS[mark.status]
|
||||
for mark in marks
|
||||
if mark.status in COMMENT_REQUIRED and not mark.comment.strip()
|
||||
]
|
||||
if missing_comment_statuses:
|
||||
findings.append(_finding(
|
||||
ErrorCode.D5,
|
||||
f"{service}: к статусу не добавлены основание и сведения",
|
||||
fact="не заполнены комментарии: " + ", ".join(missing_comment_statuses),
|
||||
norm="к каждой ручной отметке добавить основание и содержание полученных сведений",
|
||||
))
|
||||
|
||||
incomplete_comment_statuses = [
|
||||
SERVICE_STATUS_LABELS[mark.status]
|
||||
for mark in marks
|
||||
if mark.status in COMMENT_REQUIRED
|
||||
and mark.comment.strip()
|
||||
and not _has_basis_and_information(mark.comment)
|
||||
]
|
||||
if incomplete_comment_statuses:
|
||||
findings.append(_finding(
|
||||
ErrorCode.D5,
|
||||
f"{service}: комментарии к статусам не разделяют основание и сведения",
|
||||
fact="неполные комментарии: " + ", ".join(incomplete_comment_statuses),
|
||||
norm="в каждом комментарии заполнить отдельные поля «Основание» и «Сведения»",
|
||||
))
|
||||
|
||||
comments_to_check = [
|
||||
(SERVICE_STATUS_LABELS[mark.status], mark.comment.strip())
|
||||
for mark in marks
|
||||
if mark.comment.strip()
|
||||
]
|
||||
if reply_text.strip():
|
||||
comments_to_check.append(("ответ ДДС", reply_text.strip()))
|
||||
missing_recipients = [
|
||||
f"{label}: {comment[:180]}"
|
||||
for label, comment in comments_to_check
|
||||
if not _has_recipient(comment, service, crew_assignments.get(service))
|
||||
]
|
||||
if missing_recipients:
|
||||
findings.append(_finding(
|
||||
ErrorCode.D5,
|
||||
f"{service}: отдельный комментарий не указывает получателя сведений",
|
||||
fact="; ".join(missing_recipients)[:500],
|
||||
norm=(
|
||||
"назвать, кому переданы сведения: бригаде, службе, "
|
||||
"заявителю или иному адресату"
|
||||
),
|
||||
))
|
||||
|
||||
# D1 — первичного статуса нет либо он проставлен позже норматива.
|
||||
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
|
||||
if primary is None:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D1,
|
||||
|
|
@ -74,9 +178,23 @@ def evaluate_dispatcher(
|
|||
)
|
||||
continue
|
||||
|
||||
if elapsed_ms is None or elapsed_ms > deadline_ms:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D1,
|
||||
f"{service}: первичный статус проставлен с нарушением срока",
|
||||
fact=(
|
||||
f"прошло {elapsed_ms // 1000} с"
|
||||
if elapsed_ms is not None
|
||||
else "время первичной отметки не зафиксировано"
|
||||
),
|
||||
norm=f"первичный статус в течение {deadline_ms // 1000} с",
|
||||
)
|
||||
)
|
||||
|
||||
for mark in marks:
|
||||
# D4 — отказ без комментария.
|
||||
if mark.status in COMMENT_REQUIRED and not mark.comment.strip():
|
||||
if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D4,
|
||||
|
|
@ -86,48 +204,89 @@ def evaluate_dispatcher(
|
|||
)
|
||||
)
|
||||
|
||||
# D3 — отказ от профильного происшествия. Служба в списке оповещения
|
||||
# по классификатору, значит происшествие входит в её компетенцию.
|
||||
if latest is ServiceStatus.DECLINED:
|
||||
# D3 — отказ от профильного происшествия, когда эталон сценария
|
||||
# требует принятия. Дубль/территориальное исключение задаются явно.
|
||||
if latest is ServiceStatus.DECLINED and expected_decision == "accept":
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D3,
|
||||
f"{service}: отказ от происшествия, которое в её компетенции",
|
||||
fact="служба есть в списке оповещения по ЕКП",
|
||||
norm="отказываться от профильного происшествия нельзя",
|
||||
norm="принять согласно эталону сценария",
|
||||
)
|
||||
)
|
||||
|
||||
# D2 — «Принята», но работ не было и отказа тоже: статус не отражает факт.
|
||||
if latest is ServiceStatus.ACCEPTED:
|
||||
elif latest is ServiceStatus.ACCEPTED and expected_decision == "decline":
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
f"{service}: «Принята», но о реагировании ничего не отмечено",
|
||||
fact="после приёма статусов не было",
|
||||
norm="статус должен соответствовать фактическому состоянию заявки",
|
||||
f"{service}: принято вопреки эталону сценария",
|
||||
fact="карточка принята службой",
|
||||
norm=expected_decision_reason or "отказать с указанной причиной",
|
||||
)
|
||||
)
|
||||
|
||||
# D6 — работы завершены, а ход работ не отмечен. В памятке это отдельный
|
||||
# разбор: по такому происшествию идут повторные звонки, и другие службы
|
||||
# не видят, что реагирование вообще началось.
|
||||
if latest is ServiceStatus.COMPLETED and not any(
|
||||
# D2 — «Принята» без назначения бригады/дальнейшего хода или ход без
|
||||
# зафиксированной бригады. Заказчик уточнил: необходимые бригады ДДС
|
||||
# выбирает вручную; канал получения докладов при этом не предписан.
|
||||
if latest is ServiceStatus.ACCEPTED and expected_decision == "accept":
|
||||
if service not in crew_assignments:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
f"{service}: «Принята» без назначения бригады и хода реагирования",
|
||||
fact="бригада не выбрана, после приёма статусов не было",
|
||||
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
||||
)
|
||||
)
|
||||
else:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
f"{service}: «Принята», но о реагировании ничего не отмечено",
|
||||
fact="после приёма статусов не было",
|
||||
norm="статус должен соответствовать фактическому состоянию заявки",
|
||||
)
|
||||
)
|
||||
elif latest is not ServiceStatus.DECLINED and any(
|
||||
mark.status in PROGRESS for mark in marks
|
||||
):
|
||||
) and service not in crew_assignments:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
f"{service}: ход реагирования без назначения бригады",
|
||||
fact="статусы хода работ проставлены, бригада не выбрана",
|
||||
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
||||
)
|
||||
)
|
||||
|
||||
# D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке
|
||||
# это приводит к повторным звонкам и скрывает от других служб факт реакции.
|
||||
# REFUSED — отдельный допустимый терминальный статус с обязательной причиной.
|
||||
missing_progress = [status for status in PROGRESS if status not in {m.status for m in marks}]
|
||||
if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
|
||||
and (missing_progress or latest is not ServiceStatus.COMPLETED)):
|
||||
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D6,
|
||||
f"{service}: работы завершены без отметок хода",
|
||||
fact="начало реагирования и прибытие не отмечены",
|
||||
norm="статусы хода работ проставляются по факту",
|
||||
f"{service}: ход реагирования не доведён до конца",
|
||||
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"),
|
||||
norm=(
|
||||
"отметить по факту начало реагирования, прибытие, проведение работ "
|
||||
"и завершение; если работы не проводились — оформить отказ с причиной"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
|
||||
def dispatcher_metrics(
|
||||
state,
|
||||
deadline_ms: int,
|
||||
expected_decision: str = "accept",
|
||||
expected_decision_reason: str | None = None,
|
||||
) -> list[Metric]:
|
||||
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
|
||||
|
||||
Веса предварительные — до утверждения методистом. Телефон — возможный
|
||||
|
|
@ -156,19 +315,40 @@ def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
|
|||
f"≤ {deadline_ms // 1000} с")
|
||||
if primary is None:
|
||||
continue
|
||||
add("dds_decision", "профильное реагирование",
|
||||
primary.status is ServiceStatus.ACCEPTED,
|
||||
primary.status.value, "профильную заявку принять", 2.0)
|
||||
if primary.status is ServiceStatus.DECLINED:
|
||||
expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"
|
||||
else ServiceStatus.DECLINED)
|
||||
expected_label = "Принята" if expected_decision == "accept" else "Не принята"
|
||||
add("dds_decision", "решение по эталону сценария",
|
||||
primary.status is expected_status,
|
||||
primary.status.value,
|
||||
expected_decision_reason or f"по эталону ожидается «{expected_label}»", 2.0)
|
||||
if primary.status is ServiceStatus.DECLINED or expected_decision == "decline":
|
||||
continue
|
||||
crew = state.crew_assignments.get(service)
|
||||
add("dds_crew", "назначение бригады", bool(crew),
|
||||
crew or "бригада не выбрана",
|
||||
"необходимую бригаду выбирает ДДС вручную", 2.0)
|
||||
expected = {
|
||||
ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING,
|
||||
}
|
||||
add("dds_progress", "ведение хода реагирования", expected <= statuses,
|
||||
", ".join(status.value for status in statuses) or "статусов нет",
|
||||
"начало реагирования, прибытие и работы отмечены по полученной информации", 2.0)
|
||||
refused = ServiceStatus.REFUSED in statuses
|
||||
add("dds_progress", "ведение хода реагирования",
|
||||
expected <= statuses or refused,
|
||||
("отказ от выполнения работ" if refused else
|
||||
", ".join(mark.status.value for mark in marks if mark.status in expected)
|
||||
or "статусов хода нет"),
|
||||
"отметить по факту ход работ либо оформить обоснованный отказ от их выполнения", 2.0)
|
||||
add("dds_completion", "закрытие работ",
|
||||
ServiceStatus.COMPLETED in statuses,
|
||||
"завершено" if ServiceStatus.COMPLETED in statuses else "не завершено",
|
||||
"по факту поставить статус «Работы завершены»")
|
||||
ServiceStatus.COMPLETED in statuses or refused,
|
||||
("завершено" if ServiceStatus.COMPLETED in statuses else
|
||||
"оформлен отказ от выполнения работ" if refused else "не завершено"),
|
||||
"зафиксировать фактическое завершение работ или отказ от их выполнения")
|
||||
notes = [mark.comment.strip() for mark in marks if mark.status in COMMENT_REQUIRED]
|
||||
notes_complete = bool(notes) and all(
|
||||
_has_basis_and_information(note) for note in notes
|
||||
)
|
||||
add("dds_reply", "основание статусов и сведения о ходе работ",
|
||||
notes_complete,
|
||||
"; ".join(notes) if notes else "комментарии к статусам не внесены",
|
||||
"к каждой ручной отметке добавить основание и содержание полученных сведений")
|
||||
return metrics
|
||||
|
|
|
|||
Loading…
Reference in a new issue