Complete incident selection and 112 to DDS workflow

This commit is contained in:
andreysk0304 2026-09-21 20:10:57 +03:00
commit 68dd83c7c2
21 changed files with 281 additions and 56 deletions

View file

@ -26,7 +26,7 @@ async def groups() -> dict:
@router.get("/signs") @router.get("/signs")
async def signs(prefix: list[str] = Query(default=[])) -> dict: async def signs(prefix: list[str] = Query(default=[]), group: int | None = None) -> dict:
"""Значения следующего признака при уже выбранных. """Значения следующего признака при уже выбранных.
Пустой `prefix` — значения первого уровня. Возвращается и найденное Пустой `prefix` — значения первого уровня. Возвращается и найденное
@ -34,10 +34,10 @@ async def signs(prefix: list[str] = Query(default=[])) -> dict:
он классифицировал, до того, как отправит карточку. он классифицировал, до того, как отправит карточку.
""" """
level = len(prefix) + 1 level = len(prefix) + 1
found = ekp.by_signs(prefix) if prefix else None found = ekp.by_signs(prefix, group) if prefix else None
return { return {
"level": level, "level": level,
"values": ekp.signs_at_level(level, prefix) if level <= 3 else [], "values": ekp.signs_at_level(level, prefix, group) if level <= 3 else [],
"incident": ( "incident": (
{ {
"code": found.code, "code": found.code,

View file

@ -17,6 +17,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require from app.api.auth import audit, require
from app.domain import ekp
from app.db.base import get_session from app.db.base import get_session
from app.domain.roles import Role from app.domain.roles import Role
from app.scenarios import store from app.scenarios import store
@ -131,6 +132,11 @@ async def listing() -> list[dict]:
"topics": scenario.topics, "topics": scenario.topics,
"modes": scenario.modes, "modes": scenario.modes,
"dds": scenario.ground_truth.dds.value if scenario.ground_truth.dds else None, "dds": scenario.ground_truth.dds.value if scenario.ground_truth.dds else None,
"ticket": scenario.ticket,
"position": scenario.position,
"ekp_group": (ekp.incident(scenario.ground_truth.incident_code).group
if scenario.ground_truth.incident_code
and ekp.incident(scenario.ground_truth.incident_code) else None),
} }
for scenario in store.all_scenarios() for scenario in store.all_scenarios()
] ]

View file

@ -100,25 +100,37 @@ async def _handle(session_id: UUID, state, event) -> None:
code=ErrorKind.UNSUPPORTED_EVENT, message="Действие недоступно в этом упражнении", code=ErrorKind.UNSUPPORTED_EVENT, message="Действие недоступно в этом упражнении",
)) ))
return return
if state.exercise is Exercise.CARD and state.dispatched_card is not None:
hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message="Карточка уже передана в ДДС и не может быть изменена",
))
return
match event.type: match event.type:
case "card.submit": case "card.submit":
state.kio.registered_at = state.started_at or now_utc() state.kio.registered_at = state.started_at or now_utc()
state.kio.response_status = ResponseStatus.TRANSFERRED state.kio.response_status = ResponseStatus.TRANSFERRED
state.dispatched_card = state.kio.model_copy(deep=True) state.dispatched_card = state.kio.model_copy(deep=True)
state.dispatched_at = now_utc() state.dispatched_at = now_utc()
state.ended_at = state.dispatched_at if state.handoff_to_dds:
state.end_reason = CallEndReason.COMPLETE state.on_event("dds.dispatch")
hub.stop_ticker(session_id) else:
state.ended_at = state.dispatched_at
state.end_reason = CallEndReason.COMPLETE
hub.stop_ticker(session_id)
hub.to_station(session_id, state.card_received_event()) hub.to_station(session_id, state.card_received_event())
hub.to_station(session_id, StationState(snapshot=state.station_snapshot())) hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
hub.to_observers(session_id, KioState(kio=state.kio)) hub.to_observers(session_id, KioState(kio=state.kio))
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.COMPLETE)) hub.to_trainee(session_id, CallEnded(reason=CallEndReason.COMPLETE))
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE)) if state.handoff_to_dds:
if hub.journal: hub.to_observers(session_id, state.snapshot())
await hub.journal.session_ended( else:
session_id, state.ended_at, CallEndReason.COMPLETE.value hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
) if hub.journal:
await finish(session_id, state) await hub.journal.session_ended(
session_id, state.ended_at, CallEndReason.COMPLETE.value
)
await finish(session_id, state)
case "call.answer": case "call.answer":
state.on_event("call.answer") state.on_event("call.answer")
state.started_at = now_utc() state.started_at = now_utc()
@ -295,7 +307,7 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
from app.api.ws.control import card_briefing from app.api.ws.control import card_briefing
hub.to_trainee(session_id, card_briefing(state)) hub.to_trainee(session_id, card_briefing(state))
if state.ended: if state.ended or state.dispatched_card is not None:
hub.to_trainee(session_id, CallEnded(reason=state.end_reason or CallEndReason.COMPLETE)) hub.to_trainee(session_id, CallEnded(reason=state.end_reason or CallEndReason.COMPLETE))
if state.score is not None: if state.score is not None:
hub.to_trainee(session_id, ScoreReady(session_id=session_id)) hub.to_trainee(session_id, ScoreReady(session_id=session_id))

View file

