diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py index 9965d1d..3c3ca7f 100644 --- a/backend/app/domain/events.py +++ b/backend/app/domain/events.py @@ -56,6 +56,9 @@ class LessonCriteria(BaseModel): #: Сколько секунд после доклада бригады даётся на соответствующий статус. dds_report_reaction_enabled: bool = False 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) require_correct_grammar: bool = True score_weights: dict[str, float] = Field(default_factory=dict) @@ -627,6 +630,26 @@ class PhoneHangup(BaseModel): 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): type: Literal["phone.line"] = "phone.line" service: str @@ -697,7 +720,7 @@ ServerToStation = Annotated[ StationToServer = Annotated[ CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck - | PhoneHangup | StationFinish + | PhoneHangup | PhoneReply | AdjacentDial | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived, Field(discriminator="type"), diff --git a/backend/app/domain/statuses.py b/backend/app/domain/statuses.py index 133ea46..4e08c78 100644 --- a/backend/app/domain/statuses.py +++ b/backend/app/domain/statuses.py @@ -140,6 +140,23 @@ class StatusEntry(BaseModel): 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): service: str crew: str @@ -160,6 +177,10 @@ class PhoneCallPending(BaseModel): service: str crew: str phase: str + #: Вводная сценария: бригада звонит сама, а не отвечает на вызов диспетчера. + incoming: bool = False + #: Смежная служба, о которой спрашивает бригада во входящем вызове. + adjacent_service: str | None = None class DdsCardSummary(BaseModel): diff --git a/backend/app/scenarios/loader.py b/backend/app/scenarios/loader.py index eaad57b..89fb75a 100644 --- a/backend/app/scenarios/loader.py +++ b/backend/app/scenarios/loader.py @@ -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}» " + "не входит в список оповещения карточки" + ) #: Подкаталоги, которые сценариями не являются. diff --git a/backend/app/scenarios/schema.py b/backend/app/scenarios/schema.py index f57cb1f..a27952c 100644 --- a/backend/app/scenarios/schema.py +++ b/backend/app/scenarios/schema.py @@ -10,6 +10,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome from app.domain.events import Mood 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 @@ -130,6 +131,49 @@ class DdsDecision(Strict): 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): id: str title: str @@ -178,6 +222,11 @@ class Scenario(Strict): era_glonass: EraGlonass | None = None tree: Tree = Tree() + # Реагирование смежных служб в карточке ДДС: сценарный таймлайн отметок + # и не больше одной вводной «бригада просит смежников» (lct: issue 68). + adjacent_timeline: list[AdjacentTimelineEntry] = Field(default_factory=list) + crew_request: CrewRequest | None = None + @model_validator(mode="after") def valid_score_weights(self): unknown = self.score_weights.keys() - METRIC_MAP.keys() @@ -229,6 +278,32 @@ class Scenario(Strict): ) 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") def era_only_for_era_type(self): if self.era_glonass is not None and self.type is not IncidentType.ERA_GLONASS: diff --git a/backend/app/scoring/adjacent.py b/backend/app/scoring/adjacent.py new file mode 100644 index 0000000..ed8a964 --- /dev/null +++ b/backend/app/scoring/adjacent.py @@ -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 diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py index 55e750b..8cb6993 100644 --- a/backend/app/scoring/taxonomy.py +++ b/backend/app/scoring/taxonomy.py @@ -32,6 +32,8 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = { "dds_completion": (ErrorCode.D6, Competency.CARD), "dds_reply": (ErrorCode.D5, Competency.COMMUNICATION), "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), "description_grammar": (ErrorCode.E4, Competency.COMMUNICATION), } @@ -66,4 +68,6 @@ METRIC_WEIGHTS: dict[str, float] = { "card_fill_time": 1.5, "dds_work_time": 1.5, "dds_report_reaction": 1.5, + "dds_adjacent_check": 1.5, + "dds_adjacent_call": 1.5, } diff --git a/backend/app/session/dds.py b/backend/app/session/dds.py index 1c30bab..83d8d20 100644 --- a/backend/app/session/dds.py +++ b/backend/app/session/dds.py @@ -18,6 +18,8 @@ from app.domain import ekp from app.domain.events import Exercise, Metric, PhoneLine, PhoneReport, StationState from app.domain.kio import KIO, ResponseStatus, apply_patch from app.domain.statuses import ( + ADJACENT_AUTHOR, + ADJACENT_CALL_AUTHOR, PRIMARY, SERVICE_STATUS_LABELS, PhoneCallPending, @@ -33,6 +35,7 @@ from app.domain.taxonomy import Finding from app.domain.timers import TimerCode from app.scenarios.schema import Scenario from app.scoring.address import address_matches +from app.scoring.adjacent import active_ms from app.session.timers import SessionTimers, now_utc if TYPE_CHECKING: @@ -83,6 +86,17 @@ class DdsLiveCard(BaseModel): phone_pending: PhoneCallPending | None = None reply_text: str = "" 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 def card_id(self) -> UUID: @@ -144,12 +158,6 @@ def build_card( caller_name = caller_names[0] if len(caller_names) == 1 else None floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE) service = truth.dds.value if truth.dds else None - fallback = { - "01": "Служба 101", - "02": "МВД", - "03": "Скорая помощь", - "04": "Аварийная служба", - } if scenario.student_card is not None: # A moderated learner-authored KIO is the card itself; do not flatten # it to title/description and silently discard its structured fields. @@ -180,8 +188,7 @@ def build_card( ), dds=truth.dds, signs=list(scenario.signs), - notify=list(truth.notify) - or ([fallback[service]] if service in fallback else []), + notify=scenario.notify_services(), victims_count=truth.victims, description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line, @@ -295,6 +302,102 @@ def _report_text(phase: str, crew: str, address: str) -> str: 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]: call = card.phone_pending assert call is not None @@ -458,6 +561,8 @@ class DdsDesk(BaseModel): ) session.consecutive_refusals = 0 _first_record(card, command.comment) + if card.adjacent_started_at is None: + card.adjacent_started_at = now_utc() except StatusError: pass # статус уже стоит: повторное нажатие ничего не меняет case "card.status": @@ -472,6 +577,8 @@ class DdsDesk(BaseModel): command.service, command.status, command.comment, author="диспетчер" ) _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: # Сигнал реестра "повторные отказы" считает подряд идущие # отказы, а не сумму за занятие. @@ -536,6 +643,8 @@ class DdsDesk(BaseModel): outcome.events.extend(_finish_phone_call(card)) case "phone.check": call = card.phone_pending + if call is not None and call.incoming: + return _refused("Это входящий вызов бригады: ответьте на её запрос") if call is None or call.phase == "dispatched": return _refused("Сначала передайте вызов, затем запросите обстановку") if not _has_purpose(command.text, ("обстанов", "статус", "прибыл", "доех", @@ -547,8 +656,88 @@ class DdsDesk(BaseModel): case "phone.hangup": if card.phone_pending is None: return _refused("Нет активного разговора") + if card.phone_pending.incoming: + # Сброс закрыл бы вводную без ответа навсегда: бригада + # повторно не звонит, а метрика исхода уже не пройдёт. + return _refused( + "Ответьте бригаде: подтвердите реакцию смежной службы " + "или скажите, что уточните и свяжетесь с ней" + ) card.dds_log.append(("phone.hangup", now_utc(), card.phone_pending.crew)) 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": if not dds_lesson or command.card_id != card.card_id: return _refused("Ответ относится не к текущей карточке") @@ -575,6 +764,10 @@ class DdsDesk(BaseModel): return _refused("Следующая карточка недоступна: ID текущей не совпадает") if self.is_completed(command.card_id): 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 diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py index 9573616..4864a1e 100644 --- a/backend/app/session/finish.py +++ b/backend/app/session/finish.py @@ -14,6 +14,7 @@ from uuid import UUID from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded, SessionPaused from app.domain.timers import TimerCode +from app.scoring.adjacent import adjacent_metrics from app.scoring.ai_coach import coach from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher from app.scoring.gost import GostResult @@ -84,6 +85,11 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord: weighted.metrics.append(work_metric) if work_finding is not None: 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}) actions = [ {"type": "card.status", "service": mark.service, "status": mark.status.value, diff --git a/backend/app/session/hub.py b/backend/app/session/hub.py index c43c756..220d3c3 100644 --- a/backend/app/session/hub.py +++ b/backend/app/session/hub.py @@ -22,6 +22,7 @@ from app.domain.events import ( StationState, TimerTick, ) +from app.session.dds import deliver_adjacent_due from app.session.state import SessionState from app.session.store import MemorySessionStore, Record, SessionLeaseLost, SessionStore from app.session.timers import now_utc @@ -427,7 +428,11 @@ class SessionHub: if state.dds_phase and not state.paused: async with self.operation(session_id) as op: 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: self.to_station(session_id, state.card_received_event()) # Keep the pending count and countdown live even while diff --git a/backend/app/session/state.py b/backend/app/session/state.py index 5c99a5c..5892ca1 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -370,7 +370,10 @@ class SessionState(PersistedSession): recipients = [] services = self._managed_services_for(recipients) 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() overdue = False if card is not None: @@ -417,7 +420,9 @@ class SessionState(PersistedSession): recipient_services=recipients, managed_service=services[0] if services else None, 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) if shows_card else CardStatus.REGISTERED), log=log, diff --git a/backend/tests/test_adjacent_services.py b/backend/tests/test_adjacent_services.py new file mode 100644 index 0000000..e3f2f33 --- /dev/null +++ b/backend/tests/test_adjacent_services.py @@ -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 diff --git a/backend/tests/test_checkpoint_model.py b/backend/tests/test_checkpoint_model.py index 5032577..ca3f853 100644 --- a/backend/tests/test_checkpoint_model.py +++ b/backend/tests/test_checkpoint_model.py @@ -103,9 +103,16 @@ def full_state() -> SessionState: text="Выехали", at=AT)], phone_lines=[PhoneLineRecord(service="01", crew=crew, speaker="crew", 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_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( scenarios=[first, second], diff --git a/backend/tests/test_dds_exercise.py b/backend/tests/test_dds_exercise.py index e8296df..cef4c06 100644 --- a/backend/tests/test_dds_exercise.py +++ b/backend/tests/test_dds_exercise.py @@ -459,6 +459,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa "dds_card_work_time_limit_seconds": 180, "dds_report_reaction_enabled": False, "dds_report_reaction_limit_seconds": 20, + "dds_adjacent_limit_seconds": 90, "allowed_errors": 50, "require_correct_grammar": False, "score_weights": {"dds_ack": 3.5}, diff --git a/frontend/src/features/instructor/ScoreWeightsEditor.tsx b/frontend/src/features/instructor/ScoreWeightsEditor.tsx index 69e0122..45191b3 100644 --- a/frontend/src/features/instructor/ScoreWeightsEditor.tsx +++ b/frontend/src/features/instructor/ScoreWeightsEditor.tsx @@ -33,6 +33,8 @@ const DDS_METRICS: MetricOption[] = [ { key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." }, { key: "dds_work_time", 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 }) => [ diff --git a/frontend/src/pages/dds/Dds.tsx b/frontend/src/pages/dds/Dds.tsx index cc5e921..e58a28c 100644 --- a/frontend/src/pages/dds/Dds.tsx +++ b/frontend/src/pages/dds/Dds.tsx @@ -17,7 +17,10 @@ const SipPhone = lazy(() => import("@/features/sip/SipPhone").then(({ SipPhone } type ReportPhase = "dispatched" | "arrived" | "working" | "completed"; 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 PhonePending { service: string; crew: string; phase: ReportPhase } +interface PhonePending { + service: string; crew: string; phase: string; + incoming?: boolean; adjacent_service?: string | null; +} interface CompletedCard { card_id: string; scenario_id: string; score_auto: number; score_final?: number; 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 [brief, setBrief] = useState({ address: "", incident: "", request: "" }); const [checkText, setCheckText] = useState(""); + const [crewReplyText, setCrewReplyText] = useState(""); + const [adjacentTarget, setAdjacentTarget] = useState(""); + const [adjacentText, setAdjacentText] = useState(""); const [error, setError] = useState(""); const [ended, setEnded] = useState(false); const [paused, setPaused] = useState(false); @@ -286,6 +292,9 @@ export function Dds() { setDialing(false); setBrief({ address: "", incident: "", request: "" }); setCheckText(""); + setCrewReplyText(""); + setAdjacentTarget(""); + setAdjacentText(""); setZone(null); setScoreReady(false); setView(openCardAfterSwitch.current ? "card" : "registry"); @@ -334,7 +343,15 @@ export function Dds() { const latest = (lastReport?.crew === selectedCrew ? lastReport : null) ?? reports.filter((item) => item.crew === selectedCrew).at(-1) ?? 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"; // Вверх: короткая пауза не должна выглядеть как «0 с», а без пауз строки нет. const pausedSeconds = Math.ceil((report?.total_paused_ms ?? 0) / 1000); @@ -378,6 +395,10 @@ export function Dds() { АРМ {role}: {isConnected ? "на связи" : status} {error &&
Перед звонком поставьте статус «Принята» для службы этой бригады.
} - {phoneCall &&Бригада спрашивает о смежной службе{phoneCall.adjacent_service ? ` «${phoneCall.adjacent_service}»` : ""}. + Сверьтесь с журналом карточки: если служба реагирует — передайте это бригаде, + если молчит — сообщите, что уточните, и позвоните её диспетчеру.
+ +Передайте сведения из карточки и задачу бригаде. Доклад о выезде появится только после передачи.
@@ -661,6 +697,28 @@ export function Dds() { {reports.length > 0 &&Реагирование смежных служб видно в журнале и на кнопках служб. + Если нужная служба в карточке молчит — позвоните её диспетчеру.
+ + + +