feat: реагирование смежных служб в карточке ДДС и вводная «бригада просит смежников»

This commit is contained in:
gglamer 2026-09-28 18:02:03 +00:00
commit de624d09f0
19 changed files with 1139 additions and 21 deletions

View file

@ -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"),

View file

@ -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):

View file

@ -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}» "
"не входит в список оповещения карточки"
)
#: Подкаталоги, которые сценариями не являются. #: Подкаталоги, которые сценариями не являются.

View file

@ -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:

View 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

View file

@ -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,
} }

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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,

View 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

View file

@ -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],

View file

@ -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},

View file

@ -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 }) => [

View file

@ -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}

View file

@ -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">
Допустимо нарушенных метрик Допустимо нарушенных метрик

View file

@ -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"

View 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

View 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