Merge branch 'feat/report-reaction'
lct-37: норматив реакции ДДС на доклад бригады
This commit is contained in:
commit
b66cf14e3f
15 changed files with 475 additions and 20 deletions
|
|
@ -53,6 +53,8 @@ class LessonCriteria(BaseModel):
|
||||||
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
|
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)
|
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_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)
|
allowed_errors: int = Field(default=0, ge=0, le=50)
|
||||||
require_correct_grammar: bool = True
|
require_correct_grammar: bool = True
|
||||||
score_weights: dict[str, float] = Field(default_factory=dict)
|
score_weights: dict[str, float] = Field(default_factory=dict)
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,14 @@
|
||||||
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
|
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
from app.domain.statuses import (
|
from app.domain.statuses import (
|
||||||
COMMENT_REQUIRED,
|
COMMENT_REQUIRED,
|
||||||
PRIMARY,
|
PRIMARY,
|
||||||
REFUSAL_COMMENT_REQUIRED,
|
REFUSAL_COMMENT_REQUIRED,
|
||||||
SERVICE_STATUS_LABELS,
|
SERVICE_STATUS_LABELS,
|
||||||
|
PhoneReportRecord,
|
||||||
ServiceStatus,
|
ServiceStatus,
|
||||||
StatusEntry,
|
StatusEntry,
|
||||||
current,
|
current,
|
||||||
|
|
@ -23,10 +26,102 @@ from app.domain.statuses import (
|
||||||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||||
from app.domain.events import Metric
|
from app.domain.events import Metric
|
||||||
from app.domain.timers import GOST_REF
|
from app.domain.timers import GOST_REF
|
||||||
|
from app.scoring.timing import TimeNorm, time_metric
|
||||||
|
|
||||||
|
MEMO_REF = "памятка «Работа на АРМ-112», раздел «Статусы реагирования»"
|
||||||
|
|
||||||
#: Статусы, означающие, что реагирование действительно шло.
|
#: Статусы, означающие, что реагирование действительно шло.
|
||||||
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
|
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
|
||||||
|
#: Реакция на доклад — норматив времени, как отработка карточки, но отметка
|
||||||
|
#: D6 из памятки: опоздавший статус — тот же неполный ход работ.
|
||||||
|
DDS_REPORT_REACTION = TimeNorm(
|
||||||
|
key="dds_report_reaction",
|
||||||
|
title="реакция на доклад бригады",
|
||||||
|
summary="статус по докладу бригады отмечен с нарушением норматива",
|
||||||
|
action="отметить статус по докладу",
|
||||||
|
unmeasured="не отмечен",
|
||||||
|
incomplete="не отмечен",
|
||||||
|
code=ErrorCode.D6,
|
||||||
|
source=FindingSource.DISPATCHER,
|
||||||
|
ref=MEMO_REF,
|
||||||
|
)
|
||||||
|
#: Терминальный отказ: работ не было, поэтому неотмеченный по докладу статус
|
||||||
|
#: не штрафуется ни отметкой, ни метрикой.
|
||||||
|
TERMINAL_REFUSAL = {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
|
||||||
|
|
||||||
|
|
||||||
|
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 report_reaction(
|
||||||
|
service: str, marks: list[StatusEntry], reports: list[PhoneReportRecord], limit_ms: int,
|
||||||
|
) -> tuple[Metric, Finding | None] | None:
|
||||||
|
"""Метрика и отметка D6 по самой поздней реакции службы на доклады бригады.
|
||||||
|
|
||||||
|
Одна функция для отметок и для балла: иначе метрика провалится там,
|
||||||
|
где отметки нет. None — реагировать было не на что.
|
||||||
|
"""
|
||||||
|
refused = current(marks, service) in TERMINAL_REFUSAL
|
||||||
|
reactions = [(report, delay) for report, delay in report_reactions(marks, reports)
|
||||||
|
if delay is not None or not refused]
|
||||||
|
if not reactions:
|
||||||
|
return None
|
||||||
|
report, delay = max(reactions, key=lambda item: math.inf if item[1] is None else item[1])
|
||||||
|
metric, finding = time_metric(DDS_REPORT_REACTION, delay, limit_ms, completed=True)
|
||||||
|
label = SERVICE_STATUS_LABELS[REPORT_STATUS[report.phase]]
|
||||||
|
detail = "отмечен не позже доклада" if delay == 0 else metric.fact
|
||||||
|
fact = (f"статус «{label}» по докладу бригады «{report.crew}» "
|
||||||
|
f"{REPORT_LABELS[report.phase]}: {detail}")
|
||||||
|
if len(reactions) > 1:
|
||||||
|
late = sum(item is None or item > limit_ms for _, item in reactions)
|
||||||
|
fact += f"; с нарушением {late} из {len(reactions)} докладов"
|
||||||
|
metric = metric.model_copy(update={
|
||||||
|
"title": f"{service}: {metric.title}", "fact": fact, "service": service,
|
||||||
|
})
|
||||||
|
if finding is not None:
|
||||||
|
finding = finding.model_copy(update={
|
||||||
|
"summary": f"{service}: {finding.summary}", "fact": fact, "service": service,
|
||||||
|
})
|
||||||
|
return metric, finding
|
||||||
|
|
||||||
|
|
||||||
def _finding(service: str, code: ErrorCode, summary: str, fact: str,
|
def _finding(service: str, code: ErrorCode, summary: str, fact: str,
|
||||||
norm: str | None = None) -> Finding:
|
norm: str | None = None) -> Finding:
|
||||||
|
|
@ -37,7 +132,7 @@ def _finding(service: str, code: ErrorCode, summary: str, fact: str,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
fact=fact,
|
fact=fact,
|
||||||
norm=norm,
|
norm=norm,
|
||||||
ref="памятка «Работа на АРМ-112», раздел «Статусы реагирования»",
|
ref=MEMO_REF,
|
||||||
competency=(
|
competency=(
|
||||||
Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD
|
Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD
|
||||||
),
|
),
|
||||||
|
|
@ -93,6 +188,8 @@ def evaluate_dispatcher(
|
||||||
reply_text: str = "",
|
reply_text: str = "",
|
||||||
expected_decision: str = "accept",
|
expected_decision: str = "accept",
|
||||||
expected_decision_reason: str | None = None,
|
expected_decision_reason: str | None = None,
|
||||||
|
phone_reports: list[PhoneReportRecord] | None = None,
|
||||||
|
report_reaction_ms: int = REPORT_REACTION_MS,
|
||||||
) -> list[Finding]:
|
) -> list[Finding]:
|
||||||
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
|
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
|
||||||
|
|
||||||
|
|
@ -264,15 +361,18 @@ def evaluate_dispatcher(
|
||||||
# D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке
|
# D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке
|
||||||
# это приводит к повторным звонкам и скрывает от других служб факт реакции.
|
# это приводит к повторным звонкам и скрывает от других служб факт реакции.
|
||||||
# REFUSED — отдельный допустимый терминальный статус с обязательной причиной.
|
# 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}]
|
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}
|
if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
|
||||||
and (missing_progress or latest is not ServiceStatus.COMPLETED)):
|
and (missing_progress or latest is not ServiceStatus.COMPLETED)):
|
||||||
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
|
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
|
||||||
|
reported_done = any(report.phase == "completed" for report in reports)
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
service, ErrorCode.D6,
|
service, ErrorCode.D6,
|
||||||
f"{service}: ход реагирования не доведён до конца",
|
f"{service}: ход реагирования не доведён до конца",
|
||||||
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"),
|
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта")
|
||||||
|
+ ("; бригада доложила о завершении работ" if reported_done else ""),
|
||||||
norm=(
|
norm=(
|
||||||
"отметить по факту начало реагирования, прибытие, проведение работ "
|
"отметить по факту начало реагирования, прибытие, проведение работ "
|
||||||
"и завершение; если работы не проводились — оформить отказ с причиной"
|
"и завершение; если работы не проводились — оформить отказ с причиной"
|
||||||
|
|
@ -280,6 +380,11 @@ def evaluate_dispatcher(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
|
||||||
|
reaction = report_reaction(service, marks, reports, report_reaction_ms)
|
||||||
|
if reaction is not None and reaction[1] is not None:
|
||||||
|
findings.append(reaction[1])
|
||||||
|
|
||||||
return findings
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -290,6 +395,7 @@ def dispatcher_metrics(
|
||||||
expected_decision_reason: str | None = None,
|
expected_decision_reason: str | None = None,
|
||||||
*,
|
*,
|
||||||
services: list[str] | None = None,
|
services: list[str] | None = None,
|
||||||
|
report_reaction_ms: int = REPORT_REACTION_MS,
|
||||||
) -> list[Metric]:
|
) -> list[Metric]:
|
||||||
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
|
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
|
||||||
|
|
||||||
|
|
@ -319,6 +425,12 @@ def dispatcher_metrics(
|
||||||
f"≤ {deadline_ms // 1000} с")
|
f"≤ {deadline_ms // 1000} с")
|
||||||
if primary is None:
|
if primary is None:
|
||||||
continue
|
continue
|
||||||
|
# Метрика есть только там, где бригада докладывала: без докладов
|
||||||
|
# реагировать не на что, и отсутствие звонков не штраф.
|
||||||
|
reports = [report for report in getattr(state, "phone_reports", ()) if report.service == service]
|
||||||
|
reaction = report_reaction(service, marks, reports, report_reaction_ms)
|
||||||
|
if reaction is not None:
|
||||||
|
metrics.append(reaction[0])
|
||||||
expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"
|
expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"
|
||||||
else ServiceStatus.DECLINED)
|
else ServiceStatus.DECLINED)
|
||||||
expected_label = "Принята" if expected_decision == "accept" else "Не принята"
|
expected_label = "Принята" if expected_decision == "accept" else "Не принята"
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ def to_csv(report: SessionReport) -> bytes:
|
||||||
row("Занятие", "", "Режим", report.mode.value)
|
row("Занятие", "", "Режим", report.mode.value)
|
||||||
row("Занятие", "", "Попытка", report.attempt)
|
row("Занятие", "", "Попытка", report.attempt)
|
||||||
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
|
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
|
||||||
|
row("Критерии", "", "Лимит реакции на доклад бригады, с",
|
||||||
|
report.criteria.dds_report_reaction_limit_seconds)
|
||||||
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
|
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
|
||||||
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
|
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
|
||||||
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
|
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
|
||||||
|
|
@ -170,6 +172,7 @@ def to_pdf(report: SessionReport) -> bytes:
|
||||||
("Режим", report.mode.value),
|
("Режим", report.mode.value),
|
||||||
("Попытка", report.attempt),
|
("Попытка", report.attempt),
|
||||||
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
|
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
|
||||||
|
("Лимит реакции на доклад бригады", f"{report.criteria.dds_report_reaction_limit_seconds} с"),
|
||||||
("Допустимо ошибок", report.criteria.allowed_errors),
|
("Допустимо ошибок", report.criteria.allowed_errors),
|
||||||
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
|
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
|
||||||
("Переопределения весов", report.criteria.score_weights or "стандартные"),
|
("Переопределения весов", report.criteria.score_weights or "стандартные"),
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
||||||
"dds_progress": (ErrorCode.D6, Competency.CARD),
|
"dds_progress": (ErrorCode.D6, Competency.CARD),
|
||||||
"dds_completion": (ErrorCode.D6, Competency.CARD),
|
"dds_completion": (ErrorCode.D6, Competency.CARD),
|
||||||
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
|
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
|
||||||
|
"dds_report_reaction": (ErrorCode.D6, Competency.NORMS),
|
||||||
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
|
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
|
||||||
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
|
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
|
||||||
}
|
}
|
||||||
|
|
@ -64,4 +65,5 @@ METRIC_WEIGHTS: dict[str, float] = {
|
||||||
"description_grammar": 1.0,
|
"description_grammar": 1.0,
|
||||||
"card_fill_time": 1.5,
|
"card_fill_time": 1.5,
|
||||||
"dds_work_time": 1.5,
|
"dds_work_time": 1.5,
|
||||||
|
"dds_report_reaction": 1.5,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ def time_credit(elapsed_ms: int | None, limit_ms: int) -> float:
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TimeNorm:
|
class TimeNorm:
|
||||||
"""Формулировки норматива времени: карточка 112 и карточка ДДС различаются
|
"""Формулировки норматива времени: нормативы различаются словами и кодом
|
||||||
только словами, расчёт и отметка E3 у них общие."""
|
отметки, расчёт вклада и формат факта у них общие."""
|
||||||
|
|
||||||
key: str
|
key: str
|
||||||
title: str
|
title: str
|
||||||
|
|
@ -32,6 +32,10 @@ class TimeNorm:
|
||||||
action: str
|
action: str
|
||||||
unmeasured: str
|
unmeasured: str
|
||||||
incomplete: str
|
incomplete: str
|
||||||
|
code: ErrorCode = ErrorCode.E3
|
||||||
|
source: FindingSource = FindingSource.TIMERS
|
||||||
|
competency: Competency = Competency.NORMS
|
||||||
|
ref: str = TIME_REF
|
||||||
|
|
||||||
|
|
||||||
CARD_FILL_TIME = TimeNorm(
|
CARD_FILL_TIME = TimeNorm(
|
||||||
|
|
@ -55,7 +59,7 @@ DDS_WORK_TIME = TimeNorm(
|
||||||
def time_metric(
|
def time_metric(
|
||||||
norm: TimeNorm, elapsed_ms: int | None, limit_ms: int, *, completed: bool
|
norm: TimeNorm, elapsed_ms: int | None, limit_ms: int, *, completed: bool
|
||||||
) -> tuple[Metric, Finding | None]:
|
) -> tuple[Metric, Finding | None]:
|
||||||
"""Метрика норматива и отметка E3, если он не выполнен.
|
"""Метрика норматива и отметка (по умолчанию E3), если он не выполнен.
|
||||||
|
|
||||||
Незавершённая работа не получает временного вклада: быстрое, но брошенное
|
Незавершённая работа не получает временного вклада: быстрое, но брошенное
|
||||||
заполнение не должно выглядеть скоростью.
|
заполнение не должно выглядеть скоростью.
|
||||||
|
|
@ -64,13 +68,20 @@ def time_metric(
|
||||||
fact = norm.unmeasured
|
fact = norm.unmeasured
|
||||||
else:
|
else:
|
||||||
delta_ms = elapsed_ms - limit_ms
|
delta_ms = elapsed_ms - limit_ms
|
||||||
|
# Превышение до полусекунды при округлении до целых читалось бы
|
||||||
|
# как «45 с (+0 с)» при нормативе 45 с — тогда нужна десятая доля.
|
||||||
|
precise = 0 < delta_ms <= 500
|
||||||
|
|
||||||
|
def seconds(ms: int) -> str:
|
||||||
|
return f"{ms / 1000:.1f}" if precise else f"{round(ms / 1000)}"
|
||||||
|
|
||||||
if delta_ms > 0:
|
if delta_ms > 0:
|
||||||
deviation = f"+{round(delta_ms / 1000)} с сверх норматива"
|
deviation = f"+{seconds(delta_ms)} с сверх норматива"
|
||||||
elif delta_ms < 0:
|
elif delta_ms < 0:
|
||||||
deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива"
|
deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива"
|
||||||
else:
|
else:
|
||||||
deviation = "точно в норматив"
|
deviation = "точно в норматив"
|
||||||
fact = f"{round(elapsed_ms / 1000)} с ({deviation})"
|
fact = f"{seconds(elapsed_ms)} с ({deviation})"
|
||||||
if not completed:
|
if not completed:
|
||||||
fact = f"{norm.incomplete}; {fact}"
|
fact = f"{norm.incomplete}; {fact}"
|
||||||
norm_text = f"{norm.action} за {round(limit_ms / 1000)} с"
|
norm_text = f"{norm.action} за {round(limit_ms / 1000)} с"
|
||||||
|
|
@ -80,7 +91,7 @@ def time_metric(
|
||||||
title=norm.title,
|
title=norm.title,
|
||||||
fact=fact,
|
fact=fact,
|
||||||
norm=norm_text,
|
norm=norm_text,
|
||||||
ref=TIME_REF,
|
ref=norm.ref,
|
||||||
passed=passed,
|
passed=passed,
|
||||||
weight=METRIC_WEIGHTS[norm.key],
|
weight=METRIC_WEIGHTS[norm.key],
|
||||||
credit=time_credit(elapsed_ms, limit_ms) if completed else 0.0,
|
credit=time_credit(elapsed_ms, limit_ms) if completed else 0.0,
|
||||||
|
|
@ -88,12 +99,12 @@ def time_metric(
|
||||||
if passed:
|
if passed:
|
||||||
return metric, None
|
return metric, None
|
||||||
return metric, Finding(
|
return metric, Finding(
|
||||||
code=ErrorCode.E3,
|
code=norm.code,
|
||||||
source=FindingSource.TIMERS,
|
source=norm.source,
|
||||||
summary=norm.summary,
|
summary=norm.summary,
|
||||||
fact=fact,
|
fact=fact,
|
||||||
norm=norm_text,
|
norm=norm_text,
|
||||||
ref=TIME_REF,
|
ref=norm.ref,
|
||||||
competency=Competency.NORMS,
|
competency=norm.competency,
|
||||||
metric_key=norm.key,
|
metric_key=norm.key,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
||||||
services = state.card_services(card)
|
services = state.card_services(card)
|
||||||
decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK]
|
decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK]
|
||||||
dds_decision = card.scenario.dds_decision
|
dds_decision = card.scenario.dds_decision
|
||||||
|
reaction_ms = state.criteria.dds_report_reaction_limit_seconds * 1000
|
||||||
findings = evaluate_dispatcher(
|
findings = evaluate_dispatcher(
|
||||||
entries=card.status_log,
|
entries=card.status_log,
|
||||||
services=services,
|
services=services,
|
||||||
|
|
@ -57,10 +58,12 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
||||||
reply_text=card.reply_text,
|
reply_text=card.reply_text,
|
||||||
expected_decision=dds_decision.expected,
|
expected_decision=dds_decision.expected,
|
||||||
expected_decision_reason=dds_decision.reason,
|
expected_decision_reason=dds_decision.reason,
|
||||||
|
phone_reports=card.phone_reports,
|
||||||
|
report_reaction_ms=reaction_ms,
|
||||||
)
|
)
|
||||||
metrics = dispatcher_metrics(
|
metrics = dispatcher_metrics(
|
||||||
card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
|
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)
|
weighted = GostResult(metrics=metrics, findings=findings)
|
||||||
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]
|
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,7 @@ async def score_session(
|
||||||
if live_card is not None:
|
if live_card is not None:
|
||||||
decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK]
|
decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK]
|
||||||
services = list(snapshot.call_card_services)
|
services = list(snapshot.call_card_services)
|
||||||
|
reaction_ms = snapshot.criteria.dds_report_reaction_limit_seconds * 1000
|
||||||
result.findings.extend(evaluate_dispatcher(
|
result.findings.extend(evaluate_dispatcher(
|
||||||
entries=live_card.status_log,
|
entries=live_card.status_log,
|
||||||
services=services,
|
services=services,
|
||||||
|
|
@ -228,10 +229,12 @@ async def score_session(
|
||||||
reply_text=live_card.reply_text,
|
reply_text=live_card.reply_text,
|
||||||
expected_decision=scenario.dds_decision.expected,
|
expected_decision=scenario.dds_decision.expected,
|
||||||
expected_decision_reason=scenario.dds_decision.reason,
|
expected_decision_reason=scenario.dds_decision.reason,
|
||||||
|
phone_reports=live_card.phone_reports,
|
||||||
|
report_reaction_ms=reaction_ms,
|
||||||
))
|
))
|
||||||
result.metrics.extend(dispatcher_metrics(
|
result.metrics.extend(dispatcher_metrics(
|
||||||
live_card, decision_limit_ms, scenario.dds_decision.expected,
|
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,
|
||||||
))
|
))
|
||||||
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
|
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
|
||||||
# занятия. Веса первого сценария поверх них испортили бы остальные.
|
# занятия. Веса первого сценария поверх них испортили бы остальные.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -396,6 +396,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
|
||||||
client,
|
client,
|
||||||
criteria={
|
criteria={
|
||||||
"decision_time_limit_seconds": 45,
|
"decision_time_limit_seconds": 45,
|
||||||
|
"dds_report_reaction_limit_seconds": 20,
|
||||||
"allowed_errors": 50,
|
"allowed_errors": 50,
|
||||||
"require_correct_grammar": False,
|
"require_correct_grammar": False,
|
||||||
"score_weights": {"dds_ack": 3.5},
|
"score_weights": {"dds_ack": 3.5},
|
||||||
|
|
@ -454,6 +455,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
|
||||||
"decision_time_limit_seconds": 45,
|
"decision_time_limit_seconds": 45,
|
||||||
"card_fill_time_limit_seconds": 180,
|
"card_fill_time_limit_seconds": 180,
|
||||||
"dds_card_work_time_limit_seconds": 180,
|
"dds_card_work_time_limit_seconds": 180,
|
||||||
|
"dds_report_reaction_limit_seconds": 20,
|
||||||
"allowed_errors": 50,
|
"allowed_errors": 50,
|
||||||
"require_correct_grammar": False,
|
"require_correct_grammar": False,
|
||||||
"score_weights": {"dds_ack": 3.5},
|
"score_weights": {"dds_ack": 3.5},
|
||||||
|
|
@ -797,6 +799,118 @@ def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
|
||||||
]
|
]
|
||||||
assert all(report.crew == crew for report in state.desk.active.phone_reports)
|
assert all(report.crew == crew for report in state.desk.active.phone_reports)
|
||||||
assert not score["findings"]
|
assert not score["findings"]
|
||||||
|
reaction = [m for m in score["metrics"] if m["key"] == "dds_report_reaction"]
|
||||||
|
assert len(reaction) == 1 and reaction[0]["passed"], "каждый доклад отмечен вовремя"
|
||||||
|
assert "с нарушением 0 из 4 докладов" in reaction[0]["fact"]
|
||||||
|
finally:
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
STATUS_COMMENT = "Основание: доклад бригады.\nСведения: передано старшему бригады."
|
||||||
|
|
||||||
|
|
||||||
|
def accept_with_crew(station):
|
||||||
|
read_until(station, "card.received")
|
||||||
|
snapshot = read_until(station, "station.state")["snapshot"]
|
||||||
|
service = snapshot["services"][0]
|
||||||
|
crew = next(option for option in snapshot["crew_options"]
|
||||||
|
if option.startswith(service + " — "))
|
||||||
|
station.send_json({"type": "card.status", "service": service, "status": "accepted",
|
||||||
|
"comment": STATUS_COMMENT})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
station.send_json({"type": "crew.select", "crew": crew})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
def send_status(station, service, status):
|
||||||
|
station.send_json({"type": "card.status", "service": service, "status": status,
|
||||||
|
"comment": STATUS_COMMENT})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("reaction", "passed"), [
|
||||||
|
("fast", True), ("slow", False), ("missing", False), ("early", True),
|
||||||
|
])
|
||||||
|
def test_dds_report_reaction_goes_from_criteria_to_report(client, reaction, passed):
|
||||||
|
"""Лимит из `scenario.start` доходит до метрики, отметки D6 и отчёта."""
|
||||||
|
session_id, control = start(client, criteria={
|
||||||
|
"dds_report_reaction_limit_seconds": 20,
|
||||||
|
"score_weights": {"dds_report_reaction": 3.5},
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
|
service = accept_with_crew(station)
|
||||||
|
if reaction == "early":
|
||||||
|
send_status(station, service, "responding")
|
||||||
|
complete_phone_call(station, state, "dispatched")
|
||||||
|
read_until(station, "station.state")
|
||||||
|
if reaction in {"fast", "slow"}:
|
||||||
|
send_status(station, service, "responding")
|
||||||
|
if reaction == "slow":
|
||||||
|
# Доклад на 70 с раньше статуса: время доклада — серверное.
|
||||||
|
state.desk.active.phone_reports[0].at -= timedelta(seconds=70)
|
||||||
|
station.send_json({"type": "station.finish"})
|
||||||
|
read_until(station, "score.ready")
|
||||||
|
|
||||||
|
score = wait_for(lambda: hub.get(session_id).score)
|
||||||
|
metrics = [item for item in score["metrics"] if item["key"] == "dds_report_reaction"]
|
||||||
|
assert len(metrics) == 1
|
||||||
|
metric = metrics[0]
|
||||||
|
assert metric["passed"] is passed
|
||||||
|
assert metric["weight"] == 3.5
|
||||||
|
assert metric["norm"] == "отметить статус по докладу за 20 с"
|
||||||
|
assert "«Начало реагирования» по докладу бригады" in metric["fact"]
|
||||||
|
findings = [item for item in score["findings"]
|
||||||
|
if item["metric_key"] == "dds_report_reaction"]
|
||||||
|
assert [item["code"] for item in findings] == ([] if passed else ["D6"])
|
||||||
|
assert all(item["competency"] == "norms" for item in findings)
|
||||||
|
if reaction == "slow":
|
||||||
|
assert "70 с (+50 с сверх норматива)" in metric["fact"]
|
||||||
|
assert findings[0]["fact"] == metric["fact"]
|
||||||
|
if reaction == "missing":
|
||||||
|
assert metric["fact"].endswith(": не отмечен") and metric["credit"] == 0.0
|
||||||
|
if reaction == "early":
|
||||||
|
assert metric["fact"].endswith("отмечен не позже доклада")
|
||||||
|
|
||||||
|
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||||
|
assert report["criteria"]["dds_report_reaction_limit_seconds"] == 20
|
||||||
|
assert [item for item in report["metrics"]
|
||||||
|
if item["key"] == "dds_report_reaction"] == metrics
|
||||||
|
if reaction == "slow":
|
||||||
|
csv_text = client.get(f"/api/sessions/{session_id}/report.csv").content.decode("utf-8-sig")
|
||||||
|
assert "Лимит реакции на доклад бригады" in csv_text
|
||||||
|
assert "70 с (+50 с сверх норматива)" in csv_text
|
||||||
|
finally:
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_refusal_after_crew_report_does_not_lower_score_without_finding(client):
|
||||||
|
"""Бригада доложила о прибытии, но работы не проводились: отказ с причиной
|
||||||
|
— терминальный исход, неотмеченное «Прибытие» не штрафуется."""
|
||||||
|
session_id, control = start(client)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
|
service = accept_with_crew(station)
|
||||||
|
complete_phone_call(station, state, "dispatched")
|
||||||
|
read_until(station, "station.state")
|
||||||
|
send_status(station, service, "responding")
|
||||||
|
complete_phone_call(station, state, "arrived")
|
||||||
|
read_until(station, "station.state")
|
||||||
|
send_status(station, service, "refused")
|
||||||
|
station.send_json({"type": "station.finish"})
|
||||||
|
read_until(station, "score.ready")
|
||||||
|
|
||||||
|
score = wait_for(lambda: hub.get(session_id).score)
|
||||||
|
assert [report.phase for report in state.desk.active.phone_reports] == [
|
||||||
|
"dispatched", "arrived",
|
||||||
|
]
|
||||||
|
metric = next(item for item in score["metrics"] if item["key"] == "dds_report_reaction")
|
||||||
|
assert metric["passed"], "прибытие при отказе не требуется"
|
||||||
|
assert "о выезде" in metric["fact"] and "докладов" not in metric["fact"]
|
||||||
|
assert not [item for item in score["findings"] if item["code"] == "D6"]
|
||||||
finally:
|
finally:
|
||||||
control.__exit__(None, None, None)
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from app.domain.statuses import ServiceStatus, StatusEntry
|
from app.domain.statuses import PhoneReportRecord, ServiceStatus, StatusEntry
|
||||||
from app.domain.taxonomy import ErrorCode
|
from app.domain.taxonomy import Competency, ErrorCode, FindingSource
|
||||||
from app.scenarios.loader import load_file
|
from app.scenarios.loader import load_file
|
||||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||||
|
from app.scoring.timing import time_credit
|
||||||
from app.session.dds import DdsLiveCard, build_card
|
from app.session.dds import DdsLiveCard, build_card
|
||||||
from app.session.timers import SessionTimers
|
from app.session.timers import SessionTimers
|
||||||
|
|
||||||
|
|
@ -294,3 +295,158 @@ def test_reasoned_refusal_after_acceptance_is_a_valid_terminal_path():
|
||||||
deadline_ms=30_000, elapsed_ms=0,
|
deadline_ms=30_000, elapsed_ms=0,
|
||||||
)
|
)
|
||||||
assert ErrorCode.D6 not in [finding.code for finding in findings]
|
assert ErrorCode.D6 not in [finding.code for finding in findings]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Реакция на доклад бригады (lct-37) ──
|
||||||
|
|
||||||
|
T0 = datetime(2026, 9, 27, 10, 0, tzinfo=timezone.utc)
|
||||||
|
COMMENT = "Основание: доклад старшего.\nСведения: передано дежурному."
|
||||||
|
|
||||||
|
|
||||||
|
def at_second(seconds: int) -> datetime:
|
||||||
|
return T0 + timedelta(seconds=seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def mark(status: ServiceStatus, seconds: int) -> StatusEntry:
|
||||||
|
return StatusEntry(service=SERVICES[0], status=status, at=at_second(seconds), comment=COMMENT)
|
||||||
|
|
||||||
|
|
||||||
|
def crew_report(phase: str, seconds: int) -> PhoneReportRecord:
|
||||||
|
return PhoneReportRecord(service=SERVICES[0], crew="Бригада 12", phase=phase,
|
||||||
|
text="доклад", at=at_second(seconds))
|
||||||
|
|
||||||
|
|
||||||
|
def reaction(status_log, reports, limit_ms=45_000):
|
||||||
|
card = live_card(status_log=status_log, crew_assignments={SERVICES[0]: "Бригада 12"},
|
||||||
|
dispatched_at=T0)
|
||||||
|
card.phone_reports = reports
|
||||||
|
findings = evaluate_dispatcher(
|
||||||
|
entries=status_log, services=SERVICES, crew_assignments=card.crew_assignments,
|
||||||
|
deadline_ms=30_000, elapsed_ms=5_000, phone_reports=reports,
|
||||||
|
report_reaction_ms=limit_ms,
|
||||||
|
)
|
||||||
|
metric = next((m for m in dispatcher_metrics(card, 30_000, services=SERVICES,
|
||||||
|
report_reaction_ms=limit_ms)
|
||||||
|
if m.key == "dds_report_reaction"), None)
|
||||||
|
return [f for f in findings if f.metric_key == "dds_report_reaction"], metric
|
||||||
|
|
||||||
|
|
||||||
|
def full_run(arrived_at: int) -> list[StatusEntry]:
|
||||||
|
return [mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20),
|
||||||
|
mark(ServiceStatus.ARRIVED, arrived_at), mark(ServiceStatus.WORKING, arrived_at + 10),
|
||||||
|
mark(ServiceStatus.COMPLETED, arrived_at + 20)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_soon_after_crew_report_passes():
|
||||||
|
findings, metric = reaction(full_run(arrived_at=110), [crew_report("arrived", 100)])
|
||||||
|
|
||||||
|
assert findings == []
|
||||||
|
assert metric.passed and metric.weight == 1.5
|
||||||
|
assert metric.fact == (
|
||||||
|
"статус «Прибытие» по докладу бригады «Бригада 12» о прибытии: "
|
||||||
|
"10 с (на 35 с быстрее норматива)"
|
||||||
|
)
|
||||||
|
assert metric.credit == time_credit(10_000, 45_000)
|
||||||
|
assert metric.title == f"{SERVICES[0]}: реакция на доклад бригады"
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_later_than_limit_after_crew_report_is_d6_with_fact_and_norm():
|
||||||
|
findings, metric = reaction(full_run(arrived_at=170), [crew_report("arrived", 100)])
|
||||||
|
|
||||||
|
assert [f.code for f in findings] == [ErrorCode.D6]
|
||||||
|
assert "«Прибытие»" in findings[0].fact and "70 с (+25 с сверх норматива)" in findings[0].fact
|
||||||
|
assert "о прибытии" in findings[0].fact, "факт ссылается на доклад"
|
||||||
|
assert findings[0].norm == metric.norm == "отметить статус по докладу за 45 с"
|
||||||
|
assert findings[0].competency is Competency.NORMS, "отметка и метрика в одной компетенции"
|
||||||
|
assert findings[0].source is FindingSource.DISPATCHER
|
||||||
|
assert findings[0].service == SERVICES[0]
|
||||||
|
assert not metric.passed and metric.fact == findings[0].fact
|
||||||
|
assert metric.credit == time_credit(70_000, 45_000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_violation_by_a_fraction_of_a_second_is_not_shown_as_the_limit():
|
||||||
|
status_log = full_run(arrived_at=170)
|
||||||
|
status_log[2] = status_log[2].model_copy(
|
||||||
|
update={"at": at_second(100) + timedelta(milliseconds=45_400)},
|
||||||
|
)
|
||||||
|
|
||||||
|
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
|
||||||
|
|
||||||
|
assert not metric.passed
|
||||||
|
assert "45.4 с (+0.4 с сверх норматива)" in findings[0].fact
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_status_after_crew_report_is_d6():
|
||||||
|
status_log = [mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20)]
|
||||||
|
|
||||||
|
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
|
||||||
|
|
||||||
|
assert [f.code for f in findings] == [ErrorCode.D6]
|
||||||
|
assert "не отмечен" in findings[0].fact and "Бригада 12" in findings[0].fact
|
||||||
|
assert not metric.passed and metric.credit == 0.0
|
||||||
|
assert metric.fact == "статус «Прибытие» по докладу бригады «Бригада 12» о прибытии: не отмечен"
|
||||||
|
|
||||||
|
|
||||||
|
def test_metric_takes_the_worst_report_and_counts_violations():
|
||||||
|
reports = [crew_report("dispatched", 10), crew_report("arrived", 100),
|
||||||
|
crew_report("working", 175)]
|
||||||
|
|
||||||
|
findings, metric = reaction(full_run(arrived_at=170), reports)
|
||||||
|
|
||||||
|
assert len(findings) == 1, "одна отметка на службу, как у других нормативов"
|
||||||
|
assert "о прибытии: 70 с" in metric.fact
|
||||||
|
assert "с нарушением 1 из 3 докладов" in metric.fact
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_set_before_the_report_and_custom_limit():
|
||||||
|
findings, metric = reaction(full_run(arrived_at=90), [crew_report("arrived", 100)])
|
||||||
|
assert findings == [] and metric.passed, "статус раньше доклада — не нарушение"
|
||||||
|
assert metric.fact.endswith("отмечен не позже доклада") and metric.credit == 1.0
|
||||||
|
|
||||||
|
strict, _ = reaction(full_run(arrived_at=130), [crew_report("arrived", 100)], limit_ms=20_000)
|
||||||
|
assert strict[0].norm == "отметить статус по докладу за 20 с"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_crew_reports_means_no_reaction_metric():
|
||||||
|
findings, metric = reaction(full_run(arrived_at=110), [])
|
||||||
|
|
||||||
|
assert findings == [] and metric is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_refusal_after_crew_report_is_neither_d6_nor_failed_metric():
|
||||||
|
status_log = [
|
||||||
|
mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20),
|
||||||
|
StatusEntry(service=SERVICES[0], status=ServiceStatus.REFUSED, at=at_second(120),
|
||||||
|
comment="Основание: доклад старшего.\nСведения: бригаде отказано в доступе."),
|
||||||
|
]
|
||||||
|
|
||||||
|
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
|
||||||
|
|
||||||
|
assert findings == [], "работ не было — статус «Прибытие» отмечать не нужно"
|
||||||
|
assert metric is None, "метрика не снижает балл без отметки"
|
||||||
|
|
||||||
|
|
||||||
|
def test_refusal_keeps_late_reaction_on_reported_status():
|
||||||
|
status_log = [
|
||||||
|
mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 80),
|
||||||
|
StatusEntry(service=SERVICES[0], status=ServiceStatus.REFUSED, at=at_second(120),
|
||||||
|
comment="Основание: доклад старшего.\nСведения: бригаде отказано в доступе."),
|
||||||
|
]
|
||||||
|
|
||||||
|
findings, metric = reaction(status_log, [crew_report("dispatched", 10),
|
||||||
|
crew_report("arrived", 100)])
|
||||||
|
|
||||||
|
assert [f.code for f in findings] == [ErrorCode.D6]
|
||||||
|
assert not metric.passed and "о выезде: 70 с" in metric.fact
|
||||||
|
assert "докладов" not in metric.fact, "неотмеченное прибытие при отказе не считается"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfinished_work_after_completion_report_names_the_report():
|
||||||
|
status_log = full_run(arrived_at=110)[:-1]
|
||||||
|
findings = evaluate_dispatcher(
|
||||||
|
entries=status_log, services=SERVICES, crew_assignments={SERVICES[0]: "Бригада 12"},
|
||||||
|
deadline_ms=30_000, elapsed_ms=5_000, phone_reports=[crew_report("completed", 150)],
|
||||||
|
)
|
||||||
|
|
||||||
|
unfinished = next(f for f in findings if f.summary.endswith("не доведён до конца"))
|
||||||
|
assert "бригада доложила о завершении работ" in unfinished.fact
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ def test_csv_contains_sections_and_blocks_formula_injection():
|
||||||
assert any(row[0] == "Занятие" and row[3] == "'=1+1" for row in rows)
|
assert any(row[0] == "Занятие" and row[3] == "'=1+1" for row in rows)
|
||||||
assert any(row[0] == "Ошибки" and row[3] == "'+HYPERLINK(\"x\")" for row in rows)
|
assert any(row[0] == "Ошибки" and row[3] == "'+HYPERLINK(\"x\")" for row in rows)
|
||||||
assert any(row[0] == "Самооценка" and row[3] == "'@SUM(1,2)" for row in rows)
|
assert any(row[0] == "Самооценка" and row[3] == "'@SUM(1,2)" for row in rows)
|
||||||
|
assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "45", ""] in rows
|
||||||
assert _cell(" =cmd") == "' =cmd"
|
assert _cell(" =cmd") == "' =cmd"
|
||||||
assert _cell("\tОбычный текст") == "'\tОбычный текст"
|
assert _cell("\tОбычный текст") == "'\tОбычный текст"
|
||||||
|
|
||||||
|
|
@ -89,6 +90,7 @@ def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
|
||||||
assert "Отчёт по учебному занятию" in extracted
|
assert "Отчёт по учебному занятию" in extracted
|
||||||
assert "Адрес должен быть уточнён" in extracted
|
assert "Адрес должен быть уточнён" in extracted
|
||||||
assert "Заявитель сообщает о дыме" in extracted
|
assert "Заявитель сообщает о дыме" in extracted
|
||||||
|
assert "Лимит реакции на доклад бригады" in extracted
|
||||||
|
|
||||||
|
|
||||||
def test_certificate_pdf_contains_saved_result(tmp_path):
|
def test_certificate_pdf_contains_saved_result(tmp_path):
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,13 @@ from app.domain.events import (
|
||||||
SessionMode,
|
SessionMode,
|
||||||
)
|
)
|
||||||
from app.domain.kio import KIO
|
from app.domain.kio import KIO
|
||||||
from app.domain.statuses import PhoneCallPending, ServiceStatus
|
from app.domain.statuses import PhoneCallPending, PhoneReportRecord, ServiceStatus
|
||||||
from app.domain.taxonomy import ErrorCode, Finding, FindingSource
|
from app.domain.taxonomy import ErrorCode, Finding, FindingSource
|
||||||
from app.domain.timers import TimerCode
|
from app.domain.timers import TimerCode
|
||||||
from app.scenarios.loader import load_file
|
from app.scenarios.loader import load_file
|
||||||
from app.session.checkpoint import dump_state, load_state
|
from app.session.checkpoint import dump_state, load_state
|
||||||
from app.session.dds import prepare_handoff_queue, prepare_queue
|
from app.session.dds import prepare_handoff_queue, prepare_queue
|
||||||
|
from app.session.finish import score_dds_card
|
||||||
from app.session.state import DdsCardRecord, SessionState, now_utc
|
from app.session.state import DdsCardRecord, SessionState, now_utc
|
||||||
|
|
||||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||||
|
|
@ -85,6 +86,38 @@ def test_active_dds_session_round_trips_without_losing_work():
|
||||||
assert timer.limit_ms == 45_000
|
assert timer.limit_ms == 45_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_reaction_keeps_times_and_limit_after_restore():
|
||||||
|
before = dds_state()
|
||||||
|
before.criteria.dds_report_reaction_limit_seconds = 20
|
||||||
|
card = before.desk.active
|
||||||
|
service = before.card_services(card)[0]
|
||||||
|
card.phone_reports.append(PhoneReportRecord(
|
||||||
|
service=service, crew=card.crew_selected, phase="dispatched",
|
||||||
|
text="Бригада выехала", at=now_utc(),
|
||||||
|
))
|
||||||
|
card.set_service_status(service, ServiceStatus.RESPONDING,
|
||||||
|
"Основание: доклад.\nСведения: бригада выехала.")
|
||||||
|
card.phone_reports[0].at -= timedelta(seconds=70)
|
||||||
|
|
||||||
|
restored = load_state(dump_state(before), now_utc())
|
||||||
|
|
||||||
|
assert restored.criteria.dds_report_reaction_limit_seconds == 20
|
||||||
|
assert restored.desk.active.phone_reports == card.phone_reports
|
||||||
|
|
||||||
|
def reaction(state):
|
||||||
|
result = score_dds_card(state, state.desk.active)
|
||||||
|
metric = next(item for item in result.metrics if item.key == "dds_report_reaction")
|
||||||
|
findings = [item for item in result.findings if item.metric_key == "dds_report_reaction"]
|
||||||
|
return metric, findings
|
||||||
|
|
||||||
|
metric, findings = reaction(restored)
|
||||||
|
assert not metric.passed
|
||||||
|
assert metric.norm == "отметить статус по докладу за 20 с"
|
||||||
|
assert "70 с (+50 с сверх норматива)" in metric.fact
|
||||||
|
assert [item.code for item in findings] == [ErrorCode.D6]
|
||||||
|
assert reaction(restored) == reaction(before), "оценка после восстановления та же"
|
||||||
|
|
||||||
|
|
||||||
def completed_card() -> DdsCardRecord:
|
def completed_card() -> DdsCardRecord:
|
||||||
return DdsCardRecord(
|
return DdsCardRecord(
|
||||||
card_id=uuid4(),
|
card_id=uuid4(),
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,9 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
||||||
{` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}</>}
|
{` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}</>}
|
||||||
{report.exercise === "card" && <>Норматив заполнения КИО —
|
{report.exercise === "card" && <>Норматив заполнения КИО —
|
||||||
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>}
|
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>}
|
||||||
|
{/* 112 → ДДС приходит как exercise "card": работу ДДС выдают карточки пульта. */}
|
||||||
|
{(report.exercise === "dds" || Boolean(report.card_results?.length)) && <>; статус по докладу бригады —
|
||||||
|
{` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`}</>}
|
||||||
{report.exercise && report.exercise !== "dds" && <>
|
{report.exercise && report.exercise !== "dds" && <>
|
||||||
; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку
|
; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку
|
||||||
</>}.
|
</>}.
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ const DDS_METRICS: MetricOption[] = [
|
||||||
{ key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." },
|
{ key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." },
|
||||||
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
|
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
|
||||||
{ key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." },
|
{ key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." },
|
||||||
|
{ key: "dds_report_reaction", label: "Реакция на доклад бригады", hint: "Статус по докладу бригады не позже лимита занятия." },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
|
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ export function Instructor() {
|
||||||
const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
|
const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
|
||||||
const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180);
|
const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180);
|
||||||
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
|
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
|
||||||
|
const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45);
|
||||||
const [allowedErrors, setAllowedErrors] = useState(0);
|
const [allowedErrors, setAllowedErrors] = useState(0);
|
||||||
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
|
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
|
||||||
const [lessonScoreWeights, setLessonScoreWeights] = useState<Record<string, number>>({});
|
const [lessonScoreWeights, setLessonScoreWeights] = useState<Record<string, number>>({});
|
||||||
|
|
@ -307,6 +308,7 @@ export function Instructor() {
|
||||||
decision_time_limit_seconds: decisionTimeLimit,
|
decision_time_limit_seconds: decisionTimeLimit,
|
||||||
card_fill_time_limit_seconds: cardFillTimeLimit,
|
card_fill_time_limit_seconds: cardFillTimeLimit,
|
||||||
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
|
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
|
||||||
|
dds_report_reaction_limit_seconds: ddsReportReactionLimit,
|
||||||
allowed_errors: allowedErrors,
|
allowed_errors: allowedErrors,
|
||||||
require_correct_grammar: requireCorrectGrammar,
|
require_correct_grammar: requireCorrectGrammar,
|
||||||
score_weights: lessonScoreWeights,
|
score_weights: lessonScoreWeights,
|
||||||
|
|
@ -314,7 +316,7 @@ export function Instructor() {
|
||||||
});
|
});
|
||||||
if (sent) toStart.current = null;
|
if (sent) toStart.current = null;
|
||||||
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
|
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
|
||||||
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
|
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
|
||||||
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
|
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
|
||||||
randomScenario, visibleScenarios]);
|
randomScenario, visibleScenarios]);
|
||||||
|
|
||||||
|
|
@ -662,6 +664,13 @@ export function Instructor() {
|
||||||
onCommit={setDdsCardWorkTimeLimit} />
|
onCommit={setDdsCardWorkTimeLimit} />
|
||||||
<small className="ref">По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.</small>
|
<small className="ref">По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.</small>
|
||||||
</label>}
|
</label>}
|
||||||
|
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
|
||||||
|
Реакция на доклад бригады, секунд
|
||||||
|
<LessonNumberInput label="Лимит реакции на доклад бригады, секунд"
|
||||||
|
value={ddsReportReactionLimit} min={5} max={600} fallback={45}
|
||||||
|
onCommit={setDdsReportReactionLimit} />
|
||||||
|
<small className="ref">По умолчанию 45 секунд: статус по докладу бригады — не позже лимита.</small>
|
||||||
|
</label>}
|
||||||
<label className="lesson-criterion">
|
<label className="lesson-criterion">
|
||||||
Допустимо нарушенных метрик
|
Допустимо нарушенных метрик
|
||||||
<LessonNumberInput label="Допустимое число ошибок" value={allowedErrors}
|
<LessonNumberInput label="Допустимое число ошибок" value={allowedErrors}
|
||||||
|
|
|
||||||
|
|
@ -400,6 +400,7 @@ export interface LessonCriteria {
|
||||||
decision_time_limit_seconds?: number;
|
decision_time_limit_seconds?: number;
|
||||||
card_fill_time_limit_seconds?: number;
|
card_fill_time_limit_seconds?: number;
|
||||||
dds_card_work_time_limit_seconds?: number;
|
dds_card_work_time_limit_seconds?: number;
|
||||||
|
dds_report_reaction_limit_seconds?: number;
|
||||||
allowed_errors?: number;
|
allowed_errors?: number;
|
||||||
require_correct_grammar?: boolean;
|
require_correct_grammar?: boolean;
|
||||||
score_weights?: Record<string, number>;
|
score_weights?: Record<string, number>;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue