lct-50: таймеры ДДС — 30 с до открытия карточки, 3 минуты до первой записи
This commit is contained in:
parent
8ff51a8955
commit
51ef99c472
18 changed files with 169 additions and 169 deletions
|
|
@ -24,7 +24,13 @@ from app.db.base import get_session
|
|||
from app.db.models import Group, Score, Session, Trainee
|
||||
from app.domain.events import Exercise, SessionMode, SessionReport
|
||||
from app.domain.roles import Role
|
||||
from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current
|
||||
from app.domain.statuses import (
|
||||
SERVICE_STATUS_LABELS,
|
||||
DdsQueueCard,
|
||||
ServiceStatus,
|
||||
StationSnapshot,
|
||||
current,
|
||||
)
|
||||
from app.domain.timers import TimerCode, TimerState
|
||||
from app.scoring.export import to_csv, to_pdf
|
||||
from app.scoring.report import build as build_report
|
||||
|
|
@ -212,10 +218,12 @@ def _signals(
|
|||
"""
|
||||
settings = get_settings()
|
||||
signals: list[Signal] = []
|
||||
# Необработанная — без первичного статуса: он останавливает норматив
|
||||
# DDS_ACK. Принятая или отклонённая карточка висит в очереди до
|
||||
# «Следующей», но реакции курсанта уже не ждёт.
|
||||
pending = sum(not card.timer_stopped for card in queue)
|
||||
# Необработанная — без первичного статуса. Норматив DDS_ACK теперь
|
||||
# останавливается открытием карточки, поэтому считаем по статусу службы:
|
||||
# открытая, но не принятая и не отклонённая карточка реакции ещё ждёт.
|
||||
pending = sum(
|
||||
card.service_status in {ServiceStatus.ADDED, ServiceStatus.RECEIVED} for card in queue
|
||||
)
|
||||
if pending >= settings.signal_backlog_threshold:
|
||||
signals.append(Signal(
|
||||
kind="backlog", severity=TimerState.WARN,
|
||||
|
|
|
|||
|
|
@ -59,10 +59,11 @@ NORMATIVES: dict[TimerCode, Normative] = {
|
|||
TimerCode.DDS_ACK: Normative(
|
||||
# 30 секунд — ПП РФ № 1931 и памятка АРМ-112, а не ГОСТ: до получения
|
||||
# датасета здесь стояло 4 секунды, взятые по догадке (docs/spec/DATASET.md).
|
||||
# Отсчёт — от появления карточки до её открытия (ответ заказчика, П.5).
|
||||
code=TimerCode.DDS_ACK,
|
||||
title="Подтверждение получения карточки",
|
||||
title="Открытие карточки ДДС",
|
||||
limit_ms=30_000,
|
||||
ref="ПП РФ № 1931",
|
||||
ref="ПП РФ № 1931; ответ заказчика, П.5",
|
||||
),
|
||||
TimerCode.CARD_FILL: Normative(
|
||||
code=TimerCode.CARD_FILL,
|
||||
|
|
@ -72,9 +73,9 @@ NORMATIVES: dict[TimerCode, Normative] = {
|
|||
),
|
||||
TimerCode.DDS_WORK: Normative(
|
||||
code=TimerCode.DDS_WORK,
|
||||
title="Отработка карточки ДДС",
|
||||
title="Первая запись по карточке ДДС",
|
||||
limit_ms=180_000,
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
ref="ТЗ задачи № 9; ответ заказчика, П.5",
|
||||
),
|
||||
TimerCode.ZONE_CHECK: Normative(
|
||||
code=TimerCode.ZONE_CHECK, title="Проверка зоны ответственности", limit_ms=30_000
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from app.domain.statuses import (
|
|||
)
|
||||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||
from app.domain.events import Metric
|
||||
from app.domain.timers import GOST_REF
|
||||
from app.domain.timers import GOST_REF, TimerCode
|
||||
|
||||
#: Статусы, означающие, что реагирование действительно шло.
|
||||
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
|
||||
|
|
@ -161,37 +161,37 @@ def evaluate_dispatcher(
|
|||
),
|
||||
))
|
||||
|
||||
# D1 — первичного статуса нет либо он проставлен позже норматива.
|
||||
# D1 — карточка открыта позже норматива или не открыта вовсе. 30 с идут
|
||||
# от появления карточки в строке сообщений до её открытия (ответ
|
||||
# заказчика, П.5); `elapsed_ms` — зафиксированное время открытия.
|
||||
if elapsed_ms is None or elapsed_ms > deadline_ms:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D1,
|
||||
(f"{service}: карточка открыта с нарушением срока"
|
||||
if elapsed_ms is not None else f"{service}: карточка не открыта"),
|
||||
fact=(
|
||||
f"открыта через {elapsed_ms // 1000} с"
|
||||
if elapsed_ms is not None
|
||||
else "карточка не открыта до конца занятия"
|
||||
),
|
||||
norm=f"открыть карточку в течение {deadline_ms // 1000} с",
|
||||
)
|
||||
)
|
||||
|
||||
# D1 — первичного статуса нет: карточка ушла в «Не оповещено».
|
||||
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
|
||||
if primary is None:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D1,
|
||||
f"{service}: статус реагирования не проставлен",
|
||||
fact=(
|
||||
f"прошло {elapsed_ms // 1000} с"
|
||||
if elapsed_ms is not None
|
||||
else "карточка осталась без отметки"
|
||||
),
|
||||
norm=f"первичный статус в течение {deadline_ms // 1000} с",
|
||||
fact="карточка осталась без первичного статуса",
|
||||
norm="проставить «Принята» или «Не принята» с комментарием",
|
||||
)
|
||||
)
|
||||
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 REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
|
||||
|
|
@ -307,13 +307,14 @@ def dispatcher_metrics(
|
|||
))
|
||||
|
||||
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
|
||||
elapsed = (int((primary.at - state.dispatched_at).total_seconds() * 1000)
|
||||
if primary and state.dispatched_at else None)
|
||||
# Время открытия — то, что зафиксировал таймер событием, а не разница
|
||||
# отметок: 30 с по ответу заказчика идут до открытия карточки.
|
||||
elapsed = state.timers.measured_ms(TimerCode.DDS_ACK)
|
||||
add("dds_primary", "решение по карточке", primary is not None,
|
||||
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
|
||||
add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с",
|
||||
add("dds_ack", f"открытие карточки за {deadline_ms // 1000} с",
|
||||
elapsed is not None and elapsed <= deadline_ms,
|
||||
f"{elapsed / 1000:.1f} с" if elapsed is not None else "не отмечено",
|
||||
f"{elapsed / 1000:.1f} с" if elapsed is not None else "карточка не открыта",
|
||||
f"≤ {deadline_ms // 1000} с")
|
||||
if primary is None:
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ def to_csv(report: SessionReport) -> bytes:
|
|||
row("Занятие", "", "Сценарий", report.scenario_id)
|
||||
row("Занятие", "", "Режим", report.mode.value)
|
||||
row("Занятие", "", "Попытка", report.attempt)
|
||||
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
|
||||
row("Критерии", "", "Лимит открытия карточки ДДС, с", report.criteria.decision_time_limit_seconds)
|
||||
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
|
||||
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
|
||||
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
|
||||
|
|
@ -150,7 +150,7 @@ def to_pdf(report: SessionReport) -> bytes:
|
|||
("Сценарий", report.scenario_id),
|
||||
("Режим", report.mode.value),
|
||||
("Попытка", report.attempt),
|
||||
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
|
||||
("Лимит открытия карточки ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
|
||||
("Допустимо ошибок", report.criteria.allowed_errors),
|
||||
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
|
||||
("Переопределения весов", report.criteria.score_weights or "стандартные"),
|
||||
|
|
|
|||
|
|
@ -44,11 +44,11 @@ CARD_FILL_TIME = TimeNorm(
|
|||
)
|
||||
DDS_WORK_TIME = TimeNorm(
|
||||
key="dds_work_time",
|
||||
title="Отработка карточки ДДС",
|
||||
summary="ДДС: норматив времени отработки карточки не выполнен",
|
||||
action="завершить",
|
||||
unmeasured="время обработки не зафиксировано",
|
||||
incomplete="карточка не завершена",
|
||||
title="Первая запись по карточке ДДС",
|
||||
summary="ДДС: первая запись по карточке позже норматива",
|
||||
action="открыть карточку и добавить первую запись (статус и текст)",
|
||||
unmeasured="карточка не открыта",
|
||||
incomplete="первая запись не добавлена",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -241,12 +241,23 @@ def _refused(message: str) -> DeskOutcome:
|
|||
|
||||
|
||||
def _start_work_timer(card: DdsLiveCard) -> None:
|
||||
"""Три минуты на работу идут один раз — с открытия карточки."""
|
||||
"""Открытие карточки: останавливает 30 с и запускает 3 мин — один раз.
|
||||
|
||||
Автоматическая активация первой карточки очереди открытием не считается:
|
||||
карточка стоит в строке сообщений, пока диспетчер её не откроет. Первое
|
||||
действие по карточке без явного открытия тоже доказывает, что она открыта.
|
||||
"""
|
||||
timer = card.timers.timers.get(TimerCode.DDS_WORK)
|
||||
if timer is None or timer.started_at is None:
|
||||
card.on_event("dds.open")
|
||||
|
||||
|
||||
def _first_record(card: DdsLiveCard, comment: str) -> None:
|
||||
"""Первая запись — ручной статус с текстом; она закрывает 3 минуты (П.5)."""
|
||||
if comment.strip():
|
||||
card.on_event("dds.record")
|
||||
|
||||
|
||||
def _line(card: DdsLiveCard, speaker: str, text: str) -> PhoneLine | None:
|
||||
call = card.phone_pending
|
||||
if call is None:
|
||||
|
|
@ -436,17 +447,17 @@ class DdsDesk(BaseModel):
|
|||
return _refused("Для подтверждения приёма добавьте комментарий с основанием")
|
||||
if any(action == "card.ack" for action, _at, _detail in card.dds_log):
|
||||
return DeskOutcome()
|
||||
card.on_event("card.ack")
|
||||
card.dds_log.append(("card.ack", now_utc(), None))
|
||||
_start_work_timer(card)
|
||||
services = session.card_services(card)
|
||||
if services:
|
||||
_start_work_timer(card)
|
||||
try:
|
||||
card.set_service_status(
|
||||
services[0], ServiceStatus.ACCEPTED, command.comment,
|
||||
author="диспетчер",
|
||||
)
|
||||
session.consecutive_refusals = 0
|
||||
_first_record(card, command.comment)
|
||||
except StatusError:
|
||||
pass # статус уже стоит: повторное нажатие ничего не меняет
|
||||
case "card.status":
|
||||
|
|
@ -460,9 +471,8 @@ class DdsDesk(BaseModel):
|
|||
card.set_service_status(
|
||||
command.service, command.status, command.comment, author="диспетчер"
|
||||
)
|
||||
# Первичный статус останавливает норматив 30 секунд.
|
||||
_first_record(card, command.comment)
|
||||
if command.status in PRIMARY:
|
||||
card.on_event("card.ack")
|
||||
# Сигнал реестра "повторные отказы" считает подряд идущие
|
||||
# отказы, а не сумму за занятие.
|
||||
session.consecutive_refusals = (
|
||||
|
|
@ -470,10 +480,6 @@ class DdsDesk(BaseModel):
|
|||
if command.status is ServiceStatus.DECLINED
|
||||
else 0
|
||||
)
|
||||
if command.status in {
|
||||
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
|
||||
}:
|
||||
card.on_event("dds.complete")
|
||||
case "crew.select":
|
||||
if command.crew not in session.crew_options(card):
|
||||
return _refused("Выберите бригаду из списка доступных")
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import time
|
|||
from uuid import UUID
|
||||
|
||||
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded
|
||||
from app.domain.statuses import ServiceStatus, current
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scoring.ai_coach import coach
|
||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||
|
|
@ -65,16 +64,11 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
|||
work_elapsed_ms = card.timers.measured_ms(TimerCode.DDS_WORK)
|
||||
if work_elapsed_ms is None and work_timer is not None and work_timer.started_at is not None:
|
||||
work_elapsed_ms = work_timer.current_ms(time.monotonic())
|
||||
terminal = bool(services) and all(
|
||||
current(card.status_log, service) in {
|
||||
ServiceStatus.COMPLETED,
|
||||
ServiceStatus.DECLINED,
|
||||
ServiceStatus.REFUSED,
|
||||
}
|
||||
for service in services
|
||||
)
|
||||
# 3 минуты — на открытие и первую запись (статус с текстом), а не на
|
||||
# закрытие: работы по карточке могут идти часы и дни (ответ заказчика, П.5).
|
||||
recorded = any(mark.comment.strip() for mark in card.status_log if mark.service in services)
|
||||
work_metric, work_finding = time_metric(
|
||||
DDS_WORK_TIME, work_elapsed_ms, work_limit_ms, completed=terminal
|
||||
DDS_WORK_TIME, work_elapsed_ms, work_limit_ms, completed=recorded
|
||||
)
|
||||
weighted.metrics.append(work_metric)
|
||||
if work_finding is not None:
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ STARTS: dict[str, tuple[TimerCode, ...]] = {
|
|||
"call.incoming": (TimerCode.ANSWER,),
|
||||
"call.answer": (TimerCode.INTERVIEW,),
|
||||
"dds.dispatch": (TimerCode.DDS_ACK, TimerCode.CLOSE),
|
||||
# Ответ заказчика (П.5): 30 с — от появления карточки в строке сообщений
|
||||
# до её открытия, 3 мин — на открытие и первую запись.
|
||||
"dds.open": (TimerCode.DDS_WORK,),
|
||||
"card.start": (TimerCode.CARD_FILL,),
|
||||
"card.received": (TimerCode.ZONE_CHECK,),
|
||||
|
|
@ -55,10 +57,12 @@ STOPS: dict[str, tuple[TimerCode, ...]] = {
|
|||
# Справка и передача в другой регион заканчивают опрос так же, как передача
|
||||
# карточки: норматив опроса не должен тикать после решения (lct-36).
|
||||
"call.resolve": (TimerCode.INTERVIEW,),
|
||||
"card.ack": (TimerCode.DDS_ACK,),
|
||||
"card.submit": (TimerCode.CARD_FILL,),
|
||||
"card.end": (TimerCode.CARD_FILL,),
|
||||
"dds.complete": (TimerCode.DDS_WORK,),
|
||||
"dds.open": (TimerCode.DDS_ACK,),
|
||||
# Первая запись — первый ручной статус с текстом. Дальше работы могут идти
|
||||
# часы и дни: остальные статусы по времени не нормируются (П.5).
|
||||
"dds.record": (TimerCode.DDS_WORK,),
|
||||
"dds.finish": (TimerCode.DDS_WORK,),
|
||||
"zone.decision": (TimerCode.ZONE_CHECK,),
|
||||
"crew.arrived": (TimerCode.CLOSE,),
|
||||
|
|
|
|||
Loading…
Reference in a new issue