@ -72,10 +72,17 @@ def card_briefing(state: SessionState) -> CardBriefing:
required_fields=([field for field in state.required_fields if field != "dds"] required_fields=([field for field in state.required_fields if field != "dds"]
if scenario.ground_truth.incident_code else list(state.required_fields)), if scenario.ground_truth.incident_code else list(state.required_fields)),
card=state.kio, card=state.kio,
handoff_to_dds=state.handoff_to_dds,
) )
async def _start(session_id: UUID, event, who=None) -> None: async def _start(session_id: UUID, event, who=None) -> None:
if event.handoff_to_dds and event.exercise is not Exercise.CARD:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID,
message="Передача в ДДС доступна только для текстовой карточки 112",
))
return
scenario = store.get(event.scenario_id) scenario = store.get(event.scenario_id)
if scenario is None: if scenario is None:
hub.to_observers( hub.to_observers(
@ -118,6 +125,7 @@ async def _start(session_id: UUID, event, who=None) -> None:
level=scenario.level.value, level=scenario.level.value,
mode=event.mode, mode=event.mode,
exercise=event.exercise, exercise=event.exercise,
handoff_to_dds=event.handoff_to_dds,
scenario=scenario.model_copy(deep=True), scenario=scenario.model_copy(deep=True),
required_fields=scenario.required_fields, required_fields=scenario.required_fields,
trainee_name=event.trainee, trainee_name=event.trainee,
@ -188,12 +196,12 @@ async def _stop(session_id: UUID) -> None:
if state.exercise in {Exercise.DDS, Exercise.CARD}: if state.exercise in {Exercise.DDS, Exercise.CARD}:
from app.session.finish import finish from app.session.finish import finish
if state.exercise is Exercise.DDS: if state.exercise is Exercise.DDS or state.handoff_to_dds and state.dispatched_card is not None:
hub.to_station(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR)) hub.to_station(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
else: else:
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR)) hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR))
await finish(session_id, state) await finish(session_id, state)
if state.exercise is Exercise.DDS: if state.exercise is Exercise.DDS or state.handoff_to_dds and state.dispatched_card is not None:
hub.to_station(session_id, ScoreReady(session_id=session_id)) hub.to_station(session_id, ScoreReady(session_id=session_id))
if hub.journal: if hub.journal:
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value) await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value)

View file

@ -86,7 +86,7 @@ async def _handle(session_id: UUID, state, event) -> None:
_error(session_id, "Служба отсутствует в списке оповещения карточки") _error(session_id, "Служба отсутствует в списке оповещения карточки")
return return
phase = REPORT_FOR_STATUS.get(event.status) phase = REPORT_FOR_STATUS.get(event.status)
if state.exercise is Exercise.DDS and phase is not None and not any( if (state.exercise is Exercise.DDS or state.handoff_to_dds) and phase is not None and not any(
report.service == event.service and report.phase == phase report.service == event.service and report.phase == phase
for report in state.phone_reports for report in state.phone_reports
): ):
@ -144,7 +144,7 @@ async def _handle(session_id: UUID, state, event) -> None:
state.dds_log.append(("phone.dial", now_utc(), crew)) state.dds_log.append(("phone.dial", now_utc(), crew))
hub.to_station(session_id, PhoneReport(**report.model_dump())) hub.to_station(session_id, PhoneReport(**report.model_dump()))
case "card.reply": case "card.reply":
if state.exercise is not Exercise.DDS or not state.dispatched_card or ( if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.dispatched_card or (
event.card_id != state.dispatched_card.card_id event.card_id != state.dispatched_card.card_id
): ):
_error(session_id, "Ответ относится не к текущей карточке") _error(session_id, "Ответ относится не к текущей карточке")
@ -168,7 +168,7 @@ async def _handle(session_id: UUID, state, event) -> None:
else: else:
await _finish_dds(session_id, state) await _finish_dds(session_id, state)
case "station.finish": case "station.finish":
if state.exercise is not Exercise.DDS: if state.exercise is not Exercise.DDS and not state.handoff_to_dds:
_error(session_id, "Операторское занятие завершается после звонка 112") _error(session_id, "Операторское занятие завершается после звонка 112")
return return
await _finish_dds(session_id, state) await _finish_dds(session_id, state)

View file

@ -95,12 +95,12 @@ def _by_code() -> dict[str, Incident]:
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def _by_signs() -> dict[tuple[str, ...], Incident]: def _by_signs() -> dict[tuple[int, tuple[str, ...]], Incident]:
"""Признаки → строка классификатора. Нормализуем регистр: методист пишет """Признаки → строка классификатора. Нормализуем регистр: методист пишет
«Жилой дом», книга — «жилой дом», и это одно и то же.""" «Жилой дом», книга — «жилой дом», и это одно и то же."""
table: dict[tuple[str, ...], Incident] = {} table: dict[tuple[int, tuple[str, ...]], Incident] = {}
for incident in reference().incidents: for incident in reference().incidents:
table.setdefault(tuple(sign.lower() for sign in incident.signs), incident) table.setdefault((incident.group, tuple(sign.lower() for sign in incident.signs)), incident)
return table return table
@ -108,10 +108,14 @@ def incident(code: str) -> Incident | None:
return _by_code().get(code) return _by_code().get(code)
def by_signs(signs: list[str]) -> Incident | None: def by_signs(signs: list[str], group: int | None = None) -> Incident | None:
"""Комбинация признаков → происшествие. Порядок признаков значим: """Комбинация признаков → происшествие. Порядок признаков значим:
в книге это три уровня, а не множество.""" в книге это три уровня, а не множество."""
return _by_signs().get(tuple(sign.strip().lower() for sign in signs if sign.strip())) key = tuple(sign.strip().lower() for sign in signs if sign.strip())
if group is not None:
return _by_signs().get((group, key))
return next((item for (item_group, item_signs), item in _by_signs().items()
if item_signs == key), None)
def groups() -> list[IncidentGroup]: def groups() -> list[IncidentGroup]:
@ -146,7 +150,7 @@ def notify_list(code: str, modifiers: list[str] | None = None) -> list[str]:
return service_names(indexes) return service_names(indexes)
def signs_at_level(level: int, prefix: list[str] | None = None) -> list[str]: def signs_at_level(level: int, prefix: list[str] | None = None, group: int | None = None) -> list[str]:
"""Значения признака уровня 1–3 при уже выбранных предыдущих. """Значения признака уровня 1–3 при уже выбранных предыдущих.
Это источник выпадающих списков в карточке: выбрал «жилой дом» — во втором Это источник выпадающих списков в карточке: выбрал «жилой дом» — во втором
@ -155,6 +159,8 @@ def signs_at_level(level: int, prefix: list[str] | None = None) -> list[str]:
chosen = [sign.strip().lower() for sign in (prefix or [])] chosen = [sign.strip().lower() for sign in (prefix or [])]
values: list[str] = [] values: list[str] = []
for item in reference().incidents: for item in reference().incidents:
if group is not None and item.group != group:
continue
if len(item.signs) < level or item.signs[0].lower() == HIDDEN_SIGN: if len(item.signs) < level or item.signs[0].lower() == HIDDEN_SIGN:
continue continue
if [sign.lower() for sign in item.signs[: len(chosen)]] != chosen: if [sign.lower() for sign in item.signs[: len(chosen)]] != chosen:

View file

@ -128,6 +128,7 @@ class CardBriefing(BaseModel):
text: str text: str
required_fields: list[str] required_fields: list[str]
card: KIO | None = None card: KIO | None = None
handoff_to_dds: bool = False
class CallStarted(BaseModel): class CallStarted(BaseModel):
@ -424,6 +425,7 @@ class ScenarioStart(BaseModel):
group_id: str | None = None group_id: str | None = None
mode: SessionMode mode: SessionMode
exercise: Exercise = Exercise.CALL exercise: Exercise = Exercise.CALL
handoff_to_dds: bool = False
class DirectorInject(BaseModel): class DirectorInject(BaseModel):

View file

@ -104,6 +104,7 @@ class KIO(BaseModel):
# Происшествие. Оператор проставляет признаки, остальное считает система # Происшествие. Оператор проставляет признаки, остальное считает система
# по ЕКП (domain/ekp.py) — службу он не выбирает. # по ЕКП (domain/ekp.py) — службу он не выбирает.
incident_group: int | None = None
signs: list[str] = Field(default_factory=list, max_length=3) signs: list[str] = Field(default_factory=list, max_length=3)
incident_code: str | None = Field(default=None, description="Код ЕКП по признакам") incident_code: str | None = Field(default=None, description="Код ЕКП по признакам")
incident_type: IncidentType | None = None incident_type: IncidentType | None = None
@ -163,7 +164,7 @@ def missing_fields(card: KIO, required: list[str]) -> list[str]:
#: те же «признаки», только не про тип происшествия, а про обстоятельства: #: те же «признаки», только не про тип происшествия, а про обстоятельства:
#: пострадавшие поднимают скорую, газификация — МОСГАЗ (domain/ekp.py). #: пострадавшие поднимают скорую, газификация — МОСГАЗ (domain/ekp.py).
MODIFIER_FIELDS: frozenset[str] = frozenset( MODIFIER_FIELDS: frozenset[str] = frozenset(
{"signs", "victims_count", "life_threat", "evacuation_needed", "fire.gasified"} {"incident_group", "signs", "victims_count", "life_threat", "evacuation_needed", "fire.gasified"}
) )
@ -189,7 +190,7 @@ def derive_incident(card: KIO) -> KIO:
""" """
from app.domain import ekp from app.domain import ekp
found = ekp.by_signs(card.signs) found = ekp.by_signs(card.signs, card.incident_group)
keys = modifiers(card) keys = modifiers(card)
data = card.model_dump() data = card.model_dump()
data["incident_code"] = found.code if found else None data["incident_code"] = found.code if found else None

View file

@ -2,6 +2,7 @@
import re import re
from app.domain import ekp
from app.domain.kio import KIO, ResponseStatus from app.domain.kio import KIO, ResponseStatus
from app.scenarios.schema import Scenario from app.scenarios.schema import Scenario
from app.session.state import SessionState, now_utc from app.session.state import SessionState, now_utc
@ -25,6 +26,8 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
address=truth.address or address_fact or None, address=truth.address or address_fact or None,
floor=floor.group(1) if floor else None, floor=floor.group(1) if floor else None,
incident_type=truth.incident_type, incident_code=truth.incident_code, incident_type=truth.incident_type, incident_code=truth.incident_code,
incident_group=(ekp.incident(truth.incident_code).group
if truth.incident_code and ekp.incident(truth.incident_code) else None),
dds=truth.dds, signs=list(scenario.signs), dds=truth.dds, signs=list(scenario.signs),
notify=list(truth.notify) or ([fallback[service]] if service in fallback else []), notify=list(truth.notify) or ([fallback[service]] if service in fallback else []),
victims_count=truth.victims, victims_count=truth.victims,

View file

