lct-37: норматив реакции ДДС на доклад бригады, отметка D6 и настраиваемый лимит
This commit is contained in:
parent
3f5f57877a
commit
a998b0c2f7
12 changed files with 213 additions and 7 deletions
|
|
@ -53,6 +53,8 @@ class LessonCriteria(BaseModel):
|
|||
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
|
||||
card_fill_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
|
||||
dds_card_work_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
|
||||
#: Сколько секунд после доклада бригады даётся на соответствующий статус.
|
||||
dds_report_reaction_limit_seconds: int = Field(default=45, ge=5, le=600)
|
||||
allowed_errors: int = Field(default=0, ge=0, le=50)
|
||||
require_correct_grammar: bool = True
|
||||
score_weights: dict[str, float] = Field(default_factory=dict)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from app.domain.statuses import (
|
|||
PRIMARY,
|
||||
REFUSAL_COMMENT_REQUIRED,
|
||||
SERVICE_STATUS_LABELS,
|
||||
PhoneReportRecord,
|
||||
ServiceStatus,
|
||||
StatusEntry,
|
||||
current,
|
||||
|
|
@ -27,6 +28,47 @@ from app.domain.timers import GOST_REF
|
|||
#: Статусы, означающие, что реагирование действительно шло.
|
||||
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
|
||||
|
||||
#: Статус, который диспетчер отмечает по докладу бригады этой фазы.
|
||||
REPORT_STATUS = {
|
||||
"dispatched": ServiceStatus.RESPONDING,
|
||||
"arrived": ServiceStatus.ARRIVED,
|
||||
"working": ServiceStatus.WORKING,
|
||||
"completed": ServiceStatus.COMPLETED,
|
||||
}
|
||||
REPORT_LABELS = {
|
||||
"dispatched": "о выезде",
|
||||
"arrived": "о прибытии",
|
||||
"working": "о начале работ",
|
||||
"completed": "о завершении работ",
|
||||
}
|
||||
#: Лимит реакции на доклад по умолчанию; занятие задаёт свой в критериях.
|
||||
REPORT_REACTION_MS = 45_000
|
||||
|
||||
|
||||
def report_reactions(
|
||||
marks: list[StatusEntry], reports: list[PhoneReportRecord],
|
||||
) -> list[tuple[PhoneReportRecord, int | None]]:
|
||||
"""Задержка статуса после каждого доклада бригады, мс; None — статус не отмечен.
|
||||
|
||||
Отсчёт — от серверного времени доклада. Статус, отмеченный раньше доклада,
|
||||
задержки не имеет: диспетчер узнал о ходе работ по другому каналу.
|
||||
"""
|
||||
result: list[tuple[PhoneReportRecord, int | None]] = []
|
||||
for report in reports:
|
||||
status = REPORT_STATUS.get(report.phase)
|
||||
if status is None:
|
||||
continue
|
||||
times = [mark.at for mark in marks if mark.status is status]
|
||||
if any(at <= report.at for at in times):
|
||||
result.append((report, 0))
|
||||
continue
|
||||
after = min(times, default=None)
|
||||
result.append((
|
||||
report,
|
||||
int((after - report.at).total_seconds() * 1000) if after is not None else None,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def _finding(service: str, code: ErrorCode, summary: str, fact: str,
|
||||
norm: str | None = None) -> Finding:
|
||||
|
|
@ -93,6 +135,8 @@ def evaluate_dispatcher(
|
|||
reply_text: str = "",
|
||||
expected_decision: str = "accept",
|
||||
expected_decision_reason: str | None = None,
|
||||
phone_reports: list[PhoneReportRecord] | None = None,
|
||||
report_reaction_ms: int = REPORT_REACTION_MS,
|
||||
) -> list[Finding]:
|
||||
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
|
||||
|
||||
|
|
@ -264,15 +308,18 @@ def evaluate_dispatcher(
|
|||
# D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке
|
||||
# это приводит к повторным звонкам и скрывает от других служб факт реакции.
|
||||
# REFUSED — отдельный допустимый терминальный статус с обязательной причиной.
|
||||
reports = [report for report in phone_reports or () if report.service == service]
|
||||
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)
|
||||
reported_done = any(report.phase == "completed" for report in reports)
|
||||
findings.append(
|
||||
_finding(
|
||||
service, ErrorCode.D6,
|
||||
f"{service}: ход реагирования не доведён до конца",
|
||||
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"),
|
||||
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта")
|
||||
+ ("; бригада доложила о завершении работ" if reported_done else ""),
|
||||
norm=(
|
||||
"отметить по факту начало реагирования, прибытие, проведение работ "
|
||||
"и завершение; если работы не проводились — оформить отказ с причиной"
|
||||
|
|
@ -280,6 +327,32 @@ def evaluate_dispatcher(
|
|||
)
|
||||
)
|
||||
|
||||
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
|
||||
# Код новый не вводится: это тот же неполный ход работ из памятки,
|
||||
# только с измеримым временем реакции на доклад.
|
||||
terminal = latest in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
|
||||
limit_s = report_reaction_ms // 1000
|
||||
for report, delay_ms in report_reactions(marks, reports):
|
||||
label = SERVICE_STATUS_LABELS[REPORT_STATUS[report.phase]]
|
||||
source = f"доклад бригады «{report.crew}» {REPORT_LABELS[report.phase]}"
|
||||
if delay_ms is None and not terminal:
|
||||
finding = _finding(
|
||||
service, ErrorCode.D6,
|
||||
f"{service}: по докладу бригады статус «{label}» не отмечен",
|
||||
fact=f"{source} получен, статус «{label}» не отмечен",
|
||||
norm=f"отметить статус по докладу не позже {limit_s} с",
|
||||
)
|
||||
elif delay_ms is not None and delay_ms > report_reaction_ms:
|
||||
finding = _finding(
|
||||
service, ErrorCode.D6,
|
||||
f"{service}: статус «{label}» отмечен позже норматива после доклада",
|
||||
fact=f"статус «{label}» отмечен через {round(delay_ms / 1000)} с; {source}",
|
||||
norm=f"отметить статус по докладу не позже {limit_s} с",
|
||||
)
|
||||
else:
|
||||
continue
|
||||
findings.append(finding.model_copy(update={"metric_key": "dds_report_reaction"}))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
|
|
@ -290,6 +363,7 @@ def dispatcher_metrics(
|
|||
expected_decision_reason: str | None = None,
|
||||
*,
|
||||
services: list[str] | None = None,
|
||||
report_reaction_ms: int = REPORT_REACTION_MS,
|
||||
) -> list[Metric]:
|
||||
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
|
||||
|
||||
|
|
@ -355,4 +429,17 @@ def dispatcher_metrics(
|
|||
notes_complete,
|
||||
"; ".join(notes) if notes else "комментарии к статусам не внесены",
|
||||
"к каждой ручной отметке добавить основание и содержание полученных сведений")
|
||||
# Метрика есть только там, где бригада докладывала: без докладов
|
||||
# реагировать не на что, и отсутствие звонков не штраф.
|
||||
reports = [report for report in getattr(state, "phone_reports", ()) if report.service == service]
|
||||
reactions = report_reactions(marks, reports)
|
||||
if reactions:
|
||||
add("dds_report_reaction", "реакция на доклады бригады",
|
||||
all(delay is not None and delay <= report_reaction_ms for _, delay in reactions),
|
||||
"; ".join(
|
||||
f"{REPORT_LABELS[report.phase]}: "
|
||||
+ (f"{round(delay / 1000)} с" if delay is not None else "статус не отмечен")
|
||||
for report, delay in reactions
|
||||
),
|
||||
f"статус по каждому докладу не позже {report_reaction_ms // 1000} с")
|
||||
return metrics
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ def to_csv(report: SessionReport) -> bytes:
|
|||
row("Занятие", "", "Режим", report.mode.value)
|
||||
row("Занятие", "", "Попытка", report.attempt)
|
||||
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
|
||||
row("Критерии", "", "Лимит реакции на доклад бригады, с",
|
||||
report.criteria.dds_report_reaction_limit_seconds)
|
||||
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
|
||||
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
|
||||
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
|||
"dds_progress": (ErrorCode.D6, Competency.CARD),
|
||||
"dds_completion": (ErrorCode.D6, Competency.CARD),
|
||||
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
|
||||
"dds_report_reaction": (ErrorCode.D6, Competency.NORMS),
|
||||
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
|
||||
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
|||
services = state.card_services(card)
|
||||
decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK]
|
||||
dds_decision = card.scenario.dds_decision
|
||||
reaction_ms = state.criteria.dds_report_reaction_limit_seconds * 1000
|
||||
findings = evaluate_dispatcher(
|
||||
entries=card.status_log,
|
||||
services=services,
|
||||
|
|
@ -57,10 +58,12 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
|||
reply_text=card.reply_text,
|
||||
expected_decision=dds_decision.expected,
|
||||
expected_decision_reason=dds_decision.reason,
|
||||
phone_reports=card.phone_reports,
|
||||
report_reaction_ms=reaction_ms,
|
||||
)
|
||||
metrics = dispatcher_metrics(
|
||||
card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
|
||||
services=services,
|
||||
services=services, report_reaction_ms=reaction_ms,
|
||||
)
|
||||
weighted = GostResult(metrics=metrics, findings=findings)
|
||||
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ async def score_session(
|
|||
if live_card is not None:
|
||||
decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK]
|
||||
services = list(snapshot.call_card_services)
|
||||
reaction_ms = snapshot.criteria.dds_report_reaction_limit_seconds * 1000
|
||||
result.findings.extend(evaluate_dispatcher(
|
||||
entries=live_card.status_log,
|
||||
services=services,
|
||||
|
|
@ -228,10 +229,12 @@ async def score_session(
|
|||
reply_text=live_card.reply_text,
|
||||
expected_decision=scenario.dds_decision.expected,
|
||||
expected_decision_reason=scenario.dds_decision.reason,
|
||||
phone_reports=live_card.phone_reports,
|
||||
report_reaction_ms=reaction_ms,
|
||||
))
|
||||
result.metrics.extend(dispatcher_metrics(
|
||||
live_card, decision_limit_ms, scenario.dds_decision.expected,
|
||||
scenario.dds_decision.reason, services=services,
|
||||
scenario.dds_decision.reason, services=services, report_reaction_ms=reaction_ms,
|
||||
))
|
||||
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
|
||||
# занятия. Веса первого сценария поверх них испортили бы остальные.
|
||||
|
|
|
|||
Loading…
Reference in a new issue