feat: реагирование смежных служб в карточке ДДС и вводная «бригада просит смежников»
This commit is contained in:
parent
b7445a60fe
commit
de624d09f0
19 changed files with 1139 additions and 21 deletions
|
|
@ -56,6 +56,9 @@ class LessonCriteria(BaseModel):
|
||||||
#: Сколько секунд после доклада бригады даётся на соответствующий статус.
|
#: Сколько секунд после доклада бригады даётся на соответствующий статус.
|
||||||
dds_report_reaction_enabled: bool = False
|
dds_report_reaction_enabled: bool = False
|
||||||
dds_report_reaction_limit_seconds: int = Field(default=45, ge=5, le=600)
|
dds_report_reaction_limit_seconds: int = Field(default=45, ge=5, le=600)
|
||||||
|
#: Сколько секунд от вводной «бригада просит смежников» даётся, чтобы
|
||||||
|
#: ответить бригаде и — при молчании смежной службы — позвонить ей.
|
||||||
|
dds_adjacent_limit_seconds: int = Field(default=90, 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)
|
||||||
|
|
@ -627,6 +630,26 @@ class PhoneHangup(BaseModel):
|
||||||
type: Literal["phone.hangup"] = "phone.hangup"
|
type: Literal["phone.hangup"] = "phone.hangup"
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneReply(BaseModel):
|
||||||
|
"""Ответ диспетчера на входящий вызов бригады (вводная «бригада просит
|
||||||
|
смежников»). Свободный текст проверяется кодом по основам слов."""
|
||||||
|
|
||||||
|
type: Literal["phone.reply"] = "phone.reply"
|
||||||
|
text: str = Field(min_length=8, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class AdjacentDial(BaseModel):
|
||||||
|
"""Звонок диспетчеру смежной службы из списка оповещения карточки.
|
||||||
|
|
||||||
|
Один обмен: запрос курсанта и скриптовый ответ смежной службы; её статус
|
||||||
|
после звонка появляется в журнале карточки.
|
||||||
|
"""
|
||||||
|
|
||||||
|
type: Literal["adjacent.dial"] = "adjacent.dial"
|
||||||
|
service: str = Field(min_length=1, max_length=200)
|
||||||
|
text: str = Field(min_length=8, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
class PhoneLine(BaseModel):
|
class PhoneLine(BaseModel):
|
||||||
type: Literal["phone.line"] = "phone.line"
|
type: Literal["phone.line"] = "phone.line"
|
||||||
service: str
|
service: str
|
||||||
|
|
@ -697,7 +720,7 @@ ServerToStation = Annotated[
|
||||||
|
|
||||||
StationToServer = Annotated[
|
StationToServer = Annotated[
|
||||||
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck
|
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck
|
||||||
| PhoneHangup | StationFinish
|
| PhoneHangup | PhoneReply | AdjacentDial | StationFinish
|
||||||
| CardReply | CardNext | CardOpen
|
| CardReply | CardNext | CardOpen
|
||||||
| ZoneDecision | CrewDispatched | CrewArrived,
|
| ZoneDecision | CrewDispatched | CrewArrived,
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,23 @@ class StatusEntry(BaseModel):
|
||||||
author: str = ""
|
author: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
#: Авторы отметок смежных служб — для журнала и разбора: преподаватель видит,
|
||||||
|
#: пришла отметка сценарием или её вызвал звонок курсанта. Метрики авторов
|
||||||
|
#: не различают: реакция после звонка — такая же реакция в карточке.
|
||||||
|
ADJACENT_AUTHOR = "смежная служба"
|
||||||
|
ADJACENT_CALL_AUTHOR = "смежная служба, по звонку диспетчера"
|
||||||
|
|
||||||
|
#: Смежная служба «реагирует»: успокаивать бригаду можно только при этих
|
||||||
|
#: статусах. Отказ — тоже отметка, но передать его как «служба будет» нельзя.
|
||||||
|
ADJACENT_REACTING: frozenset[ServiceStatus] = frozenset({
|
||||||
|
ServiceStatus.ACCEPTED,
|
||||||
|
ServiceStatus.RESPONDING,
|
||||||
|
ServiceStatus.ARRIVED,
|
||||||
|
ServiceStatus.WORKING,
|
||||||
|
ServiceStatus.COMPLETED,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class PhoneReportRecord(BaseModel):
|
class PhoneReportRecord(BaseModel):
|
||||||
service: str
|
service: str
|
||||||
crew: str
|
crew: str
|
||||||
|
|
@ -160,6 +177,10 @@ class PhoneCallPending(BaseModel):
|
||||||
service: str
|
service: str
|
||||||
crew: str
|
crew: str
|
||||||
phase: str
|
phase: str
|
||||||
|
#: Вводная сценария: бригада звонит сама, а не отвечает на вызов диспетчера.
|
||||||
|
incoming: bool = False
|
||||||
|
#: Смежная служба, о которой спрашивает бригада во входящем вызове.
|
||||||
|
adjacent_service: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class DdsCardSummary(BaseModel):
|
class DdsCardSummary(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,31 @@ def load_file(path: Path, root: Path) -> Scenario:
|
||||||
"которого нет в чек-листе"
|
"которого нет в чек-листе"
|
||||||
)
|
)
|
||||||
|
|
||||||
return _derive_ground_truth(scenario)
|
scenario = _derive_ground_truth(scenario)
|
||||||
|
_check_adjacent_services(scenario, path)
|
||||||
|
return scenario
|
||||||
|
|
||||||
|
|
||||||
|
def _check_adjacent_services(scenario: Scenario, path: Path) -> None:
|
||||||
|
"""Смежные службы существуют только в списке оповещения карточки.
|
||||||
|
|
||||||
|
Проверяется после вывода эталона: список оповещения считается из признаков
|
||||||
|
ЕКП, и до `_derive_ground_truth` его ещё нет. Служба вне списка означала бы
|
||||||
|
отметки, которых диспетчер не может увидеть в карточке.
|
||||||
|
"""
|
||||||
|
recipients = set(scenario.notify_services())
|
||||||
|
for entry in scenario.adjacent_timeline:
|
||||||
|
if entry.service not in recipients:
|
||||||
|
raise ScenarioError(
|
||||||
|
f"{path.name}: adjacent_timeline: служба «{entry.service}» "
|
||||||
|
"не входит в список оповещения карточки"
|
||||||
|
)
|
||||||
|
request = scenario.crew_request
|
||||||
|
if request is not None and request.service not in recipients:
|
||||||
|
raise ScenarioError(
|
||||||
|
f"{path.name}: crew_request: служба «{request.service}» "
|
||||||
|
"не входит в список оповещения карточки"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
#: Подкаталоги, которые сценариями не являются.
|
#: Подкаталоги, которые сценариями не являются.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
|
from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
|
||||||
from app.domain.events import Mood
|
from app.domain.events import Mood
|
||||||
from app.domain.kio import EDITABLE_KIO_FIELDS, KIO
|
from app.domain.kio import EDITABLE_KIO_FIELDS, KIO
|
||||||
|
from app.domain.statuses import NEXT, SERVICE_STATUS_LABELS, ServiceStatus
|
||||||
from app.scoring.taxonomy import METRIC_MAP
|
from app.scoring.taxonomy import METRIC_MAP
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -130,6 +131,49 @@ class DdsDecision(Strict):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class AdjacentTimelineEntry(Strict):
|
||||||
|
"""Отметка смежной службы, которую симуляция ставит по сроку.
|
||||||
|
|
||||||
|
`after_seconds` отсчитывается от принятия карточки курсантом; пауза
|
||||||
|
занятия таймлайн замораживает. Комментарий обязателен: пустая отметка
|
||||||
|
смежной службы учила бы курсанта тому, за что сама система ставит D5.
|
||||||
|
"""
|
||||||
|
|
||||||
|
service: str = Field(min_length=1)
|
||||||
|
after_seconds: int = Field(ge=0, le=3600)
|
||||||
|
status: ServiceStatus
|
||||||
|
comment: str = Field(min_length=1)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def comment_not_blank(self):
|
||||||
|
if not self.comment.strip():
|
||||||
|
raise ValueError(f"смежная служба {self.service}: комментарий отметки пуст")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class CrewRequest(Strict):
|
||||||
|
"""Вводная «бригада просит смежников»: входящий вызов своей бригады.
|
||||||
|
|
||||||
|
Курсант сверяется с карточкой и либо передаёт бригаде, что смежная служба
|
||||||
|
реагирует, либо связывается с ней сам. `after_seconds` — от принятия
|
||||||
|
карточки, как у таймлайна.
|
||||||
|
"""
|
||||||
|
|
||||||
|
after_seconds: int = Field(ge=0, le=3600)
|
||||||
|
service: str = Field(min_length=1)
|
||||||
|
text: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
#: Список оповещения, если у сценария нет признаков ЕКП: главная служба
|
||||||
|
#: по коду ДДС. Тот же запасной вариант использует пульт при сборке карточки.
|
||||||
|
FALLBACK_NOTIFY: dict[str, str] = {
|
||||||
|
"01": "Служба 101",
|
||||||
|
"02": "МВД",
|
||||||
|
"03": "Скорая помощь",
|
||||||
|
"04": "Аварийная служба",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Scenario(Strict):
|
class Scenario(Strict):
|
||||||
id: str
|
id: str
|
||||||
title: str
|
title: str
|
||||||
|
|
@ -178,6 +222,11 @@ class Scenario(Strict):
|
||||||
era_glonass: EraGlonass | None = None
|
era_glonass: EraGlonass | None = None
|
||||||
tree: Tree = Tree()
|
tree: Tree = Tree()
|
||||||
|
|
||||||
|
# Реагирование смежных служб в карточке ДДС: сценарный таймлайн отметок
|
||||||
|
# и не больше одной вводной «бригада просит смежников» (lct: issue 68).
|
||||||
|
adjacent_timeline: list[AdjacentTimelineEntry] = Field(default_factory=list)
|
||||||
|
crew_request: CrewRequest | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def valid_score_weights(self):
|
def valid_score_weights(self):
|
||||||
unknown = self.score_weights.keys() - METRIC_MAP.keys()
|
unknown = self.score_weights.keys() - METRIC_MAP.keys()
|
||||||
|
|
@ -229,6 +278,32 @@ class Scenario(Strict):
|
||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def adjacent_timeline_follows_status_machine(self):
|
||||||
|
"""Кривой таймлайн валит загрузку сценария, а не занятие: симуляция
|
||||||
|
пишет отметки тем же автоматом переходов, что и диспетчер."""
|
||||||
|
chains: dict[str, ServiceStatus] = {}
|
||||||
|
for entry in sorted(self.adjacent_timeline, key=lambda item: item.after_seconds):
|
||||||
|
state = chains.get(entry.service, ServiceStatus.ADDED)
|
||||||
|
if entry.status not in NEXT[state]:
|
||||||
|
raise ValueError(
|
||||||
|
f"adjacent_timeline: {entry.service} — после "
|
||||||
|
f"«{SERVICE_STATUS_LABELS[state]}» недопустим статус "
|
||||||
|
f"«{SERVICE_STATUS_LABELS[entry.status]}»"
|
||||||
|
)
|
||||||
|
chains[entry.service] = entry.status
|
||||||
|
return self
|
||||||
|
|
||||||
|
def notify_services(self) -> list[str]:
|
||||||
|
"""Список оповещения карточки пульта, как его соберёт `build_card`:
|
||||||
|
карточка курсанта, иначе эталон по ЕКП, иначе главная служба по коду."""
|
||||||
|
if self.student_card is not None:
|
||||||
|
return list(self.student_card.notify)
|
||||||
|
if self.ground_truth.notify:
|
||||||
|
return list(self.ground_truth.notify)
|
||||||
|
service = self.ground_truth.dds.value if self.ground_truth.dds else None
|
||||||
|
return [FALLBACK_NOTIFY[service]] if service in FALLBACK_NOTIFY else []
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def era_only_for_era_type(self):
|
def era_only_for_era_type(self):
|
||||||
if self.era_glonass is not None and self.type is not IncidentType.ERA_GLONASS:
|
if self.era_glonass is not None and self.type is not IncidentType.ERA_GLONASS:
|
||||||
|
|
|
||||||
138
backend/app/scoring/adjacent.py
Normal file
138
backend/app/scoring/adjacent.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
"""Оценка работы курсанта со смежными службами: вводная «бригада просит смежников».
|
||||||
|
|
||||||
|
Ответ заказчика (28.09): статусы и комментарии в карточке в первую очередь
|
||||||
|
читают другие ДДС, участвующие в ликвидации. Проверяются два действия —
|
||||||
|
сверился ли курсант с карточкой перед ответом бригаде и связался ли со
|
||||||
|
смежной службой, когда та молчит. Обе метрики детерминированные, по событиям
|
||||||
|
занятия, и обе размечаются кодом D6: неполный ход работ в карточке скрывает
|
||||||
|
от смежных служб факт реакции (классификатор D1–D6 не расширяется).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.domain.events import Metric
|
||||||
|
from app.domain.statuses import ADJACENT_REACTING, StatusEntry
|
||||||
|
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||||
|
from app.scoring.taxonomy import METRIC_WEIGHTS
|
||||||
|
|
||||||
|
ADJACENT_REF = "памятка «Работа на АРМ-112», статусы реагирования; ответ заказчика 28.09"
|
||||||
|
|
||||||
|
|
||||||
|
def active_ms(start: datetime, end: datetime,
|
||||||
|
pauses: Sequence[tuple[datetime, datetime]]) -> int:
|
||||||
|
"""Активное время между моментами: закрытые интервалы пауз вычитаются."""
|
||||||
|
elapsed = int((end - start).total_seconds() * 1000)
|
||||||
|
for pause_start, pause_end in pauses:
|
||||||
|
overlap = (min(end, pause_end) - max(start, pause_start)).total_seconds()
|
||||||
|
if overlap > 0:
|
||||||
|
elapsed -= int(overlap * 1000)
|
||||||
|
return max(0, elapsed)
|
||||||
|
|
||||||
|
|
||||||
|
def _reacted_before(entries: list[StatusEntry], service: str, moment: datetime) -> bool:
|
||||||
|
"""Реакция смежной службы в карточке к моменту: любой статус реагирования.
|
||||||
|
|
||||||
|
Автор не важен: отметка после звонка курсанта — такая же реакция, как
|
||||||
|
сценарная, и успокоить бригаду после неё правомерно.
|
||||||
|
"""
|
||||||
|
return any(entry.service == service and entry.status in ADJACENT_REACTING
|
||||||
|
and entry.at <= moment for entry in entries)
|
||||||
|
|
||||||
|
|
||||||
|
def _metric(key: str, title: str, passed: bool, fact: str, norm: str, service: str) -> Metric:
|
||||||
|
return Metric(key=key, title=f"{service}: {title}", fact=fact, norm=norm,
|
||||||
|
ref=ADJACENT_REF, passed=passed, service=service,
|
||||||
|
weight=METRIC_WEIGHTS.get(key, 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def _finding(service: str, competency: Competency, summary: str, fact: str,
|
||||||
|
norm: str) -> Finding:
|
||||||
|
return Finding(service=service, code=ErrorCode.D6, source=FindingSource.DISPATCHER,
|
||||||
|
summary=f"{service}: {summary}", fact=fact, norm=norm,
|
||||||
|
ref=ADJACENT_REF, competency=competency)
|
||||||
|
|
||||||
|
|
||||||
|
def adjacent_metrics(
|
||||||
|
card, limit_ms: int,
|
||||||
|
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
|
||||||
|
) -> tuple[list[Metric], list[Finding]]:
|
||||||
|
"""Метрики и отметки по вводной. Пусто — вводной не было или она не пришла.
|
||||||
|
|
||||||
|
Исход «реагирует / молчит» — следствие таймлайна сценария, отдельной
|
||||||
|
ручки нет. Перестраховочный звонок смежной службе при её реагировании
|
||||||
|
ошибкой не считается: метрика звонка в этом исходе не выставляется.
|
||||||
|
"""
|
||||||
|
request = card.scenario.crew_request
|
||||||
|
if request is None or card.crew_request_at is None:
|
||||||
|
return [], []
|
||||||
|
pauses = list(pause_intervals or [])
|
||||||
|
service = request.service
|
||||||
|
reacting_variant = any(
|
||||||
|
entry.service == service and entry.status in ADJACENT_REACTING
|
||||||
|
for entry in card.scenario.adjacent_timeline
|
||||||
|
)
|
||||||
|
reply_delay_ms = (active_ms(card.crew_request_at, card.adjacent_reply_at, pauses)
|
||||||
|
if card.adjacent_reply_at is not None else None)
|
||||||
|
call_delay_ms = (active_ms(card.crew_request_at, card.adjacent_call_at, pauses)
|
||||||
|
if card.adjacent_call_at is not None else None)
|
||||||
|
limit_s = limit_ms // 1000
|
||||||
|
metrics: list[Metric] = []
|
||||||
|
findings: list[Finding] = []
|
||||||
|
|
||||||
|
check_norm = ("свериться с журналом карточки и передать бригаде фактическое "
|
||||||
|
"состояние реагирования смежной службы")
|
||||||
|
if card.adjacent_reply_claimed is True:
|
||||||
|
reacted = _reacted_before(card.status_log, service, card.adjacent_reply_at)
|
||||||
|
late = reacting_variant and reply_delay_ms is not None and reply_delay_ms > limit_ms
|
||||||
|
if not reacted:
|
||||||
|
fact = ("бригаде передано «смежная служба реагирует», "
|
||||||
|
"хотя отметок её реакции в карточке не было")
|
||||||
|
elif late:
|
||||||
|
fact = (f"ответ бригаде через {reply_delay_ms // 1000} с "
|
||||||
|
f"при лимите {limit_s} с")
|
||||||
|
else:
|
||||||
|
fact = f"ответ бригаде сверен с карточкой: {service} реагирует"
|
||||||
|
metrics.append(_metric("dds_adjacent_check", "сверка с карточкой перед ответом бригаде",
|
||||||
|
reacted and not late, fact, check_norm, service))
|
||||||
|
if not reacted:
|
||||||
|
findings.append(_finding(service, Competency.CARD,
|
||||||
|
"бригада успокоена без сверки с карточкой",
|
||||||
|
fact, check_norm))
|
||||||
|
elif late:
|
||||||
|
findings.append(_finding(service, Competency.CARD,
|
||||||
|
"ответ бригаде позже лимита вводной",
|
||||||
|
fact, check_norm))
|
||||||
|
elif card.adjacent_reply_claimed is False:
|
||||||
|
if reacting_variant:
|
||||||
|
metrics.append(_metric(
|
||||||
|
"dds_adjacent_check", "сверка с карточкой перед ответом бригаде", True,
|
||||||
|
"бригаде передано, что сведения будут уточнены", check_norm, service))
|
||||||
|
elif reacting_variant:
|
||||||
|
fact = "входящий вызов бригады остался без ответа до закрытия карточки"
|
||||||
|
metrics.append(_metric("dds_adjacent_check",
|
||||||
|
"сверка с карточкой перед ответом бригаде",
|
||||||
|
False, fact, check_norm, service))
|
||||||
|
findings.append(_finding(service, Competency.CARD,
|
||||||
|
"запрос бригады о смежной службе проигнорирован",
|
||||||
|
fact, check_norm))
|
||||||
|
|
||||||
|
if not reacting_variant:
|
||||||
|
call_norm = (f"при молчании смежной службы связаться с ней и ответить "
|
||||||
|
f"бригаде не позже {limit_s} с от вводной")
|
||||||
|
reply_ok = reply_delay_ms is not None and reply_delay_ms <= limit_ms
|
||||||
|
call_ok = call_delay_ms is not None and call_delay_ms <= limit_ms
|
||||||
|
call_fact = (f"звонок «{service}» через {call_delay_ms // 1000} с"
|
||||||
|
if call_delay_ms is not None else f"звонка «{service}» не было")
|
||||||
|
reply_fact = (f"ответ бригаде через {reply_delay_ms // 1000} с"
|
||||||
|
if reply_delay_ms is not None else "ответа бригаде не было")
|
||||||
|
fact = f"{call_fact}; {reply_fact}"
|
||||||
|
metrics.append(_metric("dds_adjacent_call", "связь со смежной службой при её молчании",
|
||||||
|
reply_ok and call_ok, fact, call_norm, service))
|
||||||
|
if not (reply_ok and call_ok):
|
||||||
|
summary = ("молчание смежной службы осталось без звонка"
|
||||||
|
if call_delay_ms is None
|
||||||
|
else "запрос бригады о смежной службе не закрыт в лимит")
|
||||||
|
findings.append(_finding(service, Competency.COMMUNICATION, summary,
|
||||||
|
fact, call_norm))
|
||||||
|
return metrics, findings
|
||||||
|
|
@ -32,6 +32,8 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
||||||
"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_report_reaction": (ErrorCode.D6, Competency.NORMS),
|
||||||
|
"dds_adjacent_check": (ErrorCode.D6, Competency.CARD),
|
||||||
|
"dds_adjacent_call": (ErrorCode.D6, Competency.COMMUNICATION),
|
||||||
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
|
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
|
||||||
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
|
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
|
||||||
}
|
}
|
||||||
|
|
@ -66,4 +68,6 @@ METRIC_WEIGHTS: dict[str, float] = {
|
||||||
"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,
|
"dds_report_reaction": 1.5,
|
||||||
|
"dds_adjacent_check": 1.5,
|
||||||
|
"dds_adjacent_call": 1.5,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ from app.domain import ekp
|
||||||
from app.domain.events import Exercise, Metric, PhoneLine, PhoneReport, StationState
|
from app.domain.events import Exercise, Metric, PhoneLine, PhoneReport, StationState
|
||||||
from app.domain.kio import KIO, ResponseStatus, apply_patch
|
from app.domain.kio import KIO, ResponseStatus, apply_patch
|
||||||
from app.domain.statuses import (
|
from app.domain.statuses import (
|
||||||
|
ADJACENT_AUTHOR,
|
||||||
|
ADJACENT_CALL_AUTHOR,
|
||||||
PRIMARY,
|
PRIMARY,
|
||||||
SERVICE_STATUS_LABELS,
|
SERVICE_STATUS_LABELS,
|
||||||
PhoneCallPending,
|
PhoneCallPending,
|
||||||
|
|
@ -33,6 +35,7 @@ from app.domain.taxonomy import Finding
|
||||||
from app.domain.timers import TimerCode
|
from app.domain.timers import TimerCode
|
||||||
from app.scenarios.schema import Scenario
|
from app.scenarios.schema import Scenario
|
||||||
from app.scoring.address import address_matches
|
from app.scoring.address import address_matches
|
||||||
|
from app.scoring.adjacent import active_ms
|
||||||
from app.session.timers import SessionTimers, now_utc
|
from app.session.timers import SessionTimers, now_utc
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -83,6 +86,17 @@ class DdsLiveCard(BaseModel):
|
||||||
phone_pending: PhoneCallPending | None = None
|
phone_pending: PhoneCallPending | None = None
|
||||||
reply_text: str = ""
|
reply_text: str = ""
|
||||||
reply_log: list[tuple[datetime, str]] = Field(default_factory=list)
|
reply_log: list[tuple[datetime, str]] = Field(default_factory=list)
|
||||||
|
#: Принятие карточки курсантом — старт таймлайна смежных служб и вводной.
|
||||||
|
adjacent_started_at: datetime | None = None
|
||||||
|
#: Сколько записей отсортированного таймлайна уже выдано (или пропущено).
|
||||||
|
adjacent_next_index: int = 0
|
||||||
|
#: Вводная «бригада просит смежников»: когда пришла и чем закрыта.
|
||||||
|
crew_request_at: datetime | None = None
|
||||||
|
adjacent_reply_at: datetime | None = None
|
||||||
|
#: Курсант передал бригаде «смежная служба реагирует» (иначе — уточнит).
|
||||||
|
adjacent_reply_claimed: bool | None = None
|
||||||
|
#: Первый звонок диспетчеру смежной службы из вводной.
|
||||||
|
adjacent_call_at: datetime | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def card_id(self) -> UUID:
|
def card_id(self) -> UUID:
|
||||||
|
|
@ -144,12 +158,6 @@ def build_card(
|
||||||
caller_name = caller_names[0] if len(caller_names) == 1 else None
|
caller_name = caller_names[0] if len(caller_names) == 1 else None
|
||||||
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
|
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
|
||||||
service = truth.dds.value if truth.dds else None
|
service = truth.dds.value if truth.dds else None
|
||||||
fallback = {
|
|
||||||
"01": "Служба 101",
|
|
||||||
"02": "МВД",
|
|
||||||
"03": "Скорая помощь",
|
|
||||||
"04": "Аварийная служба",
|
|
||||||
}
|
|
||||||
if scenario.student_card is not None:
|
if scenario.student_card is not None:
|
||||||
# A moderated learner-authored KIO is the card itself; do not flatten
|
# A moderated learner-authored KIO is the card itself; do not flatten
|
||||||
# it to title/description and silently discard its structured fields.
|
# it to title/description and silently discard its structured fields.
|
||||||
|
|
@ -180,8 +188,7 @@ def build_card(
|
||||||
),
|
),
|
||||||
dds=truth.dds,
|
dds=truth.dds,
|
||||||
signs=list(scenario.signs),
|
signs=list(scenario.signs),
|
||||||
notify=list(truth.notify)
|
notify=scenario.notify_services(),
|
||||||
or ([fallback[service]] if service in fallback else []),
|
|
||||||
victims_count=truth.victims,
|
victims_count=truth.victims,
|
||||||
description="; ".join(fact.value for fact in scenario.facts[:3])
|
description="; ".join(fact.value for fact in scenario.facts[:3])
|
||||||
or scenario.first_line,
|
or scenario.first_line,
|
||||||
|
|
@ -295,6 +302,102 @@ def _report_text(phase: str, crew: str, address: str) -> str:
|
||||||
return f"{crew}: работы завершены. Дальнейшая помощь от нашей бригады не требуется."
|
return f"{crew}: работы завершены. Дальнейшая помощь от нашей бригады не требуется."
|
||||||
|
|
||||||
|
|
||||||
|
#: Фаза входящего вызова бригады с запросом к смежной службе.
|
||||||
|
ADJACENT_REQUEST_PHASE = "adjacent_request"
|
||||||
|
|
||||||
|
#: Ответ бригаде «смежная служба реагирует»: только глаголы самой реакции.
|
||||||
|
#: Общие слова вроде «будут»/«работы» сюда нельзя — «сведения будут позже»
|
||||||
|
#: не успокаивание, а классификация напрямую задаёт отметку D6.
|
||||||
|
_REASSURE_STEMS = ("реагир", "приня", "выех", "выезжа", "направ", "едут",
|
||||||
|
"прибуд", "отреагир")
|
||||||
|
#: Ответ «уточню и свяжусь»: курсант не подтверждает реакцию, а берёт запрос на себя.
|
||||||
|
_DEFER_STEMS = ("свяж", "уточн", "выясн", "позвон", "запрош", "запрос",
|
||||||
|
"ожидай", "жди", "вызову", "сообщу", "передам позже")
|
||||||
|
#: Отрицание реакции сильнее её глагола: «не реагируют, разберусь» — не
|
||||||
|
#: успокаивание, хотя основа «реагир» в реплике есть.
|
||||||
|
_SILENT_MARKERS = ("не реагир", "не приня", "не отреагир", "молч",
|
||||||
|
"нет реакц", "не отмет", "пока нет", "не видно")
|
||||||
|
|
||||||
|
|
||||||
|
def classify_reply(text: str) -> bool | None:
|
||||||
|
"""Что курсант передал бригаде: True — «смежники реагируют», False —
|
||||||
|
«уточню и свяжусь», None — не разобрать (команда отклоняется).
|
||||||
|
|
||||||
|
Утверждение реакции сильнее обещания уточнить: «служба реагирует,
|
||||||
|
сообщу о прибытии» — успокаивание, и проверяться должно оно.
|
||||||
|
"""
|
||||||
|
if _has_purpose(text, _SILENT_MARKERS):
|
||||||
|
return False
|
||||||
|
if _has_purpose(text, _REASSURE_STEMS):
|
||||||
|
return True
|
||||||
|
if _has_purpose(text, _DEFER_STEMS):
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
#: Запрос смежной службе должен называть, что от неё требуется по карточке.
|
||||||
|
_ADJACENT_REQUEST_STEMS = ("обесточ", "отключ", "перекр", "реагир", "выезд",
|
||||||
|
"выех", "направ", "приступ", "помощ", "прошу",
|
||||||
|
"требу", "статус", "обстанов", "карточк", "бригад")
|
||||||
|
|
||||||
|
|
||||||
|
def deliver_adjacent_due(state: "SessionState", now: datetime | None = None) -> bool:
|
||||||
|
"""Выдать созревшие отметки смежных служб и вводные по всем живым карточкам.
|
||||||
|
|
||||||
|
Вызывается тикером хаба вне паузы; сроки считаются по активному времени
|
||||||
|
от принятия карточки, поэтому простой сдвигает таймлайн, а не съедает его.
|
||||||
|
"""
|
||||||
|
now = now or now_utc()
|
||||||
|
changed = False
|
||||||
|
for card in state.desk.ordered():
|
||||||
|
changed |= _deliver_card_adjacent(state, card, now)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _deliver_card_adjacent(state: "SessionState", card: DdsLiveCard, now: datetime) -> bool:
|
||||||
|
if card.adjacent_started_at is None:
|
||||||
|
return False
|
||||||
|
managed = set(state.card_services(card))
|
||||||
|
elapsed_ms = active_ms(card.adjacent_started_at, now, state.pause_intervals)
|
||||||
|
timeline = sorted(card.scenario.adjacent_timeline, key=lambda item: item.after_seconds)
|
||||||
|
changed = False
|
||||||
|
while card.adjacent_next_index < len(timeline):
|
||||||
|
entry = timeline[card.adjacent_next_index]
|
||||||
|
if elapsed_ms < entry.after_seconds * 1000:
|
||||||
|
break
|
||||||
|
card.adjacent_next_index += 1
|
||||||
|
if entry.service in managed:
|
||||||
|
continue # службу курсанта симуляция не подменяет
|
||||||
|
try:
|
||||||
|
card.set_service_status(entry.service, entry.status, entry.comment,
|
||||||
|
author=ADJACENT_AUTHOR)
|
||||||
|
changed = True
|
||||||
|
except StatusError:
|
||||||
|
# Звонок курсанта уже сдвинул статус службы дальше таймлайна.
|
||||||
|
continue
|
||||||
|
request = card.scenario.crew_request
|
||||||
|
# Вводная ждёт, пока карточка открыта на пульте: входящий вызов фоновой
|
||||||
|
# карточки курсант не видит, и лимит тикал бы без шанса среагировать.
|
||||||
|
if (request is not None and card.crew_request_at is None
|
||||||
|
and request.service not in managed
|
||||||
|
and elapsed_ms >= request.after_seconds * 1000
|
||||||
|
and card.phone_pending is None and managed
|
||||||
|
and card.card_id == state.desk.active_id):
|
||||||
|
own_service = state.card_services(card)[0]
|
||||||
|
crew = card.crew_assignments.get(own_service) or f"{own_service} — бригада №1"
|
||||||
|
card.phone_pending = PhoneCallPending(
|
||||||
|
service=own_service, crew=crew, phase=ADJACENT_REQUEST_PHASE,
|
||||||
|
incoming=True, adjacent_service=request.service,
|
||||||
|
)
|
||||||
|
card.phone_lines.append(PhoneLineRecord(
|
||||||
|
service=own_service, crew=crew, speaker="crew", text=request.text, at=now,
|
||||||
|
))
|
||||||
|
card.dds_log.append(("adjacent.request", now, request.service))
|
||||||
|
card.crew_request_at = now
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
def _finish_phone_call(card: DdsLiveCard) -> list[Any]:
|
def _finish_phone_call(card: DdsLiveCard) -> list[Any]:
|
||||||
call = card.phone_pending
|
call = card.phone_pending
|
||||||
assert call is not None
|
assert call is not None
|
||||||
|
|
@ -458,6 +561,8 @@ class DdsDesk(BaseModel):
|
||||||
)
|
)
|
||||||
session.consecutive_refusals = 0
|
session.consecutive_refusals = 0
|
||||||
_first_record(card, command.comment)
|
_first_record(card, command.comment)
|
||||||
|
if card.adjacent_started_at is None:
|
||||||
|
card.adjacent_started_at = now_utc()
|
||||||
except StatusError:
|
except StatusError:
|
||||||
pass # статус уже стоит: повторное нажатие ничего не меняет
|
pass # статус уже стоит: повторное нажатие ничего не меняет
|
||||||
case "card.status":
|
case "card.status":
|
||||||
|
|
@ -472,6 +577,8 @@ class DdsDesk(BaseModel):
|
||||||
command.service, command.status, command.comment, author="диспетчер"
|
command.service, command.status, command.comment, author="диспетчер"
|
||||||
)
|
)
|
||||||
_first_record(card, command.comment)
|
_first_record(card, command.comment)
|
||||||
|
if command.status is ServiceStatus.ACCEPTED and card.adjacent_started_at is None:
|
||||||
|
card.adjacent_started_at = now_utc()
|
||||||
if command.status in PRIMARY:
|
if command.status in PRIMARY:
|
||||||
# Сигнал реестра "повторные отказы" считает подряд идущие
|
# Сигнал реестра "повторные отказы" считает подряд идущие
|
||||||
# отказы, а не сумму за занятие.
|
# отказы, а не сумму за занятие.
|
||||||
|
|
@ -536,6 +643,8 @@ class DdsDesk(BaseModel):
|
||||||
outcome.events.extend(_finish_phone_call(card))
|
outcome.events.extend(_finish_phone_call(card))
|
||||||
case "phone.check":
|
case "phone.check":
|
||||||
call = card.phone_pending
|
call = card.phone_pending
|
||||||
|
if call is not None and call.incoming:
|
||||||
|
return _refused("Это входящий вызов бригады: ответьте на её запрос")
|
||||||
if call is None or call.phase == "dispatched":
|
if call is None or call.phase == "dispatched":
|
||||||
return _refused("Сначала передайте вызов, затем запросите обстановку")
|
return _refused("Сначала передайте вызов, затем запросите обстановку")
|
||||||
if not _has_purpose(command.text, ("обстанов", "статус", "прибыл", "доех",
|
if not _has_purpose(command.text, ("обстанов", "статус", "прибыл", "доех",
|
||||||
|
|
@ -547,8 +656,88 @@ class DdsDesk(BaseModel):
|
||||||
case "phone.hangup":
|
case "phone.hangup":
|
||||||
if card.phone_pending is None:
|
if card.phone_pending is None:
|
||||||
return _refused("Нет активного разговора")
|
return _refused("Нет активного разговора")
|
||||||
|
if card.phone_pending.incoming:
|
||||||
|
# Сброс закрыл бы вводную без ответа навсегда: бригада
|
||||||
|
# повторно не звонит, а метрика исхода уже не пройдёт.
|
||||||
|
return _refused(
|
||||||
|
"Ответьте бригаде: подтвердите реакцию смежной службы "
|
||||||
|
"или скажите, что уточните и свяжетесь с ней"
|
||||||
|
)
|
||||||
card.dds_log.append(("phone.hangup", now_utc(), card.phone_pending.crew))
|
card.dds_log.append(("phone.hangup", now_utc(), card.phone_pending.crew))
|
||||||
card.phone_pending = None
|
card.phone_pending = None
|
||||||
|
case "phone.reply":
|
||||||
|
call = card.phone_pending
|
||||||
|
if call is None or not call.incoming:
|
||||||
|
return _refused("Нет входящего вызова бригады")
|
||||||
|
claimed = classify_reply(command.text)
|
||||||
|
if claimed is None:
|
||||||
|
return _refused(
|
||||||
|
"Скажите бригаде, реагирует ли смежная служба, "
|
||||||
|
"либо что уточните по карточке и свяжетесь с ней"
|
||||||
|
)
|
||||||
|
outcome.events.append(_line(card, "dispatcher", command.text.strip()))
|
||||||
|
confirmation = (
|
||||||
|
f"Понял вас: {call.adjacent_service} реагирует. Продолжаем работы."
|
||||||
|
if claimed else
|
||||||
|
"Понял вас, ожидаем уточнения по смежной службе. Остаёмся на месте."
|
||||||
|
)
|
||||||
|
outcome.events.append(_line(card, "crew", confirmation))
|
||||||
|
reply_at = now_utc()
|
||||||
|
card.dds_log.append(
|
||||||
|
("adjacent.reply", reply_at, "reassure" if claimed else "defer")
|
||||||
|
)
|
||||||
|
card.adjacent_reply_at = reply_at
|
||||||
|
card.adjacent_reply_claimed = claimed
|
||||||
|
card.phone_pending = None
|
||||||
|
case "adjacent.dial":
|
||||||
|
if card.phone_pending is not None:
|
||||||
|
return _refused(
|
||||||
|
"Сначала ответьте бригаде"
|
||||||
|
if card.phone_pending.incoming
|
||||||
|
else "Завершите текущий разговор перед звонком смежной службе"
|
||||||
|
)
|
||||||
|
if (command.service not in card.dispatched_card.notify
|
||||||
|
or command.service in session.card_services(card)):
|
||||||
|
return _refused("Звонить можно смежной службе из списка оповещения карточки")
|
||||||
|
if not _has_purpose(command.text, _ADJACENT_REQUEST_STEMS):
|
||||||
|
return _refused("Сформулируйте запрос: что требуется от смежной службы по карточке")
|
||||||
|
if any(action == "adjacent.dial" and detail == command.service
|
||||||
|
for action, _at, detail in card.dds_log):
|
||||||
|
# Ответ скриптовый и не меняется: повторный звонок только
|
||||||
|
# замусорил бы журнал и разбор парами одинаковых реплик.
|
||||||
|
return _refused("Разговор с этой службой уже состоялся; сведения — в журнале")
|
||||||
|
dialed_at = now_utc()
|
||||||
|
dispatcher_name = f"{command.service} — диспетчер"
|
||||||
|
|
||||||
|
def adjacent_line(speaker: str, text: str) -> PhoneLine:
|
||||||
|
line = PhoneLineRecord(service=command.service, crew=dispatcher_name,
|
||||||
|
speaker=speaker, text=text, at=now_utc())
|
||||||
|
card.phone_lines.append(line)
|
||||||
|
card.dds_log.append(
|
||||||
|
(f"phone.line.{speaker}", line.at, f"{dispatcher_name}: {text}")
|
||||||
|
)
|
||||||
|
return PhoneLine(**line.model_dump())
|
||||||
|
|
||||||
|
outcome.events.append(adjacent_line("dispatcher", command.text.strip()))
|
||||||
|
status_now = current(card.status_log, command.service)
|
||||||
|
if status_now in {ServiceStatus.ADDED, ServiceStatus.RECEIVED}:
|
||||||
|
card.set_service_status(
|
||||||
|
command.service, ServiceStatus.ACCEPTED,
|
||||||
|
"Основание: звонок диспетчера по карточке\n"
|
||||||
|
"Сведения: запрос принят, бригада направляется",
|
||||||
|
author=ADJACENT_CALL_AUTHOR,
|
||||||
|
)
|
||||||
|
answer = "Запрос принят, направляем бригаду. Статус отметим в карточке."
|
||||||
|
elif status_now in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}:
|
||||||
|
answer = "По этой карточке оформлен отказ; основание — в комментарии к статусу."
|
||||||
|
else:
|
||||||
|
answer = "Мы уже реагируем по карточке, ход работ — в статусах."
|
||||||
|
outcome.events.append(adjacent_line("crew", answer))
|
||||||
|
card.dds_log.append(("adjacent.dial", dialed_at, command.service))
|
||||||
|
request = card.scenario.crew_request
|
||||||
|
if (request is not None and command.service == request.service
|
||||||
|
and card.adjacent_call_at is None):
|
||||||
|
card.adjacent_call_at = dialed_at
|
||||||
case "card.reply":
|
case "card.reply":
|
||||||
if not dds_lesson or command.card_id != card.card_id:
|
if not dds_lesson or command.card_id != card.card_id:
|
||||||
return _refused("Ответ относится не к текущей карточке")
|
return _refused("Ответ относится не к текущей карточке")
|
||||||
|
|
@ -575,6 +764,10 @@ class DdsDesk(BaseModel):
|
||||||
return _refused("Следующая карточка недоступна: ID текущей не совпадает")
|
return _refused("Следующая карточка недоступна: ID текущей не совпадает")
|
||||||
if self.is_completed(command.card_id):
|
if self.is_completed(command.card_id):
|
||||||
return _refused("Эта карточка уже завершена")
|
return _refused("Эта карточка уже завершена")
|
||||||
|
if card.phone_pending is not None and card.phone_pending.incoming:
|
||||||
|
# Один клик не должен запирать D6: карточка с висящим
|
||||||
|
# входящим вызовом оценилась бы как проигнорированная вводная.
|
||||||
|
return _refused("Сначала ответьте бригаде на входящий вызов")
|
||||||
# Оценка живёт в модуле завершения, который сам зависит от пульта.
|
# Оценка живёт в модуле завершения, который сам зависит от пульта.
|
||||||
from app.session.finish import score_dds_card
|
from app.session.finish import score_dds_card
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from uuid import UUID
|
||||||
|
|
||||||
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded, SessionPaused
|
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded, SessionPaused
|
||||||
from app.domain.timers import TimerCode
|
from app.domain.timers import TimerCode
|
||||||
|
from app.scoring.adjacent import adjacent_metrics
|
||||||
from app.scoring.ai_coach import coach
|
from app.scoring.ai_coach import coach
|
||||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||||
from app.scoring.gost import GostResult
|
from app.scoring.gost import GostResult
|
||||||
|
|
@ -84,6 +85,11 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
||||||
weighted.metrics.append(work_metric)
|
weighted.metrics.append(work_metric)
|
||||||
if work_finding is not None:
|
if work_finding is not None:
|
||||||
weighted.findings.append(work_finding)
|
weighted.findings.append(work_finding)
|
||||||
|
extra_metrics, extra_findings = adjacent_metrics(
|
||||||
|
card, state.criteria.dds_adjacent_limit_seconds * 1000, state.pause_intervals
|
||||||
|
)
|
||||||
|
weighted.metrics.extend(extra_metrics)
|
||||||
|
weighted.findings.extend(extra_findings)
|
||||||
apply_weights(weighted, {**card.scenario.score_weights, **state.criteria.score_weights})
|
apply_weights(weighted, {**card.scenario.score_weights, **state.criteria.score_weights})
|
||||||
actions = [
|
actions = [
|
||||||
{"type": "card.status", "service": mark.service, "status": mark.status.value,
|
{"type": "card.status", "service": mark.service, "status": mark.status.value,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from app.domain.events import (
|
||||||
StationState,
|
StationState,
|
||||||
TimerTick,
|
TimerTick,
|
||||||
)
|
)
|
||||||
|
from app.session.dds import deliver_adjacent_due
|
||||||
from app.session.state import SessionState
|
from app.session.state import SessionState
|
||||||
from app.session.store import MemorySessionStore, Record, SessionLeaseLost, SessionStore
|
from app.session.store import MemorySessionStore, Record, SessionLeaseLost, SessionStore
|
||||||
from app.session.timers import now_utc
|
from app.session.timers import now_utc
|
||||||
|
|
@ -427,7 +428,11 @@ class SessionHub:
|
||||||
if state.dds_phase and not state.paused:
|
if state.dds_phase and not state.paused:
|
||||||
async with self.operation(session_id) as op:
|
async with self.operation(session_id) as op:
|
||||||
active_before = state.desk.active_id
|
active_before = state.desk.active_id
|
||||||
op.persist = bool(state.desk.deliver_due())
|
delivered = bool(state.desk.deliver_due())
|
||||||
|
# Смежные службы живут тем же тактом: на паузе стоят,
|
||||||
|
# простой сдвигает их сроки вместе с очередью.
|
||||||
|
adjacent_changed = deliver_adjacent_due(state)
|
||||||
|
op.persist = delivered or adjacent_changed
|
||||||
if state.desk.active_id != active_before and state.desk.active_id:
|
if state.desk.active_id != active_before and state.desk.active_id:
|
||||||
self.to_station(session_id, state.card_received_event())
|
self.to_station(session_id, state.card_received_event())
|
||||||
# Keep the pending count and countdown live even while
|
# Keep the pending count and countdown live even while
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,10 @@ class SessionState(PersistedSession):
|
||||||
recipients = []
|
recipients = []
|
||||||
services = self._managed_services_for(recipients)
|
services = self._managed_services_for(recipients)
|
||||||
log = list(card.status_log) if card is not None else []
|
log = list(card.status_log) if card is not None else []
|
||||||
statuses = {service: current(log, service) for service in services}
|
# Статусы всех получателей: диспетчер в бою читает реагирование
|
||||||
|
# смежных служб из карточки. Переходы доступны только своей ДДС.
|
||||||
|
statuses = {service: current(log, service)
|
||||||
|
for service in dict.fromkeys([*services, *recipients])}
|
||||||
now_mono = time.monotonic()
|
now_mono = time.monotonic()
|
||||||
overdue = False
|
overdue = False
|
||||||
if card is not None:
|
if card is not None:
|
||||||
|
|
@ -417,7 +420,9 @@ class SessionState(PersistedSession):
|
||||||
recipient_services=recipients,
|
recipient_services=recipients,
|
||||||
managed_service=services[0] if services else None,
|
managed_service=services[0] if services else None,
|
||||||
statuses=statuses,
|
statuses=statuses,
|
||||||
available={service: list(NEXT[status]) for service, status in statuses.items()},
|
# Переходы — только для служб курсанта: смежные показываются,
|
||||||
|
# но их статусы двигает симуляция, а не пульт.
|
||||||
|
available={service: list(NEXT[statuses[service]]) for service in services},
|
||||||
card=(card_status(log, services, notify_deadline_passed=overdue)
|
card=(card_status(log, services, notify_deadline_passed=overdue)
|
||||||
if shows_card else CardStatus.REGISTERED),
|
if shows_card else CardStatus.REGISTERED),
|
||||||
log=log,
|
log=log,
|
||||||
|
|
|
||||||
383
backend/tests/test_adjacent_services.py
Normal file
383
backend/tests/test_adjacent_services.py
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
"""Смежные службы в карточке ДДС: таймлайн, вводная «бригада просит смежников»,
|
||||||
|
метрики dds_adjacent_check / dds_adjacent_call (issue 68)."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain.events import AdjacentDial, CardAck, Exercise, PhoneReply, SessionMode
|
||||||
|
from app.domain.statuses import (
|
||||||
|
ADJACENT_AUTHOR,
|
||||||
|
ADJACENT_CALL_AUTHOR,
|
||||||
|
ServiceStatus,
|
||||||
|
)
|
||||||
|
from app.scenarios.loader import ScenarioError, load_file
|
||||||
|
from app.scoring.adjacent import adjacent_metrics
|
||||||
|
from app.session.dds import classify_reply, deliver_adjacent_due, prepare_queue
|
||||||
|
from app.session.state import SessionState
|
||||||
|
from app.session.timers import now_utc
|
||||||
|
|
||||||
|
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||||
|
BASIS = "Основание: доклад старшего. Сведения: карточка принята."
|
||||||
|
REASSURE = "Мособлгаз отреагировал, аварийная бригада уже едет к вам"
|
||||||
|
DEFER = "Понял вас, уточню по карточке и свяжусь со смежной службой"
|
||||||
|
REQUEST = "Прошу обесточить и перекрыть газовый ввод по карточке, работает бригада"
|
||||||
|
|
||||||
|
|
||||||
|
def lesson(name: str) -> SessionState:
|
||||||
|
scenario = load_file(LIBRARY / f"{name}.yaml", LIBRARY)
|
||||||
|
state = SessionState(
|
||||||
|
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
||||||
|
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||||
|
)
|
||||||
|
prepare_queue(state, [scenario])
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def accepted(name: str) -> SessionState:
|
||||||
|
state = lesson(name)
|
||||||
|
assert state.desk.apply(CardAck(comment=BASIS), state).changed
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def advance(state: SessionState, seconds: int) -> None:
|
||||||
|
card = state.desk.active
|
||||||
|
card.adjacent_started_at -= timedelta(seconds=seconds)
|
||||||
|
if card.crew_request_at is not None:
|
||||||
|
card.crew_request_at -= timedelta(seconds=seconds)
|
||||||
|
deliver_adjacent_due(state)
|
||||||
|
|
||||||
|
|
||||||
|
# ── загрузка ──
|
||||||
|
|
||||||
|
|
||||||
|
def load_variant(tmp_path, patch: str, name: str = "broken.yaml"):
|
||||||
|
source = (LIBRARY / "dds-adjacent-react.yaml").read_text(encoding="utf-8")
|
||||||
|
(tmp_path / name).write_text(source.replace(*patch.split("=>", 1))
|
||||||
|
if "=>" in patch else source + patch,
|
||||||
|
encoding="utf-8")
|
||||||
|
return load_file(tmp_path / name, LIBRARY)
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_variants_load():
|
||||||
|
react = load_file(LIBRARY / "dds-adjacent-react.yaml", LIBRARY)
|
||||||
|
silent = load_file(LIBRARY / "dds-adjacent-silent.yaml", LIBRARY)
|
||||||
|
|
||||||
|
assert react.crew_request is not None and silent.crew_request is not None
|
||||||
|
assert any(entry.service == "Мособлгаз" for entry in react.adjacent_timeline)
|
||||||
|
assert not any(entry.service == "Мособлгаз" for entry in silent.adjacent_timeline)
|
||||||
|
assert "Мособлгаз" in react.notify_services()
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeline_service_outside_notify_fails_load(tmp_path):
|
||||||
|
with pytest.raises(ScenarioError, match="не входит в список оповещения"):
|
||||||
|
load_variant(tmp_path, "service: Мособлгаз=>service: Служба спасения кошек")
|
||||||
|
|
||||||
|
|
||||||
|
def test_crew_request_service_outside_notify_fails_load(tmp_path):
|
||||||
|
with pytest.raises(ScenarioError, match="crew_request"):
|
||||||
|
load_variant(tmp_path, " service: Мособлгаз\n text:=> service: Чужая служба\n text:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeline_breaking_status_machine_fails_load(tmp_path):
|
||||||
|
with pytest.raises(ScenarioError, match="недопустим статус"):
|
||||||
|
load_variant(tmp_path, "status: accepted\n comment: \"Основание: карточка по пожару в"
|
||||||
|
"=>status: completed\n comment: \"Основание: карточка по пожару в")
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeline_blank_comment_fails_load(tmp_path):
|
||||||
|
with pytest.raises(ScenarioError):
|
||||||
|
load_variant(
|
||||||
|
tmp_path,
|
||||||
|
'comment: "Основание: доклад аварийной бригады. Сведения: бригада выехала на перекрытие ввода."'
|
||||||
|
'=>comment: " "',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── доставка таймлайна ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeline_waits_for_card_acceptance():
|
||||||
|
state = lesson("dds-adjacent-react")
|
||||||
|
|
||||||
|
assert not deliver_adjacent_due(state, now_utc() + timedelta(seconds=600))
|
||||||
|
assert state.desk.active.status_log == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeline_marks_adjacent_services_with_simulation_author():
|
||||||
|
state = accepted("dds-adjacent-react")
|
||||||
|
advance(state, 60)
|
||||||
|
card = state.desk.active
|
||||||
|
|
||||||
|
marks = [entry for entry in card.status_log if entry.author == ADJACENT_AUTHOR]
|
||||||
|
assert [(entry.service, entry.status) for entry in marks] == [
|
||||||
|
("Мособлгаз", ServiceStatus.ACCEPTED),
|
||||||
|
("МВД", ServiceStatus.ACCEPTED),
|
||||||
|
("Мособлгаз", ServiceStatus.RESPONDING),
|
||||||
|
]
|
||||||
|
assert all(entry.comment.strip() for entry in marks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_shifts_timeline():
|
||||||
|
state = accepted("dds-adjacent-react")
|
||||||
|
now = now_utc()
|
||||||
|
state.desk.active.adjacent_started_at = now - timedelta(seconds=30)
|
||||||
|
state.pause_intervals.append((now - timedelta(seconds=25), now - timedelta(seconds=5)))
|
||||||
|
|
||||||
|
deliver_adjacent_due(state, now)
|
||||||
|
|
||||||
|
assert state.desk.active.adjacent_next_index == 0, "активных 10 с меньше 20 с таймлайна"
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_shows_adjacent_status_and_keeps_transitions_managed():
|
||||||
|
state = accepted("dds-adjacent-react")
|
||||||
|
advance(state, 60)
|
||||||
|
|
||||||
|
snapshot = state.station_snapshot()
|
||||||
|
|
||||||
|
assert snapshot.statuses["Мособлгаз"] is ServiceStatus.RESPONDING
|
||||||
|
assert "Мособлгаз" not in snapshot.available
|
||||||
|
assert set(snapshot.available) == set(snapshot.services)
|
||||||
|
|
||||||
|
|
||||||
|
# ── вводная ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_crew_request_arrives_as_incoming_call():
|
||||||
|
state = accepted("dds-adjacent-silent")
|
||||||
|
advance(state, 50)
|
||||||
|
card = state.desk.active
|
||||||
|
|
||||||
|
call = card.phone_pending
|
||||||
|
assert call is not None and call.incoming
|
||||||
|
assert call.adjacent_service == "Мособлгаз"
|
||||||
|
assert card.crew_request_at is not None
|
||||||
|
assert card.phone_lines[-1].speaker == "crew"
|
||||||
|
assert "перекрыть ввод" in card.phone_lines[-1].text
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_reply_understands_reassure_defer_and_negation():
|
||||||
|
assert classify_reply(REASSURE) is True
|
||||||
|
assert classify_reply(DEFER) is False
|
||||||
|
assert classify_reply("Мособлгаз пока не реагирует, разберусь сам") is False
|
||||||
|
assert classify_reply("Так точно, понял") is None
|
||||||
|
# Утверждение реакции сильнее обещания уточнить, а «будут» само по себе
|
||||||
|
# не успокаивание.
|
||||||
|
assert classify_reply("Служба реагирует, бригада выехала, сообщу о прибытии") is True
|
||||||
|
assert classify_reply("Сведения будут позже, оставайтесь на связи") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_phone_reply_closes_incoming_call_and_records_claim():
|
||||||
|
state = accepted("dds-adjacent-silent")
|
||||||
|
advance(state, 50)
|
||||||
|
card = state.desk.active
|
||||||
|
|
||||||
|
outcome = state.desk.apply(PhoneReply(text=REASSURE), state)
|
||||||
|
|
||||||
|
assert outcome.error is None and outcome.changed
|
||||||
|
assert card.phone_pending is None
|
||||||
|
assert card.adjacent_reply_claimed is True
|
||||||
|
assert ("adjacent.reply", "reassure") in [
|
||||||
|
(action, detail) for action, _at, detail in card.dds_log]
|
||||||
|
|
||||||
|
|
||||||
|
def test_incoming_call_cannot_be_dropped_or_abandoned():
|
||||||
|
"""Сброс и закрытие карточки закрыли бы вводную без ответа навсегда."""
|
||||||
|
from app.domain.events import CardNext, PhoneHangup
|
||||||
|
|
||||||
|
state = accepted("dds-adjacent-silent")
|
||||||
|
advance(state, 50)
|
||||||
|
card = state.desk.active
|
||||||
|
|
||||||
|
hangup = state.desk.apply(PhoneHangup(), state)
|
||||||
|
closing = state.desk.apply(CardNext(card_id=card.card_id), state)
|
||||||
|
|
||||||
|
assert hangup.error is not None and "Ответьте бригаде" in hangup.error
|
||||||
|
assert closing.error == "Сначала ответьте бригаде на входящий вызов"
|
||||||
|
assert card.phone_pending is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_crew_request_waits_for_card_to_be_open():
|
||||||
|
"""Входящий вызов фоновой карточки курсант не увидел бы — вводная ждёт."""
|
||||||
|
scenario = load_file(LIBRARY / "dds-adjacent-silent.yaml", LIBRARY)
|
||||||
|
other = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||||
|
state = SessionState(
|
||||||
|
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
|
||||||
|
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||||
|
)
|
||||||
|
prepare_queue(state, [scenario, other])
|
||||||
|
first = state.desk.active
|
||||||
|
assert state.desk.apply(CardAck(comment=BASIS), state).changed
|
||||||
|
second = next(card for card in state.desk.ordered() if card is not first)
|
||||||
|
state.desk.open(second.card_id)
|
||||||
|
|
||||||
|
first.adjacent_started_at -= timedelta(seconds=60)
|
||||||
|
deliver_adjacent_due(state)
|
||||||
|
assert first.crew_request_at is None and first.phone_pending is None
|
||||||
|
|
||||||
|
state.desk.open(first.card_id)
|
||||||
|
deliver_adjacent_due(state)
|
||||||
|
assert first.crew_request_at is not None and first.phone_pending.incoming
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_adjacent_dial_is_refused():
|
||||||
|
state = accepted("dds-adjacent-silent")
|
||||||
|
advance(state, 50)
|
||||||
|
state.desk.apply(PhoneReply(text=DEFER), state)
|
||||||
|
assert state.desk.apply(AdjacentDial(service="Мособлгаз", text=REQUEST), state).error is None
|
||||||
|
lines = len(state.desk.active.phone_lines)
|
||||||
|
|
||||||
|
outcome = state.desk.apply(AdjacentDial(service="Мособлгаз", text=REQUEST), state)
|
||||||
|
|
||||||
|
assert outcome.error == "Разговор с этой службой уже состоялся; сведения — в журнале"
|
||||||
|
assert len(state.desk.active.phone_lines) == lines
|
||||||
|
|
||||||
|
|
||||||
|
def test_phone_reply_without_incoming_call_is_refused():
|
||||||
|
state = accepted("dds-adjacent-silent")
|
||||||
|
|
||||||
|
outcome = state.desk.apply(PhoneReply(text=REASSURE), state)
|
||||||
|
|
||||||
|
assert outcome.error == "Нет входящего вызова бригады"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adjacent_dial_requires_answering_crew_first():
|
||||||
|
state = accepted("dds-adjacent-silent")
|
||||||
|
advance(state, 50)
|
||||||
|
|
||||||
|
outcome = state.desk.apply(AdjacentDial(service="Мособлгаз", text=REQUEST), state)
|
||||||
|
|
||||||
|
assert outcome.error == "Сначала ответьте бригаде"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adjacent_dial_scripts_answer_and_marks_status():
|
||||||
|
state = accepted("dds-adjacent-silent")
|
||||||
|
advance(state, 50)
|
||||||
|
state.desk.apply(PhoneReply(text=DEFER), state)
|
||||||
|
card = state.desk.active
|
||||||
|
|
||||||
|
outcome = state.desk.apply(AdjacentDial(service="Мособлгаз", text=REQUEST), state)
|
||||||
|
|
||||||
|
assert outcome.error is None and len(outcome.events) == 2
|
||||||
|
mark = card.status_log[-1]
|
||||||
|
assert (mark.service, mark.status, mark.author) == (
|
||||||
|
"Мособлгаз", ServiceStatus.ACCEPTED, ADJACENT_CALL_AUTHOR)
|
||||||
|
assert card.adjacent_call_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_adjacent_dial_to_own_or_unknown_service_is_refused():
|
||||||
|
state = accepted("dds-adjacent-silent")
|
||||||
|
advance(state, 50)
|
||||||
|
state.desk.apply(PhoneReply(text=DEFER), state)
|
||||||
|
|
||||||
|
for service in ("Служба 101", "Чужая служба"):
|
||||||
|
outcome = state.desk.apply(AdjacentDial(service=service, text=REQUEST), state)
|
||||||
|
assert outcome.error == "Звонить можно смежной службе из списка оповещения карточки"
|
||||||
|
|
||||||
|
|
||||||
|
# ── метрики ──
|
||||||
|
|
||||||
|
|
||||||
|
def metrics_by_key(state):
|
||||||
|
card = state.desk.active
|
||||||
|
metrics, findings = adjacent_metrics(
|
||||||
|
card, state.criteria.dds_adjacent_limit_seconds * 1000, state.pause_intervals)
|
||||||
|
return {metric.key: metric for metric in metrics}, findings
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_request_no_metrics():
|
||||||
|
state = accepted("dds-adjacent-react")
|
||||||
|
|
||||||
|
metrics, findings = metrics_by_key(state)
|
||||||
|
|
||||||
|
assert metrics == {} and findings == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_reassure_after_reaction_passes_check():
|
||||||
|
state = accepted("dds-adjacent-react")
|
||||||
|
advance(state, 90)
|
||||||
|
state.desk.apply(PhoneReply(text=REASSURE), state)
|
||||||
|
|
||||||
|
metrics, findings = metrics_by_key(state)
|
||||||
|
|
||||||
|
assert metrics["dds_adjacent_check"].passed
|
||||||
|
assert "dds_adjacent_call" not in metrics, "перестраховочная метрика не выставляется"
|
||||||
|
assert findings == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_reassure_without_reaction_fails_check():
|
||||||
|
state = accepted("dds-adjacent-silent")
|
||||||
|
advance(state, 50)
|
||||||
|
state.desk.apply(PhoneReply(text=REASSURE), state)
|
||||||
|
|
||||||
|
metrics, findings = metrics_by_key(state)
|
||||||
|
|
||||||
|
assert not metrics["dds_adjacent_check"].passed
|
||||||
|
assert any("без сверки" in finding.summary for finding in findings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ignored_request_fails_metric_of_each_outcome():
|
||||||
|
react = accepted("dds-adjacent-react")
|
||||||
|
advance(react, 90)
|
||||||
|
silent = accepted("dds-adjacent-silent")
|
||||||
|
advance(silent, 50)
|
||||||
|
|
||||||
|
react_metrics, _ = metrics_by_key(react)
|
||||||
|
silent_metrics, _ = metrics_by_key(silent)
|
||||||
|
|
||||||
|
assert not react_metrics["dds_adjacent_check"].passed
|
||||||
|
assert not silent_metrics["dds_adjacent_call"].passed
|
||||||
|
|
||||||
|
|
||||||
|
def test_silent_service_called_in_time_passes():
|
||||||
|
state = accepted("dds-adjacent-silent")
|
||||||
|
advance(state, 50)
|
||||||
|
state.desk.apply(PhoneReply(text=DEFER), state)
|
||||||
|
state.desk.apply(AdjacentDial(service="Мособлгаз", text=REQUEST), state)
|
||||||
|
|
||||||
|
metrics, findings = metrics_by_key(state)
|
||||||
|
|
||||||
|
assert metrics["dds_adjacent_call"].passed
|
||||||
|
assert findings == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_silent_service_never_called_fails_call_metric():
|
||||||
|
state = accepted("dds-adjacent-silent")
|
||||||
|
advance(state, 50)
|
||||||
|
state.desk.apply(PhoneReply(text=DEFER), state)
|
||||||
|
|
||||||
|
metrics, findings = metrics_by_key(state)
|
||||||
|
|
||||||
|
assert not metrics["dds_adjacent_call"].passed
|
||||||
|
assert any("без звонка" in finding.summary for finding in findings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_after_limit_fails_call_metric():
|
||||||
|
state = accepted("dds-adjacent-silent")
|
||||||
|
advance(state, 50)
|
||||||
|
state.desk.apply(PhoneReply(text=DEFER), state)
|
||||||
|
state.desk.apply(AdjacentDial(service="Мособлгаз", text=REQUEST), state)
|
||||||
|
card = state.desk.active
|
||||||
|
card.crew_request_at -= timedelta(seconds=state.criteria.dds_adjacent_limit_seconds + 30)
|
||||||
|
card.adjacent_reply_at -= timedelta(seconds=state.criteria.dds_adjacent_limit_seconds + 30)
|
||||||
|
|
||||||
|
metrics, _ = metrics_by_key(state)
|
||||||
|
|
||||||
|
assert not metrics["dds_adjacent_call"].passed
|
||||||
|
|
||||||
|
|
||||||
|
def test_reassure_after_own_call_counts_as_reaction():
|
||||||
|
"""Курсант позвонил молчащей службе, статус появился — успокоить бригаду теперь можно."""
|
||||||
|
state = accepted("dds-adjacent-silent")
|
||||||
|
advance(state, 50)
|
||||||
|
state.desk.apply(PhoneReply(text=DEFER), state)
|
||||||
|
state.desk.apply(AdjacentDial(service="Мособлгаз", text=REQUEST), state)
|
||||||
|
card = state.desk.active
|
||||||
|
card.adjacent_reply_claimed = True
|
||||||
|
card.adjacent_reply_at = now_utc()
|
||||||
|
|
||||||
|
metrics, _ = metrics_by_key(state)
|
||||||
|
|
||||||
|
assert metrics["dds_adjacent_check"].passed
|
||||||
|
|
@ -103,9 +103,16 @@ def full_state() -> SessionState:
|
||||||
text="Выехали", at=AT)],
|
text="Выехали", at=AT)],
|
||||||
phone_lines=[PhoneLineRecord(service="01", crew=crew, speaker="crew",
|
phone_lines=[PhoneLineRecord(service="01", crew=crew, speaker="crew",
|
||||||
text="Выехали", at=AT)],
|
text="Выехали", at=AT)],
|
||||||
phone_pending=PhoneCallPending(service="01", crew=crew, phase="arrived"),
|
phone_pending=PhoneCallPending(service="01", crew=crew, phase="arrived",
|
||||||
|
incoming=True, adjacent_service="Мособлгаз"),
|
||||||
reply_text="Бригада направлена",
|
reply_text="Бригада направлена",
|
||||||
reply_log=[(AT, "Бригада направлена")],
|
reply_log=[(AT, "Бригада направлена")],
|
||||||
|
adjacent_started_at=AT,
|
||||||
|
adjacent_next_index=1,
|
||||||
|
crew_request_at=AT,
|
||||||
|
adjacent_reply_at=AT,
|
||||||
|
adjacent_reply_claimed=True,
|
||||||
|
adjacent_call_at=AT,
|
||||||
)
|
)
|
||||||
desk = DdsDesk(
|
desk = DdsDesk(
|
||||||
scenarios=[first, second],
|
scenarios=[first, second],
|
||||||
|
|
|
||||||
|
|
@ -459,6 +459,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
|
||||||
"dds_card_work_time_limit_seconds": 180,
|
"dds_card_work_time_limit_seconds": 180,
|
||||||
"dds_report_reaction_enabled": False,
|
"dds_report_reaction_enabled": False,
|
||||||
"dds_report_reaction_limit_seconds": 20,
|
"dds_report_reaction_limit_seconds": 20,
|
||||||
|
"dds_adjacent_limit_seconds": 90,
|
||||||
"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},
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ const DDS_METRICS: MetricOption[] = [
|
||||||
{ 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: "Статус по докладу бригады не позже лимита занятия." },
|
{ key: "dds_report_reaction", label: "Реакция на доклад бригады", hint: "Статус по докладу бригады не позже лимита занятия." },
|
||||||
|
{ key: "dds_adjacent_check", label: "Сверка перед ответом бригаде", hint: "Ответ на запрос бригады о смежной службе сверен с журналом карточки." },
|
||||||
|
{ key: "dds_adjacent_call", label: "Звонок молчащей смежной службе", hint: "Связь со смежной службой и ответ бригаде не позже лимита вводной." },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
|
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,10 @@ const SipPhone = lazy(() => import("@/features/sip/SipPhone").then(({ SipPhone }
|
||||||
type ReportPhase = "dispatched" | "arrived" | "working" | "completed";
|
type ReportPhase = "dispatched" | "arrived" | "working" | "completed";
|
||||||
interface PhoneReport { service: string; crew: string; phase: ReportPhase; text: string; at: string }
|
interface PhoneReport { service: string; crew: string; phase: ReportPhase; text: string; at: string }
|
||||||
interface PhoneLine { service: string; crew: string; speaker: "dispatcher" | "crew"; text: string; at: string }
|
interface PhoneLine { service: string; crew: string; speaker: "dispatcher" | "crew"; text: string; at: string }
|
||||||
interface PhonePending { service: string; crew: string; phase: ReportPhase }
|
interface PhonePending {
|
||||||
|
service: string; crew: string; phase: string;
|
||||||
|
incoming?: boolean; adjacent_service?: string | null;
|
||||||
|
}
|
||||||
interface CompletedCard {
|
interface CompletedCard {
|
||||||
card_id: string; scenario_id: string; score_auto: number; score_final?: number;
|
card_id: string; scenario_id: string; score_auto: number; score_final?: number;
|
||||||
session_id?: string; ended_at?: string; title?: string | null; address?: string | null;
|
session_id?: string; ended_at?: string; title?: string | null; address?: string | null;
|
||||||
|
|
@ -236,6 +239,9 @@ export function Dds() {
|
||||||
const [dialing, setDialing] = useState(false);
|
const [dialing, setDialing] = useState(false);
|
||||||
const [brief, setBrief] = useState({ address: "", incident: "", request: "" });
|
const [brief, setBrief] = useState({ address: "", incident: "", request: "" });
|
||||||
const [checkText, setCheckText] = useState("");
|
const [checkText, setCheckText] = useState("");
|
||||||
|
const [crewReplyText, setCrewReplyText] = useState("");
|
||||||
|
const [adjacentTarget, setAdjacentTarget] = useState("");
|
||||||
|
const [adjacentText, setAdjacentText] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [ended, setEnded] = useState(false);
|
const [ended, setEnded] = useState(false);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
|
|
@ -286,6 +292,9 @@ export function Dds() {
|
||||||
setDialing(false);
|
setDialing(false);
|
||||||
setBrief({ address: "", incident: "", request: "" });
|
setBrief({ address: "", incident: "", request: "" });
|
||||||
setCheckText("");
|
setCheckText("");
|
||||||
|
setCrewReplyText("");
|
||||||
|
setAdjacentTarget("");
|
||||||
|
setAdjacentText("");
|
||||||
setZone(null);
|
setZone(null);
|
||||||
setScoreReady(false);
|
setScoreReady(false);
|
||||||
setView(openCardAfterSwitch.current ? "card" : "registry");
|
setView(openCardAfterSwitch.current ? "card" : "registry");
|
||||||
|
|
@ -334,7 +343,15 @@ export function Dds() {
|
||||||
const latest = (lastReport?.crew === selectedCrew ? lastReport : null) ??
|
const latest = (lastReport?.crew === selectedCrew ? lastReport : null) ??
|
||||||
reports.filter((item) => item.crew === selectedCrew).at(-1) ?? null;
|
reports.filter((item) => item.crew === selectedCrew).at(-1) ?? null;
|
||||||
const phoneCall = station?.phone_pending ?? null;
|
const phoneCall = station?.phone_pending ?? null;
|
||||||
const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew);
|
// Лента разговора следует за активным вызовом: входящий от бригады может
|
||||||
|
// прийти и без выбранной в селекторе бригады.
|
||||||
|
const activeCallCrew = phoneCall?.crew ?? selectedCrew;
|
||||||
|
const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === activeCallCrew);
|
||||||
|
// Смежные службы: получатели карточки, которыми курсант не управляет.
|
||||||
|
const adjacentServices = (station?.recipient_services ?? [])
|
||||||
|
.filter((service) => !(station?.services ?? []).includes(service));
|
||||||
|
const adjacentService = adjacentTarget || phoneCall?.adjacent_service || adjacentServices[0] || "";
|
||||||
|
const adjacentLines = (station?.phone_lines ?? []).filter((line) => line.crew.endsWith("— диспетчер"));
|
||||||
const isConnected = status === "open";
|
const isConnected = status === "open";
|
||||||
// Вверх: короткая пауза не должна выглядеть как «0 с», а без пауз строки нет.
|
// Вверх: короткая пауза не должна выглядеть как «0 с», а без пауз строки нет.
|
||||||
const pausedSeconds = Math.ceil((report?.total_paused_ms ?? 0) / 1000);
|
const pausedSeconds = Math.ceil((report?.total_paused_ms ?? 0) / 1000);
|
||||||
|
|
@ -378,6 +395,10 @@ export function Dds() {
|
||||||
АРМ {role}: {isConnected ? "на связи" : status}
|
АРМ {role}: {isConnected ? "на связи" : status}
|
||||||
</span>
|
</span>
|
||||||
{error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
|
{error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
|
||||||
|
{phoneCall?.incoming && pane !== "phone" && !ended && <div className="dds-alert" role="alert">
|
||||||
|
Входящий вызов бригады {phoneCall.crew}.{" "}
|
||||||
|
<button type="button" onClick={() => { setView("card"); setPane("phone"); }}>Ответить</button>
|
||||||
|
</div>}
|
||||||
{paused && !ended && <div className="dds-alert dds-paused">Пауза, ждите преподавателя</div>}
|
{paused && !ended && <div className="dds-alert dds-paused">Пауза, ждите преподавателя</div>}
|
||||||
{ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>}
|
{ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>}
|
||||||
{(ended || scoreReady) && <section className="dds-panel" aria-live="polite">
|
{(ended || scoreReady) && <section className="dds-panel" aria-live="polite">
|
||||||
|
|
@ -630,7 +651,22 @@ export function Dds() {
|
||||||
onClick={() => { if (send({ type: "phone.dial" })) setDialing(true); }}>
|
onClick={() => { if (send({ type: "phone.dial" })) setDialing(true); }}>
|
||||||
☎ {dialing ? "Соединение…" : "Позвонить старшему группы"}</button>
|
☎ {dialing ? "Соединение…" : "Позвонить старшему группы"}</button>
|
||||||
{selectedCrew && !crewAccepted && <p className="dds-hint state-warn">Перед звонком поставьте статус «Принята» для службы этой бригады.</p>}
|
{selectedCrew && !crewAccepted && <p className="dds-hint state-warn">Перед звонком поставьте статус «Принята» для службы этой бригады.</p>}
|
||||||
{phoneCall && <div className="dds-phone-active" aria-live="polite">
|
{phoneCall?.incoming && <div className="dds-phone-active" aria-live="polite">
|
||||||
|
<strong>● Входящий вызов · {phoneCall.crew}</strong>
|
||||||
|
<div className="dds-phone-form">
|
||||||
|
<p>Бригада спрашивает о смежной службе{phoneCall.adjacent_service ? ` «${phoneCall.adjacent_service}»` : ""}.
|
||||||
|
Сверьтесь с журналом карточки: если служба реагирует — передайте это бригаде,
|
||||||
|
если молчит — сообщите, что уточните, и позвоните её диспетчеру.</p>
|
||||||
|
<label htmlFor="dds-phone-reply">Ваш ответ бригаде</label>
|
||||||
|
<textarea id="dds-phone-reply" rows={2} value={crewReplyText}
|
||||||
|
onChange={(event) => setCrewReplyText(event.target.value)}
|
||||||
|
placeholder="Служба реагирует, бригада направлена — или: уточню по карточке и свяжусь со службой" />
|
||||||
|
<button type="button" disabled={!isConnected || ended || crewReplyText.trim().length < 8}
|
||||||
|
onClick={() => { if (send({ type: "phone.reply", text: crewReplyText })) setCrewReplyText(""); }}>
|
||||||
|
Ответить бригаде</button>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
{phoneCall && !phoneCall.incoming && <div className="dds-phone-active" aria-live="polite">
|
||||||
<strong>● Соединение установлено · {phoneCall.crew}</strong>
|
<strong>● Соединение установлено · {phoneCall.crew}</strong>
|
||||||
{phoneCall.phase === "dispatched" ? <div className="dds-phone-form">
|
{phoneCall.phase === "dispatched" ? <div className="dds-phone-form">
|
||||||
<p>Передайте сведения из карточки и задачу бригаде. Доклад о выезде появится только после передачи.</p>
|
<p>Передайте сведения из карточки и задачу бригаде. Доклад о выезде появится только после передачи.</p>
|
||||||
|
|
@ -661,6 +697,28 @@ export function Dds() {
|
||||||
{reports.length > 0 && <details className="dds-details"><summary>История докладов ({reports.length})</summary>
|
{reports.length > 0 && <details className="dds-details"><summary>История докладов ({reports.length})</summary>
|
||||||
<ol className="dds-reports">{reports.map((report, index) => <li key={`${report.at}-${index}`}><b>{REPORT_LABELS[report.phase]}</b> · {time(report.at)}<br />{report.text}</li>)}</ol>
|
<ol className="dds-reports">{reports.map((report, index) => <li key={`${report.at}-${index}`}><b>{REPORT_LABELS[report.phase]}</b> · {time(report.at)}<br />{report.text}</li>)}</ol>
|
||||||
</details>}
|
</details>}
|
||||||
|
{adjacentServices.length > 0 && <>
|
||||||
|
<h3 className="dds-protocol-title">Смежные службы</h3>
|
||||||
|
<p className="dds-hint">Реагирование смежных служб видно в журнале и на кнопках служб.
|
||||||
|
Если нужная служба в карточке молчит — позвоните её диспетчеру.</p>
|
||||||
|
<label htmlFor="dds-adjacent-service">Смежная служба</label>
|
||||||
|
<select id="dds-adjacent-service" value={adjacentService}
|
||||||
|
disabled={!isConnected || ended}
|
||||||
|
onChange={(event) => setAdjacentTarget(event.target.value)}>
|
||||||
|
{adjacentServices.map((service) => <option key={service} value={service}>{serviceTitle(service)}</option>)}
|
||||||
|
</select>
|
||||||
|
<label htmlFor="dds-adjacent-text">Запрос диспетчеру службы</label>
|
||||||
|
<textarea id="dds-adjacent-text" rows={2} value={adjacentText}
|
||||||
|
onChange={(event) => setAdjacentText(event.target.value)}
|
||||||
|
placeholder="Прошу перекрыть газовый ввод по карточке, работает бригада" />
|
||||||
|
<button type="button" className="dds-dial"
|
||||||
|
disabled={!isConnected || ended || Boolean(phoneCall) || !adjacentService || adjacentText.trim().length < 8}
|
||||||
|
onClick={() => { if (send({ type: "adjacent.dial", service: adjacentService, text: adjacentText })) setAdjacentText(""); }}>
|
||||||
|
☎ Позвонить диспетчеру службы</button>
|
||||||
|
{adjacentLines.length > 0 && <div className="dds-phone-transcript" aria-live="polite"><strong>Разговоры со смежными службами</strong>
|
||||||
|
{adjacentLines.map((line, index) => <p key={`${line.at}-${index}`}><small>{time(line.at)} · {line.speaker === "crew" ? line.crew : "Диспетчер ДДС"}</small><br />{line.text}</p>)}
|
||||||
|
</div>}
|
||||||
|
</>}
|
||||||
</section>}
|
</section>}
|
||||||
{pane === "reply" && <section className="dds-panel dds-reply">
|
{pane === "reply" && <section className="dds-panel dds-reply">
|
||||||
<div className="dds-panel-title"><h2>Рабочая заметка диспетчера</h2>
|
<div className="dds-panel-title"><h2>Рабочая заметка диспетчера</h2>
|
||||||
|
|
@ -709,7 +767,8 @@ export function Dds() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="dds-service-tools" role="group" aria-label="Действия диспетчера">
|
<div className="dds-service-tools" role="group" aria-label="Действия диспетчера">
|
||||||
<button type="button" onClick={() => setPane("phone")}><b>☎ Связь</b><small>Бригада</small></button>
|
<button type="button" onClick={() => setPane("phone")}>
|
||||||
|
<b>☎ Связь</b><small>{phoneCall?.incoming ? "Входящий!" : "Бригада"}</small></button>
|
||||||
<button type="button" onClick={() => setPane("reply")}><b>✎ Ответ</b><small>{reply.dirty ? "Не сохранён" : "Комментарий"}</small></button>
|
<button type="button" onClick={() => setPane("reply")}><b>✎ Ответ</b><small>{reply.dirty ? "Не сохранён" : "Комментарий"}</small></button>
|
||||||
<button type="button" onClick={() => setPane("journal")}><b>Журнал</b><small>Статусы</small></button>
|
<button type="button" onClick={() => setPane("journal")}><b>Журнал</b><small>Статусы</small></button>
|
||||||
<button type="button" disabled={!cardId || !isConnected || ended || advancePending}
|
<button type="button" disabled={!cardId || !isConnected || ended || advancePending}
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ export function Instructor() {
|
||||||
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
|
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
|
||||||
const [ddsReportReactionEnabled, setDdsReportReactionEnabled] = useState(false);
|
const [ddsReportReactionEnabled, setDdsReportReactionEnabled] = useState(false);
|
||||||
const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45);
|
const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45);
|
||||||
|
const [ddsAdjacentLimit, setDdsAdjacentLimit] = useState(90);
|
||||||
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>>({});
|
||||||
|
|
@ -312,6 +313,7 @@ export function Instructor() {
|
||||||
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
|
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
|
||||||
dds_report_reaction_enabled: reportReactionEnabled,
|
dds_report_reaction_enabled: reportReactionEnabled,
|
||||||
dds_report_reaction_limit_seconds: ddsReportReactionLimit,
|
dds_report_reaction_limit_seconds: ddsReportReactionLimit,
|
||||||
|
dds_adjacent_limit_seconds: ddsAdjacentLimit,
|
||||||
allowed_errors: allowedErrors,
|
allowed_errors: allowedErrors,
|
||||||
require_correct_grammar: requireCorrectGrammar,
|
require_correct_grammar: requireCorrectGrammar,
|
||||||
score_weights: reportReactionEnabled
|
score_weights: reportReactionEnabled
|
||||||
|
|
@ -321,7 +323,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, ddsReportReactionEnabled, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
|
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionEnabled, ddsReportReactionLimit, ddsAdjacentLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
|
||||||
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
|
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
|
||||||
randomScenario, visibleScenarios]);
|
randomScenario, visibleScenarios]);
|
||||||
|
|
||||||
|
|
@ -682,6 +684,13 @@ export function Instructor() {
|
||||||
onCommit={setDdsReportReactionLimit} />
|
onCommit={setDdsReportReactionLimit} />
|
||||||
<small className="ref">Учебный лимит при включённом критерии; исходное значение — 45 секунд.</small>
|
<small className="ref">Учебный лимит при включённом критерии; исходное значение — 45 секунд.</small>
|
||||||
</label>}
|
</label>}
|
||||||
|
<label className="lesson-criterion">
|
||||||
|
Лимит вводной «бригада просит смежников», секунд
|
||||||
|
<LessonNumberInput label="Лимит вводной о смежной службе, секунд"
|
||||||
|
value={ddsAdjacentLimit} min={5} max={600} fallback={90}
|
||||||
|
onCommit={setDdsAdjacentLimit} />
|
||||||
|
<small className="ref">Действует в сценариях с вводной: за это время нужно ответить бригаде, а при молчании смежной службы — позвонить ей.</small>
|
||||||
|
</label>
|
||||||
</>}
|
</>}
|
||||||
<label className="lesson-criterion">
|
<label className="lesson-criterion">
|
||||||
Допустимо нарушенных метрик
|
Допустимо нарушенных метрик
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@ export interface AIRecommendation {
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Звонок диспетчеру смежной службы из списка оповещения карточки. Один обмен: запрос курсанта и скриптовый ответ смежной службы; её статус после звонка появляется в журнале карточки. */
|
||||||
|
export interface AdjacentDial {
|
||||||
|
type: "adjacent.dial";
|
||||||
|
service: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Аудио-фон происшествия. Файл лежит в сборке фронта, трафика фон не создаёт. */
|
/** Аудио-фон происшествия. Файл лежит в сборке фронта, трафика фон не создаёт. */
|
||||||
export interface BgStart {
|
export interface BgStart {
|
||||||
type: "bg.start";
|
type: "bg.start";
|
||||||
|
|
@ -403,6 +410,7 @@ export interface LessonCriteria {
|
||||||
dds_card_work_time_limit_seconds?: number;
|
dds_card_work_time_limit_seconds?: number;
|
||||||
dds_report_reaction_enabled?: boolean;
|
dds_report_reaction_enabled?: boolean;
|
||||||
dds_report_reaction_limit_seconds?: number;
|
dds_report_reaction_limit_seconds?: number;
|
||||||
|
dds_adjacent_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>;
|
||||||
|
|
@ -462,6 +470,8 @@ export interface PhoneCallPending {
|
||||||
service: string;
|
service: string;
|
||||||
crew: string;
|
crew: string;
|
||||||
phase: string;
|
phase: string;
|
||||||
|
incoming?: boolean;
|
||||||
|
adjacent_service?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Запрос диспетчера об обстановке после предыдущего доклада. */
|
/** Запрос диспетчера об обстановке после предыдущего доклада. */
|
||||||
|
|
@ -495,6 +505,12 @@ export interface PhoneLineRecord {
|
||||||
at: string;
|
at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ответ диспетчера на входящий вызов бригады (вводная «бригада просит смежников»). Свободный текст проверяется кодом по основам слов. */
|
||||||
|
export interface PhoneReply {
|
||||||
|
type: "phone.reply";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PhoneReport {
|
export interface PhoneReport {
|
||||||
type: "phone.report";
|
type: "phone.report";
|
||||||
service: string;
|
service: string;
|
||||||
|
|
@ -831,10 +847,11 @@ export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay
|
||||||
|
|
||||||
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | SessionPaused | ErrorEvent;
|
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | SessionPaused | ErrorEvent;
|
||||||
|
|
||||||
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
|
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | PhoneReply | AdjacentDial | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
|
||||||
|
|
||||||
export type EventName =
|
export type EventName =
|
||||||
"bg.start"
|
"adjacent.dial"
|
||||||
|
| "bg.start"
|
||||||
| "bg.stop"
|
| "bg.stop"
|
||||||
| "call.answer"
|
| "call.answer"
|
||||||
| "call.ended"
|
| "call.ended"
|
||||||
|
|
@ -873,6 +890,7 @@ export type EventName =
|
||||||
| "phone.dial"
|
| "phone.dial"
|
||||||
| "phone.hangup"
|
| "phone.hangup"
|
||||||
| "phone.line"
|
| "phone.line"
|
||||||
|
| "phone.reply"
|
||||||
| "phone.report"
|
| "phone.report"
|
||||||
| "reference.play"
|
| "reference.play"
|
||||||
| "reference.started"
|
| "reference.started"
|
||||||
|
|
|
||||||
77
scenarios/dds-adjacent-react.yaml
Normal file
77
scenarios/dds-adjacent-react.yaml
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Реагирование смежных служб в карточке ДДС, исход «смежники реагируют».
|
||||||
|
# Пожар в газифицированном частном доме: Мособлгаз отмечается в карточке по
|
||||||
|
# сценарному таймлайну, затем бригада 101 просит перекрыть газ. Правильный ход —
|
||||||
|
# свериться с журналом и передать бригаде, что смежная служба уже реагирует.
|
||||||
|
# Парный сценарий с молчащей службой — dds-adjacent-silent.
|
||||||
|
id: dds-adjacent-react
|
||||||
|
title: "Пожар в частном доме, бригада просит газовиков (служба реагирует)"
|
||||||
|
type: fire
|
||||||
|
level: L3
|
||||||
|
topics: [fires, adjacent]
|
||||||
|
modes: [training, exam, self]
|
||||||
|
|
||||||
|
extends: checklists/fire.yaml
|
||||||
|
|
||||||
|
signs: [жилой дом, частный дом, открытое пламя]
|
||||||
|
|
||||||
|
persona:
|
||||||
|
base: worried
|
||||||
|
arc:
|
||||||
|
- { stage: registration, mood: worried }
|
||||||
|
- { stage: interview, mood: worried }
|
||||||
|
- { stage: dispatch, mood: calm }
|
||||||
|
|
||||||
|
first_line: "Здравствуйте! У соседей частный дом горит, дым чёрный!"
|
||||||
|
|
||||||
|
facts:
|
||||||
|
- id: f_address
|
||||||
|
value: "город Москва, посёлок Акулово, улица Садовая, дом 9"
|
||||||
|
reveal_on: { question: q_address }
|
||||||
|
- id: f_what_burns
|
||||||
|
value: "горит пристройка частного дома, огонь подбирается к газовой трубе"
|
||||||
|
reveal_on: { question: q_what_burns }
|
||||||
|
- id: f_people
|
||||||
|
value: "жильцы вышли, пострадавших нет"
|
||||||
|
reveal_on: { question: q_people }
|
||||||
|
- id: f_gas
|
||||||
|
value: "дом газифицирован, ввод со стороны улицы"
|
||||||
|
reveal_on: { question: q_gas }
|
||||||
|
- id: f_caller
|
||||||
|
value: "Соколов Пётр Андреевич, телефон 915-402-77-19"
|
||||||
|
reveal_on: { question: q_caller }
|
||||||
|
|
||||||
|
checklist:
|
||||||
|
- { id: q_address, fact: f_address }
|
||||||
|
- { id: q_what_burns, fact: f_what_burns }
|
||||||
|
- { id: q_people, fact: f_people }
|
||||||
|
- { id: q_gas, fact: f_gas }
|
||||||
|
- { id: q_caller, fact: f_caller }
|
||||||
|
|
||||||
|
required_fields: [address, incident_type, description]
|
||||||
|
|
||||||
|
ground_truth:
|
||||||
|
address: "город Москва, посёлок Акулово, улица Садовая, дом 9"
|
||||||
|
victims: 0
|
||||||
|
|
||||||
|
# Смещения — от принятия карточки курсантом; пауза занятия таймлайн замораживает.
|
||||||
|
adjacent_timeline:
|
||||||
|
- service: Мособлгаз
|
||||||
|
after_seconds: 20
|
||||||
|
status: accepted
|
||||||
|
comment: "Основание: карточка по пожару в газифицированном доме. Сведения: заявка принята, аварийная бригада формируется."
|
||||||
|
- service: МВД
|
||||||
|
after_seconds: 35
|
||||||
|
status: accepted
|
||||||
|
comment: "Основание: карточка по пожару. Сведения: наряд для оцепления направлен."
|
||||||
|
- service: Мособлгаз
|
||||||
|
after_seconds: 55
|
||||||
|
status: responding
|
||||||
|
comment: "Основание: доклад аварийной бригады. Сведения: бригада выехала на перекрытие ввода."
|
||||||
|
|
||||||
|
crew_request:
|
||||||
|
after_seconds: 80
|
||||||
|
service: Мособлгаз
|
||||||
|
text: "Говорит старший бригады №1. Работаем по пристройке, огонь у газовой трубы — нужно перекрыть ввод. Мособлгаз отреагировал? Что передать расчёту?"
|
||||||
|
|
||||||
|
tree:
|
||||||
|
pregenerated: false
|
||||||
68
scenarios/dds-adjacent-silent.yaml
Normal file
68
scenarios/dds-adjacent-silent.yaml
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Реагирование смежных служб в карточке ДДС, исход «смежная служба молчит».
|
||||||
|
# Та же карточка, что dds-adjacent-react, но Мособлгаз в таймлайне не отмечается.
|
||||||
|
# Правильный ход — не успокаивать бригаду, а позвонить диспетчеру смежной
|
||||||
|
# службы; после звонка её статус появляется в журнале карточки.
|
||||||
|
id: dds-adjacent-silent
|
||||||
|
title: "Пожар в частном доме, бригада просит газовиков (служба молчит)"
|
||||||
|
type: fire
|
||||||
|
level: L3
|
||||||
|
topics: [fires, adjacent]
|
||||||
|
modes: [training, exam, self]
|
||||||
|
|
||||||
|
extends: checklists/fire.yaml
|
||||||
|
|
||||||
|
signs: [жилой дом, частный дом, открытое пламя]
|
||||||
|
|
||||||
|
persona:
|
||||||
|
base: worried
|
||||||
|
arc:
|
||||||
|
- { stage: registration, mood: worried }
|
||||||
|
- { stage: interview, mood: worried }
|
||||||
|
- { stage: dispatch, mood: calm }
|
||||||
|
|
||||||
|
first_line: "Здравствуйте! У соседей частный дом горит, дым чёрный!"
|
||||||
|
|
||||||
|
facts:
|
||||||
|
- id: f_address
|
||||||
|
value: "город Москва, посёлок Акулово, улица Луговая, дом 4"
|
||||||
|
reveal_on: { question: q_address }
|
||||||
|
- id: f_what_burns
|
||||||
|
value: "горит пристройка частного дома, огонь подбирается к газовой трубе"
|
||||||
|
reveal_on: { question: q_what_burns }
|
||||||
|
- id: f_people
|
||||||
|
value: "жильцы вышли, пострадавших нет"
|
||||||
|
reveal_on: { question: q_people }
|
||||||
|
- id: f_gas
|
||||||
|
value: "дом газифицирован, ввод со стороны улицы"
|
||||||
|
reveal_on: { question: q_gas }
|
||||||
|
- id: f_caller
|
||||||
|
value: "Крылова Марина Ильинична, телефон 916-233-48-05"
|
||||||
|
reveal_on: { question: q_caller }
|
||||||
|
|
||||||
|
checklist:
|
||||||
|
- { id: q_address, fact: f_address }
|
||||||
|
- { id: q_what_burns, fact: f_what_burns }
|
||||||
|
- { id: q_people, fact: f_people }
|
||||||
|
- { id: q_gas, fact: f_gas }
|
||||||
|
- { id: q_caller, fact: f_caller }
|
||||||
|
|
||||||
|
required_fields: [address, incident_type, description]
|
||||||
|
|
||||||
|
ground_truth:
|
||||||
|
address: "город Москва, посёлок Акулово, улица Луговая, дом 4"
|
||||||
|
victims: 0
|
||||||
|
|
||||||
|
# Мособлгаз молчит намеренно: исход — следствие таймлайна, отдельной ручки нет.
|
||||||
|
adjacent_timeline:
|
||||||
|
- service: МВД
|
||||||
|
after_seconds: 30
|
||||||
|
status: accepted
|
||||||
|
comment: "Основание: карточка по пожару. Сведения: наряд для оцепления направлен."
|
||||||
|
|
||||||
|
crew_request:
|
||||||
|
after_seconds: 45
|
||||||
|
service: Мособлгаз
|
||||||
|
text: "Говорит старший бригады №1. Работаем по пристройке, огонь у газовой трубы — нужно перекрыть ввод. Мособлгаз отреагировал? Что передать расчёту?"
|
||||||
|
|
||||||
|
tree:
|
||||||
|
pregenerated: false
|
||||||
Loading…
Reference in a new issue