@ -126,7 +126,9 @@ async def finish(session_id: UUID, state) -> None:
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут # Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии # рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
# участвуют оба (docs/spec/DATASET.md#статусы-реагирования). # участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
if state.dispatched_card is not None and state.exercise is Exercise.CALL: if state.dispatched_card is not None and (
state.exercise is Exercise.CALL or state.handoff_to_dds
):
dispatcher_findings = evaluate_dispatcher( dispatcher_findings = evaluate_dispatcher(
entries=state.status_log, entries=state.status_log,
services=state.notified_services(), services=state.notified_services(),

View file

@ -71,6 +71,8 @@ class SessionState:
level: str level: str
mode: SessionMode mode: SessionMode
exercise: Exercise = Exercise.CALL exercise: Exercise = Exercise.CALL
#: После заполнения КИО занятие продолжится на АРМ ДДС, а не завершится.
handoff_to_dds: bool = False
required_fields: list[str] = field(default_factory=list) required_fields: list[str] = field(default_factory=list)
trainee_name: str | None = None trainee_name: str | None = None
#: Чьё это занятие. Проставляется при запуске, когда курсант известен #: Чьё это занятие. Проставляется при запуске, когда курсант известен

View file

@ -36,12 +36,13 @@ def read_until(socket, wanted):
raise AssertionError(f"событие {wanted} не пришло") raise AssertionError(f"событие {wanted} не пришло")
def start(client): def start(client, *, handoff_to_dds=False):
session_id = uuid4() session_id = uuid4()
context = client.websocket_connect(f"/ws/control/{session_id}") context = client.websocket_connect(f"/ws/control/{session_id}")
control = context.__enter__() control = context.__enter__()
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2", control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов", "mode": "training", "exercise": "card"}) "trainee": "Иванов", "mode": "training", "exercise": "card",
"handoff_to_dds": handoff_to_dds})
wait_for(lambda: hub.get(session_id)) wait_for(lambda: hub.get(session_id))
return session_id, context return session_id, context
@ -123,3 +124,53 @@ def test_incomplete_card_has_only_card_findings(client):
assert report["reference_questions"] == [] assert report["reference_questions"] == []
finally: finally:
control.__exit__(None, None, None) control.__exit__(None, None, None)
def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
session_id, control = start(client, handoff_to_dds=True)
try:
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
briefing = read_until(trainee, "card.briefing")
assert briefing["handoff_to_dds"] is True
trainee.send_json({"type": "kio.patch", "fields": {
"address": "улица Ленина, 14", "incident_type": "fire",
"victims_count": 2, "description": "горит балкон",
"signs": ["жилой дом", "балкон", "открытое пламя"],
}})
wait_for(lambda: hub.get(session_id).kio.incident_code)
trainee.send_json({"type": "card.submit"})
assert read_until(trainee, "call.ended")["reason"] == "complete"
state = hub.get(session_id)
assert state.dispatched_card is not None and not state.ended
assert state.score is None
trainee.send_json({"type": "kio.patch", "fields": {"address": "поздняя правка"}})
assert "передана" in read_until(trainee, "error")["message"]
with client.websocket_connect(f"/ws/call/{session_id}") as late:
assert read_until(late, "card.briefing")["card"]["address"] == "улица Ленина, 14"
assert read_until(late, "call.ended")["reason"] == "complete"
with client.websocket_connect(f"/ws/station/{session_id}") as station:
assert read_until(station, "card.received")["card"]["address"] == "улица Ленина, 14"
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
crew = next(item for item in snapshot["crew_options"] if item.startswith(service + " — "))
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
station.send_json({"type": "phone.dial"})
assert read_until(station, "phone.report")["phase"] == "dispatched"
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": service, "status": "responding"})
read_until(station, "station.state")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
assert hub.get(session_id).ended
keys = {item["key"] for item in score["metrics"]}
assert {"address", "incident_signs", "dds_primary", "dds_contact"} <= keys
assert score["score_auto"] < 100
finally:
control.__exit__(None, None, None)

View file

@ -5,9 +5,11 @@
""" """
import pytest import pytest
from pathlib import Path
from app.domain import ekp from app.domain import ekp
from app.domain.kio import KIO, derive_incident from app.domain.kio import KIO, derive_incident
from app.scenarios.loader import load_library
def test_reference_loads_whole_book(): def test_reference_loads_whole_book():
@ -20,6 +22,20 @@ def test_reference_loads_whole_book():
assert all(incident.type for incident in reference.incidents), "код без итогового типа" assert all(incident.type for incident in reference.incidents), "код без итогового типа"
def test_all_customer_ticket_cards_are_complete_and_classified():
cards = [scenario for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.ticket is not None]
assert len(cards) == 96
assert {scenario.ticket for scenario in cards} == set(range(1, 33))
for ticket in range(1, 33):
assert {scenario.position for scenario in cards if scenario.ticket == ticket} == {1, 2, 3}
assert all(scenario.facts and scenario.signs and scenario.ground_truth.address
and scenario.ground_truth.victims is not None
and scenario.ground_truth.incident_code
and ekp.incident(scenario.ground_truth.incident_code)
for scenario in cards)
def test_signs_give_the_code_from_the_book(): def test_signs_give_the_code_from_the_book():
found = ekp.by_signs(["жилой дом", "подъезд", "открытое пламя"]) found = ekp.by_signs(["жилой дом", "подъезд", "открытое пламя"])
assert found is not None assert found is not None
@ -40,6 +56,20 @@ def test_next_level_narrows_by_previous():
assert "мусор" not in second, "«мусор» относится к улице, а не к жилому дому" assert "мусор" not in second, "«мусор» относится к улице, а не к жилому дому"
def test_category_filters_all_three_incident_choices_and_card_derivation():
fire = ekp.by_signs(["жилой дом", "балкон", "открытое пламя"])
assert fire is not None
other_group = next(group.id for group in ekp.groups() if group.id != fire.group)
assert ekp.by_signs(fire.signs, fire.group) == fire
assert ekp.by_signs(fire.signs, other_group) is None
assert fire.signs[0] in ekp.signs_at_level(1, group=fire.group)
assert fire.signs[1] in ekp.signs_at_level(2, fire.signs[:1], group=fire.group)
assert fire.signs[2] in ekp.signs_at_level(3, fire.signs[:2], group=fire.group)
card = derive_incident(KIO(incident_group=fire.group, signs=fire.signs))
assert card.incident_code == fire.code
assert derive_incident(KIO(incident_group=other_group, signs=fire.signs)).incident_code is None
def test_service_rows_hidden_from_operator_are_not_offered(): def test_service_rows_hidden_from_operator_are_not_offered():
"""Часть строк книги существует ради интеграции и оператору не показывается.""" """Часть строк книги существует ради интеграции и оператору не показывается."""
assert all(ekp.HIDDEN_SIGN not in sign.lower() for sign in ekp.signs_at_level(1)) assert all(ekp.HIDDEN_SIGN not in sign.lower() for sign in ekp.signs_at_level(1))

View file

@ -25,7 +25,7 @@ export function useControl(sessionId: string | null) {
}, [sessionId]); }, [sessionId]);
const start = useCallback( const start = useCallback(
(scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string, scenarioIds?: string[]) => { (scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string, scenarioIds?: string[], handoffToDds = false) => {
const message = { const message = {
type: "scenario.start", type: "scenario.start",
scenario_id: scenarioId, scenario_id: scenarioId,
@ -34,6 +34,7 @@ export function useControl(sessionId: string | null) {
group_id: group || null, group_id: group || null,
trainee_id: traineeId || null, trainee_id: traineeId || null,
exercise, exercise,
handoff_to_dds: handoffToDds,
scenario_ids: scenarioIds?.length ? scenarioIds : undefined, scenario_ids: scenarioIds?.length ? scenarioIds : undefined,
} as const; } as const;
channel.current?.send(message); channel.current?.send(message);

View file

@ -1,7 +1,7 @@
// Карточка КИО. Один и тот же компонент рисуется у курсанта, на пульте, // Карточка КИО. Один и тот же компонент рисуется у курсанта, на пульте,
// на внешнем мониторе и на АРМ ДДС — там в режиме `readOnly`. // на внешнем мониторе и на АРМ ДДС — там в режиме `readOnly`.
import { useSigns } from "@/shared/api/http"; import { useIncidentGroups, useSigns } from "@/shared/api/http";
import { type CardState, display } from "./merge"; import { type CardState, display } from "./merge";
import { type Field, type FieldGroup, visibleGroups } from "./fields"; import { type Field, type FieldGroup, visibleGroups } from "./fields";
@ -16,6 +16,7 @@ interface Props {
export function KioCard({ state, required = [], readOnly = false, referenceArm = false, onChange }: Props) { export function KioCard({ state, required = [], readOnly = false, referenceArm = false, onChange }: Props) {
const dds = display(state, "dds") as string | null; const dds = display(state, "dds") as string | null;
const incidentGroup = display(state, "incident_group") as number | null;
const groups = visibleGroups(dds); const groups = visibleGroups(dds);
const section = (group: FieldGroup) => ( const section = (group: FieldGroup) => (
<section className={`kio-arm-section kio-arm-${group.area}`} key={group.title}> <section className={`kio-arm-section kio-arm-${group.area}`} key={group.title}>
@ -32,6 +33,7 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
required={required.includes(field.path)} required={required.includes(field.path)}
readOnly={readOnly || field.readOnly} readOnly={readOnly || field.readOnly}
referenceArm={referenceArm} referenceArm={referenceArm}
incidentGroup={incidentGroup}
onChange={onChange} onChange={onChange}
/> />
))} ))}
@ -54,7 +56,7 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
} }
function Row({ function Row({
field, value, pending, required, readOnly, referenceArm, onChange, field, value, pending, required, readOnly, referenceArm, incidentGroup, onChange,
}: { }: {
field: Field; field: Field;
value: unknown; value: unknown;
@ -62,6 +64,7 @@ function Row({
required: boolean; required: boolean;
readOnly?: boolean; readOnly?: boolean;
referenceArm: boolean; referenceArm: boolean;
incidentGroup: number | null;
onChange?: (path: string, value: unknown) => void; onChange?: (path: string, value: unknown) => void;
}) { }) {
const empty = value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0); const empty = value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
@ -79,7 +82,7 @@ function Row({
{readOnly ? ( {readOnly ? (
<span className="kio-arm-readonly">{formatValue(value, field)}</span> <span className="kio-arm-readonly">{formatValue(value, field)}</span>
) : ( ) : (
<Input field={field} value={value} referenceArm={referenceArm} onChange={onChange} /> <Input field={field} value={value} referenceArm={referenceArm} incidentGroup={incidentGroup} onChange={onChange} />
)} )}
</div> </div>
</div> </div>
@ -87,11 +90,12 @@ function Row({
} }
function Input({ function Input({
field, value, referenceArm, onChange, field, value, referenceArm, incidentGroup, onChange,
}: { }: {
field: Field; field: Field;
value: unknown; value: unknown;
referenceArm: boolean; referenceArm: boolean;
incidentGroup: number | null;
onChange?: (path: string, value: unknown) => void; onChange?: (path: string, value: unknown) => void;
}) { }) {
const emit = (next: unknown) => onChange?.(field.path, next); const emit = (next: unknown) => onChange?.(field.path, next);
@ -99,6 +103,12 @@ function Input({
if (field.kind === "list") { if (field.kind === "list") {
return <span className="kio-arm-readonly">{formatValue(value)}</span>; return <span className="kio-arm-readonly">{formatValue(value)}</span>;
} }
if (field.kind === "group") {
return <IncidentGroupPicker value={value as number | null} onChange={(next) => {
onChange?.("incident_group", next);
onChange?.("signs", []);
}} />;
}
if (field.kind === "bool") { if (field.kind === "bool") {
return ( return (
<div className="kio-arm-choice" role="group" aria-label={field.label}> <div className="kio-arm-choice" role="group" aria-label={field.label}>
@ -129,7 +139,7 @@ function Input({
); );
} }
if (field.kind === "signs") { if (field.kind === "signs") {
return <SignsPicker value={(value as string[]) ?? []} onChange={(next) => emit(next)} />; return <SignsPicker value={(value as string[]) ?? []} group={incidentGroup} onChange={(next) => emit(next)} />;
} }
if (field.kind === "textarea") { if (field.kind === "textarea") {
return <textarea aria-label={field.label} rows={5} value={(value as string) ?? ""} placeholder="Введите сведения со слов заявителя" onChange={(e) => emit(e.target.value)} />; return <textarea aria-label={field.label} rows={5} value={(value as string) ?? ""} placeholder="Введите сведения со слов заявителя" onChange={(e) => emit(e.target.value)} />;
@ -157,17 +167,30 @@ function Input({
); );
} }
function IncidentGroupPicker({ value, onChange }: { value: number | null; onChange: (next: number | null) => void }) {
const page = useIncidentGroups();
return <select aria-label="Категория происшествия" value={value ?? ""}
onChange={(event) => onChange(event.target.value ? Number(event.target.value) : null)}>
<option value="">Все категории ЕКП</option>
{page.data?.groups.map((group) => <option key={group.id} value={group.id}>{group.title}</option>)}
</select>;
}
/** Три признака происшествия каскадом: выбор верхнего сужает нижний. /** Три признака происшествия каскадом: выбор верхнего сужает нижний.
* *
* Значения приходят с сервера, а не лежат на фронте: классификатор — 1283 строки, * Значения приходят с сервера, а не лежат на фронте: классификатор — 1283 строки,
* и он обновляется файлом от заказчика (docs/spec/DATASET.md). Служба из выбора * и он обновляется файлом от заказчика (docs/spec/DATASET.md). Служба из выбора
* не следует — её считает сервер, поэтому здесь её нет. */ * не следует — её считает сервер, поэтому здесь её нет. */
function SignsPicker({ function SignsPicker({
value, onChange, value, group, onChange,
}: { }: {
value: string[]; value: string[];
group: number | null;
onChange: (next: string[]) => void; onChange: (next: string[]) => void;
}) { }) {
if (group === null) {
return <p className="kio-signs-hint">Сначала выберите категорию происшествия — затем появятся признаки ЕКП.</p>;
}
const levels = [0, 1, 2]; const levels = [0, 1, 2];
return ( return (
<div className="signs"> <div className="signs">
@ -175,6 +198,7 @@ function SignsPicker({
<SignSelect <SignSelect
key={level} key={level}
prefix={value.slice(0, level)} prefix={value.slice(0, level)}
group={group}
chosen={value[level] ?? ""} chosen={value[level] ?? ""}
onPick={(sign) => { onPick={(sign) => {
// Смена верхнего признака обнуляет нижние: они относились // Смена верхнего признака обнуляет нижние: они относились
@ -193,13 +217,14 @@ function SignsPicker({
} }
function SignSelect({ function SignSelect({
prefix, chosen, onPick, prefix, group, chosen, onPick,
}: { }: {
prefix: string[]; prefix: string[];
group: number | null;
chosen: string; chosen: string;
onPick: (sign: string) => void; onPick: (sign: string) => void;
}) { }) {
const page = useSigns(prefix); const page = useSigns(prefix, group);
const values = page.data?.values ?? []; const values = page.data?.values ?? [];
// Уровня может не быть: часть происшествий классифицируется двумя признаками. // Уровня может не быть: часть происшествий классифицируется двумя признаками.
if (!page.isLoading && values.length === 0) return null; if (!page.isLoading && values.length === 0) return null;

View file

@ -6,7 +6,7 @@
import type { DDSCode, IncidentType } from "@/shared/types/generated"; import type { DDSCode, IncidentType } from "@/shared/types/generated";
export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "list"; export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "group" | "list";
export interface Field { export interface Field {
/** Путь в карточке: «floor», «fire.floors» — так же, как в `kio.patch`. */ /** Путь в карточке: «floor», «fire.floors» — так же, как в `kio.patch`. */
@ -88,6 +88,7 @@ export const GROUPS: FieldGroup[] = [
title: "Происшествие", title: "Происшествие",
area: "incident", area: "incident",
fields: [ fields: [
{ path: "incident_group", label: "Категория происшествия", kind: "group" },
// Признаки по ЕКП — то, что оператор действительно проставляет. // Признаки по ЕКП — то, что оператор действительно проставляет.
// Код и список оповещения считает сервер (docs/spec/DATASET.md). // Код и список оповещения считает сервер (docs/spec/DATASET.md).
{ path: "signs", label: "Признаки происшествия", kind: "signs" }, { path: "signs", label: "Признаки происшествия", kind: "signs" },

View file

@ -15,7 +15,7 @@ import { StandStatus } from "@/features/health/StandStatus";
import { ModeBanner } from "@/features/mode-banner/ModeBanner"; import { ModeBanner } from "@/features/mode-banner/ModeBanner";
import { useObserve } from "@/features/observe/useObserve"; import { useObserve } from "@/features/observe/useObserve";
import { InterviewTimer, Timers } from "@/features/timers/Timers"; import { InterviewTimer, Timers } from "@/features/timers/Timers";
import { useScenarios, useSessions, useTrainees } from "@/shared/api/http"; import { useIncidentGroups, useScenarios, useSessions, useTrainees } from "@/shared/api/http";
import { sessionIdFromUrl } from "@/shared/api/session"; import { sessionIdFromUrl } from "@/shared/api/session";
import type { SessionMode } from "@/shared/types/generated"; import type { SessionMode } from "@/shared/types/generated";
@ -28,9 +28,12 @@ const MODES: { value: SessionMode; label: string }[] = [
export function Instructor() { export function Instructor() {
const [sessionId, setSessionId] = useState<string | null>(sessionIdFromUrl()); const [sessionId, setSessionId] = useState<string | null>(sessionIdFromUrl());
const [scenarioId, setScenarioId] = useState(""); const [scenarioId, setScenarioId] = useState("");
const [ticketFilter, setTicketFilter] = useState("");
const [categoryFilter, setCategoryFilter] = useState("");
const [scenarioSearch, setScenarioSearch] = useState("");
const [extraScenarioIds, setExtraScenarioIds] = useState<string[]>([]); const [extraScenarioIds, setExtraScenarioIds] = useState<string[]>([]);
const [mode, setMode] = useState<SessionMode>("training"); const [mode, setMode] = useState<SessionMode>("training");
const [exercise, setExercise] = useState<"dds" | "call" | "card">("dds"); const [exercise, setExercise] = useState<"dds" | "call" | "card" | "card_dds">("dds");
const [trainee, setTrainee] = useState(""); const [trainee, setTrainee] = useState("");
const [traineeId, setTraineeId] = useState(""); const [traineeId, setTraineeId] = useState("");
const [noteFor, setNoteFor] = useState<string | null>(null); const [noteFor, setNoteFor] = useState<string | null>(null);
@ -40,14 +43,24 @@ export function Instructor() {
const [noteText, setNoteText] = useState(""); const [noteText, setNoteText] = useState("");
const scenarios = useScenarios(); const scenarios = useScenarios();
const incidentGroups = useIncidentGroups();
const history = useSessions(); const history = useSessions();
const trainees = useTrainees(); const trainees = useTrainees();
const session = useObserve(sessionId); const session = useObserve(sessionId);
const control = useControl(sessionId); const control = useControl(sessionId);
const visibleScenarios = (scenarios.data ?? []).filter((item) =>
(!ticketFilter || item.ticket === Number(ticketFilter)) &&
(!categoryFilter || item.ekp_group === Number(categoryFilter)) &&
(!scenarioSearch.trim() || `${item.title} ${item.id}`.toLocaleLowerCase("ru-RU")
.includes(scenarioSearch.trim().toLocaleLowerCase("ru-RU")))
).sort((a, b) => (a.ticket ?? 99) - (b.ticket ?? 99) || (a.position ?? 0) - (b.position ?? 0));
useEffect(() => { useEffect(() => {
if (scenarios.data?.length && !scenarioId) setScenarioId(scenarios.data[0].id); if (!visibleScenarios.some((item) => item.id === scenarioId)) {
}, [scenarios.data, scenarioId]); setScenarioId(visibleScenarios[0]?.id ?? "");
}
}, [scenarios.data, ticketFilter, categoryFilter, scenarioSearch, scenarioId]);
function startLesson() { function startLesson() {
if (trainees.data?.length && !traineeId) return; if (trainees.data?.length && !traineeId) return;
@ -65,8 +78,10 @@ export function Instructor() {
const selected = trainees.data?.find((item) => item.id === traineeId); const selected = trainees.data?.find((item) => item.id === traineeId);
const scenarioIds = exercise === "dds" const scenarioIds = exercise === "dds"
? [scenarioId, ...extraScenarioIds.filter((id) => id !== scenarioId)] : undefined; ? [scenarioId, ...extraScenarioIds.filter((id) => id !== scenarioId)] : undefined;
control.start(scenarioId, mode, selected?.name || trainee || "Демо-курсант", exercise, control.start(scenarioId, mode, selected?.name || trainee || "Демо-курсант",
undefined, selected?.id, scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined); exercise === "card_dds" ? "card" : exercise,
undefined, selected?.id, scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined,
exercise === "card_dds");
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data, exercise]); }, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data, exercise]);
const links = sessionId const links = sessionId
@ -78,29 +93,44 @@ export function Instructor() {
: null; : null;
return ( return (
<main className="page"> <main className="page instructor-page">
<StandStatus /> <StandStatus />
<ModeBanner mode={session.mode ?? mode} /> <ModeBanner mode={session.mode ?? mode} />
<h1>Рабочее место преподавателя</h1> <h1>Рабочее место преподавателя</h1>
<div className="columns"> <div className="instructor-layout">
<section style={{ flex: "0 0 280px" }}> <section>
<h2>Занятие</h2> <h2>Занятие</h2>
<table className="grid"> <table className="grid">
<tbody> <tbody>
<tr> <tr>
<th>Сценарий</th> <th>Сценарий</th>
<td> <td>
<div className="instructor-scenario-filters">
<select aria-label="Категория происшествия" value={categoryFilter} onChange={(event) => setCategoryFilter(event.target.value)}>
<option value="">Все 23 категории ЕКП</option>
{incidentGroups.data?.groups.map((group) =>
<option key={group.id} value={group.id}>{group.title}</option>)}
</select>
<select aria-label="Билет заказчика" value={ticketFilter} onChange={(event) => setTicketFilter(event.target.value)}>
<option value="">Все 32 билета</option>
{Array.from({ length: 32 }, (_, index) => <option key={index} value={index + 1}>Билет {index + 1}</option>)}
</select>
<input type="search" aria-label="Поиск сценария" value={scenarioSearch}
placeholder="Название или ID карточки" onChange={(event) => setScenarioSearch(event.target.value)} />
</div>
<select value={scenarioId} onChange={(event) => { <select value={scenarioId} onChange={(event) => {
setScenarioId(event.target.value); setScenarioId(event.target.value);
setExtraScenarioIds((previous) => previous.filter((id) => id !== event.target.value)); setExtraScenarioIds((previous) => previous.filter((id) => id !== event.target.value));
}}> }}>
{scenarios.data?.map((scenario) => ( {!visibleScenarios.length && <option value="">Карточки не найдены</option>}
{visibleScenarios.map((scenario) => (
<option key={scenario.id} value={scenario.id}> <option key={scenario.id} value={scenario.id}>
{scenario.title} · {scenario.level} {scenario.ticket ? `Билет ${scenario.ticket}, вызов ${scenario.position} · ` : ""}{scenario.title} · {scenario.level}
</option> </option>
))} ))}
</select> </select>
<small className="ref">{visibleScenarios.length} из {scenarios.data?.length ?? 0} карточек (96 — билеты заказчика)</small>
{exercise === "dds" && <details className="scenario-queue"> {exercise === "dds" && <details className="scenario-queue">
<summary>Дополнительные карточки ({extraScenarioIds.length})</summary> <summary>Дополнительные карточки ({extraScenarioIds.length})</summary>
<p className="ref">Карточки поступят последовательно после завершения текущей.</p> <p className="ref">Карточки поступят последовательно после завершения текущей.</p>
@ -123,6 +153,8 @@ export function Instructor() {
onChange={() => setExercise("call")} /> Оператор 112 · звонок</label> onChange={() => setExercise("call")} /> Оператор 112 · звонок</label>
<label className="mode-choice"><input type="radio" checked={exercise === "card"} <label className="mode-choice"><input type="radio" checked={exercise === "card"}
onChange={() => setExercise("card")} /> Оператор 112 · текстовая вводная</label> onChange={() => setExercise("card")} /> Оператор 112 · текстовая вводная</label>
<label className="mode-choice"><input type="radio" checked={exercise === "card_dds"}
onChange={() => setExercise("card_dds")} /> Связка 112 → ДДС · одна карточка</label>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -235,7 +267,7 @@ export function Instructor() {
)} )}
</section> </section>
<section style={{ flex: "0 0 260px" }}> <section>
<h2>История группы</h2> <h2>История группы</h2>
<p><a href="/profile">профили курсантов</a> · <a href="/groups">аналитика группы</a></p> <p><a href="/profile">профили курсантов</a> · <a href="/groups">аналитика группы</a></p>
<table className="grid"> <table className="grid">

View file

@ -72,10 +72,14 @@ export function Call() {
</span> </span>
{!call.cardSubmitted && <button type="button" className="arm-primary" disabled={call.status !== "open"} {!call.cardSubmitted && <button type="button" className="arm-primary" disabled={call.status !== "open"}
onClick={call.submitCard}>Сохранить и проверить</button>} onClick={call.submitCard}>Сохранить и проверить</button>}
{call.cardSubmitted && <strong>Карточка отправлена</strong>} {call.cardSubmitted && <strong>{call.briefing.handoff_to_dds ? "Карточка передана ДДС" : "Карточка отправлена"}</strong>}
</footer> </footer>
{call.error && <p className="state-violated arm-message" role="alert">{call.error}</p>} {call.error && <p className="state-violated arm-message" role="alert">{call.error}</p>}
{call.cardSubmitted && !call.report && !call.reportError && <p className="arm-message">Карточка отправлена. Ожидаем итоговую оценку…</p>} {call.cardSubmitted && !call.report && !call.reportError && <p className="arm-message">
{call.briefing.handoff_to_dds
? <>Карточка передана в ДДС. <a href={`/dds?session=${sessionId}&role=dds_01`}>Открыть АРМ ДДС</a> и завершить реагирование для итоговой оценки.</>
: "Карточка отправлена. Ожидаем итоговую оценку…"}
</p>}
{call.reportError && <p className="state-violated arm-message" role="alert">{call.reportError}</p>} {call.reportError && <p className="state-violated arm-message" role="alert">{call.reportError}</p>}
{call.report && <Debrief report={call.report} />} {call.report && <Debrief report={call.report} />}
</main>; </main>;

View file

@ -43,6 +43,9 @@ export interface ScenarioSummary {
topics: string[]; topics: string[];
modes: SessionMode[]; modes: SessionMode[];
dds: string | null; dds: string | null;
ticket: number | null;
position: number | null;
ekp_group: number | null;
} }
export interface SessionInfo { export interface SessionInfo {
@ -113,10 +116,23 @@ export interface SignsPage {
incident: { code: string; type: string; notify: string[] } | null; incident: { code: string; type: string; notify: string[] } | null;
} }
export function useSigns(prefix: string[]) { export interface IncidentGroupsPage {
const query = prefix.map((sign) => `prefix=${encodeURIComponent(sign)}`).join("&"); version: string;
groups: { id: number; title: string }[];
}
export const useIncidentGroups = () => useQuery({
queryKey: ["ekp-groups"], staleTime: Infinity,
queryFn: () => request<IncidentGroupsPage>("/api/ekp/groups"),
});
export function useSigns(prefix: string[], group: number | null = null) {
const params = new URLSearchParams();
if (group !== null) params.set("group", String(group));
for (const sign of prefix) params.append("prefix", sign);
const query = params.toString();
return useQuery({ return useQuery({
queryKey: ["ekp-signs", prefix], queryKey: ["ekp-signs", group, prefix],
// Справочник не меняется между занятиями — перезапрашивать незачем. // Справочник не меняется между занятиями — перезапрашивать незачем.
staleTime: Infinity, staleTime: Infinity,
queryFn: () => request<SignsPage>(`/api/ekp/signs${query ? `?${query}` : ""}`), queryFn: () => request<SignsPage>(`/api/ekp/signs${query ? `?${query}` : ""}`),

View file

@ -85,6 +85,7 @@ export interface CardBriefing {
text: string; text: string;
required_fields: Array<string>; required_fields: Array<string>;
card?: KIO | null; card?: KIO | null;
handoff_to_dds?: boolean;
} }
export interface CardNext { export interface CardNext {
@ -280,6 +281,7 @@ export interface KIO {
floor?: string | null; floor?: string | null;
intercom_code?: string | null; intercom_code?: string | null;
coords?: Coords | null; coords?: Coords | null;
incident_group?: number | null;
signs?: Array<string>; signs?: Array<string>;
/** Код ЕКП по признакам */ /** Код ЕКП по признакам */
incident_code?: string | null; incident_code?: string | null;
@ -423,6 +425,7 @@ export interface ScenarioStart {
group_id?: string | null; group_id?: string | null;
mode: SessionMode; mode: SessionMode;
exercise?: Exercise; exercise?: Exercise;
handoff_to_dds?: boolean;
} }
/** Коррекция оценки. Автооценка сохраняется рядом. */ /** Коррекция оценки. Автооценка сохраняется рядом. */

View file

@ -53,6 +53,26 @@ button:disabled { opacity: 0.5; cursor: default; }
/* Две колонки: карточка и разговор рядом, как на настоящем АРМ. */ /* Две колонки: карточка и разговор рядом, как на настоящем АРМ. */
.columns { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; } .columns { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; }
.columns > section { flex: 1 1 420px; min-width: 0; } .columns > section { flex: 1 1 420px; min-width: 0; }
/* Пульт содержит длинные названия сценариев. Узкая первая колонка вместе с
min-width таблицы раньше перекрывала «Ход занятия» при zoom 200%. */
.instructor-layout { display: grid; grid-template-columns: minmax(300px, 360px) minmax(0, 1fr) minmax(230px, 280px); gap: 20px; align-items: start; }
.instructor-layout > section { min-width: 0; }
.instructor-layout .grid { width: 100%; min-width: 0; table-layout: fixed; }
.instructor-layout .grid th { width: 105px; }
.instructor-layout .grid td { min-width: 0; overflow-wrap: anywhere; }
.instructor-layout select, .instructor-layout input { box-sizing: border-box; max-width: 100%; min-width: 0; }
.instructor-layout section:first-child select, .instructor-layout section:first-child input[type="text"] { width: 100%; }
.instructor-layout .mode-choice { overflow-wrap: anywhere; }
.instructor-scenario-filters { display: grid; gap: 5px; margin-bottom: 6px; }
.kio-signs-hint { margin: 4px 0; color: #586572; font-size: 12px; }
@media (max-width: 1350px) {
.instructor-layout { grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); }
.instructor-layout > section:last-child { grid-column: 1 / -1; }
}
@media (max-width: 900px) {
.instructor-layout { display: block; }
.instructor-layout > section { margin-bottom: 24px; }
}
.kio { display: flex; flex-direction: column; gap: 12px; } .kio { display: flex; flex-direction: column; gap: 12px; }
.kio .grid { width: 100%; } .kio .grid { width: 100%; }
.kio thead th { background: var(--line); } .kio thead th { background: var(--line); }