diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 0725e20..17af9ce 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -51,6 +51,8 @@ _INSTANCE = hashlib.sha256( ).hexdigest() _generations: dict[str, int] = {} _active_sockets: dict[str, weakref.WeakKeyDictionary] = {} +# Logins already revoked because their users row disappeared. +_vanished: set[str] = set() AUTH_GENERATION_SYNC_SECONDS = 1.0 AUTH_GENERATION_MAX_AGE_SECONDS = 2.0 _generations_synced_at: float | None = None @@ -121,10 +123,13 @@ async def sync_generations() -> None: # The local-only dev-token principal is synthetic, never stored in users; # a DB watcher must not revoke its in-memory generation during test/demo # flows that deliberately exercise account invalidation. + # The revoked generation stays cached: dropping it would make the next + # lookup fall back to 0 and re-accept cookies issued before the revocation. synthetic = {"dev"} if get_settings().dev_auth_bypass else set() - for login in _generations.keys() - current.keys() - synthetic: + _vanished.intersection_update(_generations.keys() - current.keys()) + for login in _generations.keys() - current.keys() - synthetic - _vanished: invalidate_login(login) - _generations.pop(login, None) + _vanished.add(login) global _generations_synced_at _generations_synced_at = time.monotonic() diff --git a/backend/app/api/http/ekp.py b/backend/app/api/http/ekp.py index 116f18d..49d12e4 100644 --- a/backend/app/api/http/ekp.py +++ b/backend/app/api/http/ekp.py @@ -48,3 +48,14 @@ async def signs(prefix: list[str] = Query(default=[]), group: int | None = None) else None ), } + + +@router.get("/services") +async def services() -> dict: + """Каталог служб для ручного добавления в карточку — как окно «Добавьте службы». + + Каталог — справочник боевого АРМ, а не содержимое вызова: отдавать его + курсанту так же безопасно, как группы происшествий. + """ + data = ekp.catalog() + return {"source": data.source, "services": [item.model_dump() for item in data.services]} diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py index bebcc6c..6b85b40 100644 --- a/backend/app/api/http/sessions.py +++ b/backend/app/api/http/sessions.py @@ -23,14 +23,26 @@ from app.db import repo from app.db.base import get_session from app.db.models import Group, Score, Session, Trainee from app.domain.events import Exercise, SessionMode, SessionReport +from app.domain.taxonomy import ( + ERRORS, + ErrorCode, + Finding, + FindingDecision, + FindingReview, + FindingSource, +) from app.domain.roles import Role from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current from app.domain.timers import TimerCode, TimerState from app.scoring.export import to_csv, to_pdf from app.scoring.report import build as build_report +from app.scoring.review import has_finding +from app.scoring.taxonomy import METRIC_MAP from app.session.access import can_access from app.session.checkpoint import load_state -from app.session.finish import override_score +from app.session.finish import change_findings, override_score +from app.session.store import FindingAdded, FindingReviewed +from app.session.timers import now_utc from app.session.score import scoring_scenario from app.session.hub import hub from app.session.state import SessionState @@ -656,6 +668,157 @@ async def override( return SessionReport.model_validate(report["full_report"]) +def _required_text(value: str) -> str: + cleaned = value.strip() + if not cleaned: + raise ValueError("поле обязательно") + return cleaned + + +class FindingReviewIn(BaseModel): + """Решение по отметке: причина обязательна и остаётся в разборе.""" + + decision: FindingDecision + reason: str = Field(min_length=1, max_length=1000) + + _reason = field_validator("reason")(_required_text) + + +class FindingIn(BaseModel): + """Отметка преподавателя: код, факт и норма — то же обоснование, что у автоматической.""" + + code: ErrorCode + fact: str = Field(min_length=1, max_length=1000) + norm: str = Field(min_length=1, max_length=1000) + #: Номер карточки очереди ДДС (с 1); без него отметка относится к занятию. + card: int | None = Field(default=None, ge=1) + #: Ключ идемпотентности с клиента: повтор запроса не добавит отметку второй раз. + client_id: UUID | None = None + + _fact = field_validator("fact")(_required_text) + _norm = field_validator("norm")(_required_text) + + +async def _change_findings( + session_id: UUID, who, db: AsyncSession | None, make, +) -> SessionReport: + """Общий путь решений по отметкам — тот же, что у правки итога. + + `make(who, score)` строит запись изменения по сохранённой оценке или + отвечает HTTP-ошибкой. Роль проверяет маршрут: администратору сюда нельзя, + как и к правке балла. + """ + state = hub.get(session_id) + if state is not None: + _require_access(who, state) + if state.score is None: + raise HTTPException(status_code=409, detail="score_not_ready") + scenario = scoring_scenario(state) + if scenario is None: + raise HTTPException(status_code=409, detail="scenario_not_found") + change = make(who, state.score) + async with hub.operation(session_id): + if not _repeated(state.score, change): + change_findings(state, change) + return build_report(session_id, state, scenario) + + if db is None: + raise HTTPException(status_code=404, detail="session_not_found") + session = await repo.get_session(db, session_id) + if session is None: + raise HTTPException(status_code=404, detail="session_not_found") + _require_access(who, session) + score = await db.scalar(select(Score).where(Score.session_id == session_id)) + if score is None: + raise HTTPException(status_code=409, detail="score_not_ready") + if (score.report or {}).get("full_report") is None: + raise HTTPException(status_code=409, detail="report_not_archived") + current = dict(score.report) + change = make(who, current) + await db.rollback() # чтение закончено; запись — одной транзакцией хранилища + if not _repeated(current, change): + await hub.store.commit_archived(session_id, [change]) + # Ответ — по строке после commit: пересчёт шёл под блокировкой и мог учесть + # решение из соседней вкладки, которого не было в прочитанном выше отчёте. + score = await db.scalar( + select(Score).where(Score.session_id == session_id) + .execution_options(populate_existing=True) + ) + report = SessionReport.model_validate(score.report["full_report"]) + await db.rollback() + return report + + +def _repeated(report: dict, change: FindingReviewed | FindingAdded) -> bool: + return isinstance(change, FindingAdded) and has_finding(report, change.finding.client_id) + + +@router.post("/{session_id}/findings/{index}/review", response_model=SessionReport) +async def review_finding( + session_id: UUID, + index: int, + body: FindingReviewIn, + request: Request, + db: AsyncSession | None = Depends(optional_session), +) -> SessionReport: + """Подтвердить или снять отметку; снятая не штрафует свою метрику.""" + who = require(request, Role.INSTRUCTOR) + + def make(who, score: dict) -> FindingReviewed: + if not 0 <= index < len(score.get("findings", [])): + raise HTTPException(status_code=404, detail="finding_not_found") + return FindingReviewed(index=index, role=who.role.value, review=FindingReview( + decision=body.decision, reason=body.reason, author=who.login, at=now_utc(), + )) + + return await _change_findings(session_id, who, db, make) + + +@router.post("/{session_id}/findings", response_model=SessionReport) +async def add_finding( + session_id: UUID, + body: FindingIn, + request: Request, + db: AsyncSession | None = Depends(optional_session), +) -> SessionReport: + """Отметка преподавателя штрафует связанную метрику по той же методике.""" + who = require(request, Role.INSTRUCTOR) + + def make(who, score: dict) -> FindingAdded: + service = None + prefix = "" + exercise = (score.get("full_report") or {}).get("exercise") + # E1–E6 в связке 112 → ДДС — ошибки приёма вызова: их метрики у занятия, + # а не у карточки очереди. С номером карточки отметка не нашла бы свою + # метрику 112 и штрафовала бы отдельной метрикой преподавателя. + if (body.card is not None and body.code.value.startswith("E") + and exercise != Exercise.DDS.value): + raise HTTPException(status_code=422, detail="call_code_without_card") + if body.card is not None: + cards = score.get("card_results") or [] + if body.card > len(cards): + raise HTTPException(status_code=422, detail="card_not_found") + service = cards[body.card - 1].get("managed_service") + prefix = f"Карточка {body.card}: " + (f"{service}: " if service else "") + competency = next((item for code, item in METRIC_MAP.values() if code is body.code), None) + return FindingAdded(role=who.role.value, finding=Finding( + code=body.code, + source=FindingSource.INSTRUCTOR, + summary=f"{prefix}{ERRORS[body.code].title}", + fact=body.fact, + norm=body.norm, + ref="отметка преподавателя на разборе", + competency=competency, + at=now_utc(), + card=body.card, + service=service, + author=who.login, + client_id=body.client_id, + )) + + return await _change_findings(session_id, who, db, make) + + @router.get("", response_model=list[SessionOut]) async def listing( request: Request, diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py index 82656a9..ac507fb 100644 --- a/backend/app/api/ws/control.py +++ b/backend/app/api/ws/control.py @@ -112,6 +112,7 @@ def _build_state(session_id: UUID, event, who, scenario, scenarios, dds_service=identity.service or event.dds_service, attempt=identity.attempt, criteria=event.criteria, + socket_last_seen_at=now_utc(), ) state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000 state.timers.limits[TimerCode.CARD_FILL] = event.criteria.card_fill_time_limit_seconds * 1000 diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py index 3a93b81..beab725 100644 --- a/backend/app/api/ws/station.py +++ b/backend/app/api/ws/station.py @@ -17,7 +17,7 @@ from uuid import UUID from fastapi import APIRouter, WebSocket, WebSocketDisconnect from pydantic import TypeAdapter, ValidationError -from app.api.ws.session import pump, run_command, session_socket +from app.api.ws.session import close_fenced, pump, run_command, session_socket from app.domain.events import ( CallEndReason, ErrorEvent, @@ -62,9 +62,14 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None: entered = await session_socket(ws, session_id, (Role.INSTRUCTOR, Role.TRAINEE)) if entered is None: return - _who, state = entered + who, state = entered - with hub.station(session_id) as queue: + async with hub.trainee_socket( + session_id, station=True, trainee=who.role is Role.TRAINEE, + ) as queue: + if hub.is_lease_fenced(session_id): + await close_fenced(ws) + return sender = asyncio.create_task(pump(ws, queue)) try: # Карточка, переданная до подключения станции, не теряется: diff --git a/backend/app/config.py b/backend/app/config.py index e318cf4..ff17222 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -120,6 +120,11 @@ class Settings(BaseSettings): def limit_ms(self, code: TimerCode) -> int: return self.timer_limits_ms.get(code, NORMATIVES[code].limit_ms) + # Пороги сигналов реестра живых занятий (пульт преподавателя). + signal_backlog_threshold: int = Field(default=4, ge=1) + signal_refusals_threshold: int = Field(default=2, ge=1) + signal_offline_window_seconds: int = Field(default=45, ge=1) + def validate_deployment_security(self) -> None: """Reject known development authentication defaults on a production app.""" if self.app_env != "production": diff --git a/backend/app/domain/ekp.py b/backend/app/domain/ekp.py index 4e52aa4..5551691 100644 --- a/backend/app/domain/ekp.py +++ b/backend/app/domain/ekp.py @@ -15,6 +15,9 @@ from pathlib import Path from pydantic import BaseModel, Field DATA = Path(__file__).parent / "ekp.json" +#: Каталог окна «Добавьте службы» из «СЛУЖБЫ 112.docx»: 209 служб, в том числе +#: районные ДДС. Источник и лицензия — в шапке файла и в docs/delivery/THIRD-PARTY.md. +CATALOG = Path(__file__).parent / "services_catalog.json" #: Часть строк книги существует ради интеграции с чужими системами, а оператору #: не показывается — книга помечает их прямо в первом признаке. @@ -89,6 +92,37 @@ def reference() -> Reference: return Reference.model_validate_json(DATA.read_text(encoding="utf-8")) +class CatalogService(BaseModel): + """Служба каталога боевого АРМ — то, что оператор добавляет в карточку вручную.""" + + id: str + name: str + organization: str | None = None + #: «ДДС», если это явно указано в названии в каталоге. + service_level: str | None = None + #: Скриншот и строка списка в «СЛУЖБЫ 112.docx». + source_reference: str + + +class Catalog(BaseModel): + source: str + transcription: str + license: str + services: list[CatalogService] + + +@lru_cache(maxsize=1) +def catalog() -> Catalog: + """Каталог только расширяет выбор служб. Список оповещения по-прежнему + считается из признаков по классификатору, эталоны сценариев от него не зависят.""" + return Catalog.model_validate_json(CATALOG.read_text(encoding="utf-8")) + + +@lru_cache(maxsize=1) +def catalog_names() -> frozenset[str]: + return frozenset(service.name for service in catalog().services) + + @lru_cache(maxsize=1) def _by_code() -> dict[str, Incident]: return {incident.code: incident for incident in reference().incidents} diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py index 3b6a6f1..05c4279 100644 --- a/backend/app/domain/events.py +++ b/backend/app/domain/events.py @@ -23,7 +23,7 @@ from app.domain.roles import Role from app.domain.statuses import ServiceStatus, StationSnapshot from app.domain.taxonomy import Finding from app.domain.timers import TimerSnapshot -from app.scoring.taxonomy import METRIC_MAP +from app.scoring.taxonomy import INSTRUCTOR_METRIC, METRIC_MAP class SessionMode(StrEnum): @@ -53,6 +53,8 @@ class LessonCriteria(BaseModel): decision_time_limit_seconds: int = Field(default=30, ge=5, le=300) card_fill_time_limit_seconds: int = Field(default=180, ge=30, le=1800) dds_card_work_time_limit_seconds: int = Field(default=180, ge=30, le=1800) + #: Сколько секунд после доклада бригады даётся на соответствующий статус. + dds_report_reaction_limit_seconds: int = Field(default=45, 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) @@ -60,7 +62,7 @@ class LessonCriteria(BaseModel): @field_validator("score_weights") @classmethod def validate_score_weights(cls, weights: dict[str, float]) -> dict[str, float]: - unknown = weights.keys() - METRIC_MAP.keys() + unknown = weights.keys() - METRIC_MAP.keys() - {INSTRUCTOR_METRIC} if unknown: raise ValueError(f"неизвестные метрики весов: {', '.join(sorted(unknown))}") if any(not 0 <= weight <= 10 for weight in weights.values()): @@ -715,6 +717,12 @@ class Metric(BaseModel): passed: bool weight: float = 1.0 credit: float | None = Field(default=None, ge=0, le=1) + #: Номер карточки очереди ДДС (с 1) и служба — область метрики для разбора. + card: int | None = None + service: str | None = None + #: Автоматический вердикт, если его изменило решение преподавателя по отметке. + passed_auto: bool | None = None + credit_auto: float | None = Field(default=None, ge=0, le=1) class CompetencyScore(BaseModel): @@ -764,6 +772,8 @@ class DdsCardReport(BaseModel): card_id: UUID scenario_id: str score_auto: float + #: Балл карточки после решений преподавателя по отметкам; None — решений не было. + score_reviewed: float | None = None reply_text: str metrics: list[Metric] findings: list[Finding] @@ -803,6 +813,8 @@ class SessionReport(BaseModel): self_assessment_diff: SelfAssessmentDiff | None = None notes: list[InstructorNoteShown] = [] score_auto: float + #: Балл после решений преподавателя по отметкам; None — решений не было. + score_reviewed: float | None = None score_final: float overridden_by: str | None = None override_comment: str | None = None diff --git a/backend/app/domain/kio.py b/backend/app/domain/kio.py index 3193027..a8158a8 100644 --- a/backend/app/domain/kio.py +++ b/backend/app/domain/kio.py @@ -118,6 +118,13 @@ class KIO(BaseModel): # ДДС. `notify` считается из признаков и правится только системой: # в боевом АРМ оператор может добавить службу вручную, но не удалить. notify: list[str] = Field(default_factory=list, description="Список оповещения") + #: Службы, добавленные оператором вручную из каталога. Автоматический список + #: не заменяют и в оценку по эталону не входят. Каталог проверяется на входе + #: (apply_patch), а не здесь: иначе правка названия в каталоге сломала бы + #: загрузку сохранённых снимков занятий и сданных карточек. + notify_extra: list[str] = Field( + default_factory=list, max_length=20, description="Дополнительно оповестить" + ) dds: DDSCode | None = Field( default=None, description="Устаревшее: одна служба. Заменено списком оповещения" ) @@ -147,7 +154,7 @@ EDITABLE_KIO_FIELDS: frozenset[str] = frozenset({ "okato", "address", "street", "building", "entrance", "floor", "intercom_code", "description", "incident_group", "signs", "incident_type", "victims_count", "is_emergency", "life_threat", - "evacuation_needed", "dds", + "evacuation_needed", "dds", "notify_extra", "fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified", "fire.people_inside", "fire.smoke_spread", "police.offence_kind", "police.suspects", "police.suspect_fled", @@ -223,11 +230,40 @@ def derive_incident(card: KIO) -> KIO: return KIO.model_validate(data) +class PatchRejected(ValueError): + """Дельта `kio.patch` не принята целиком: карточка не изменилась.""" + + +def _check_catalog(names: Any) -> None: + """Только службы каталога: свободный текст в адресатах карточки + на АРМ ДДС выглядел бы как настоящая служба.""" + from app.domain import ekp + + if not isinstance(names, list): + return # тип и лимит проверит модель + known = ekp.catalog_names() + unknown = [name for name in names if name not in known] + if unknown: + raise PatchRejected(f"нет в каталоге служб: {', '.join(map(str, unknown[:3]))}") + + +def _without_auto_services(card: KIO) -> KIO: + """Добавки без повторов и без служб, которые уже есть в списке оповещения: + иначе на АРМ ДДС одна служба окажется в двух строках.""" + extra = [name for name in dict.fromkeys(card.notify_extra) if name not in card.notify] + if extra == card.notify_extra: + return card + return card.model_copy(update={"notify_extra": extra}) + + def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO: """Применить дельту `kio.patch`. Служебные поля игнорируются. Вложенные поля приходят плоским путём: {"fire.floors": 5}. + Недопустимое значение — `PatchRejected` или `ValidationError`. """ + if "notify_extra" in fields: + _check_catalog(fields["notify_extra"]) data = card.model_dump() for path, value in fields.items(): if path in READ_ONLY_FIELDS: @@ -243,4 +279,4 @@ def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO: # Пересчитываем не только на смену признаков: пострадавшие поднимают скорую, # газификация — МОСГАЗ, и список оповещения обязан это отразить сразу. touched = MODIFIER_FIELDS & set(fields) - return derive_incident(updated) if touched else updated + return _without_auto_services(derive_incident(updated) if touched else updated) diff --git a/backend/app/domain/services_catalog.json b/backend/app/domain/services_catalog.json new file mode 100644 index 0000000..407bf97 --- /dev/null +++ b/backend/app/domain/services_catalog.json @@ -0,0 +1,1470 @@ +{ + "source": "СЛУЖБЫ 112.docx — раздаточный материал задачи № 9 (сообщение 686), 55 скриншотов окна «Добавьте службы»", + "transcription": "alex-indi/training-simulator-112, коммит ded466988f505082922aacac9fff32b777d1279a, backend/seed/services/services.json", + "license": "MIT; использование разрешено авторами, разрешение получено командой 27.09.2026", + "services": [ + { + "id": "catalog-001", + "name": "Служба 101 (ГУ МЧС России по г.Москве, ГКУ \"Пожарно спасательный центр\" ОДС)", + "organization": "ГУ МЧС России по г.Москве, ГКУ \"Пожарно спасательный центр\" ОДС", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image1.png/row-01" + }, + { + "id": "catalog-002", + "name": "ФСБ", + "organization": null, + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image1.png/row-02" + }, + { + "id": "catalog-003", + "name": "ЦЭМП", + "organization": null, + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image1.png/row-03" + }, + { + "id": "catalog-004", + "name": "Служба 103 (ГБУ города Москвы Станция скорой и неотложной медицинской помощи им.А.С. Пучкова)", + "organization": "ГБУ города Москвы Станция скорой и неотложной медицинской помощи им.А.С. Пучкова", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image1.png/row-04" + }, + { + "id": "catalog-005", + "name": "Служба 104 (АО \"МОСГАЗ\" Диспетчерское управление)", + "organization": "АО \"МОСГАЗ\" Диспетчерское управление", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image1.png/row-05" + }, + { + "id": "catalog-006", + "name": "Поселение Теплый Стан (ДДС района Теплый Стан города Москвы)", + "organization": "ДДС района Теплый Стан города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image10.png/row-01" + }, + { + "id": "catalog-007", + "name": "Поселение Рязанский (ДДС Рязанского района города Москвы)", + "organization": "ДДС Рязанского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image10.png/row-02" + }, + { + "id": "catalog-008", + "name": "Поселение ЗелАО (ДДС префектуры Зеленоградского административного округа города Москвы)", + "organization": "ДДС префектуры Зеленоградского административного округа города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image10.png/row-03" + }, + { + "id": "catalog-009", + "name": "Поселение Печатники (ДДС района Печатники города Москвы)", + "organization": "ДДС района Печатники города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image10.png/row-04" + }, + { + "id": "catalog-010", + "name": "Поселение САО (ДДС префектуры Северного административного округа города Москвы)", + "organization": "ДДС префектуры Северного административного округа города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image11.png/row-01" + }, + { + "id": "catalog-011", + "name": "Поселение Новокосино (ДДС района Новокосино города Москвы)", + "organization": "ДДС района Новокосино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image11.png/row-02" + }, + { + "id": "catalog-012", + "name": "Поселение Царицыно (ДДС района Царицыно города Москвы)", + "organization": "ДДС района Царицыно города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image11.png/row-03" + }, + { + "id": "catalog-013", + "name": "Поселение Марьино (ДДС района Марьино города Москвы)", + "organization": "ДДС района Марьино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image12.png/row-01" + }, + { + "id": "catalog-014", + "name": "Поселение Кузьминки (ДДС района Кузьминки города Москвы)", + "organization": "ДДС района Кузьминки города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image12.png/row-02" + }, + { + "id": "catalog-015", + "name": "Поселение Митино (ДДС района Митино города Москвы)", + "organization": "ДДС района Митино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image12.png/row-03" + }, + { + "id": "catalog-016", + "name": "Поселение Савеловский (ДДС Савеловского района города Москвы)", + "organization": "ДДС Савеловского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image12.png/row-04" + }, + { + "id": "catalog-017", + "name": "Поселение Щукино (ДДС района Щукино города Москвы)", + "organization": "ДДС района Щукино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image12.png/row-05" + }, + { + "id": "catalog-018", + "name": "Поселение Покровское-Стрешнево (ДДС района Покровское-Стрешнево города Москвы)", + "organization": "ДДС района Покровское-Стрешнево города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image13.png/row-02" + }, + { + "id": "catalog-019", + "name": "Поселение Первомайское (ДДС поселения Первомайское в городе Москве)", + "organization": "ДДС поселения Первомайское в городе Москве", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image13.png/row-03" + }, + { + "id": "catalog-020", + "name": "МЖД (Московская железная дорога)", + "organization": "Московская железная дорога", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image13.png/row-04" + }, + { + "id": "catalog-021", + "name": "112 Кал. обл. (112 Калужская область)", + "organization": "112 Калужская область", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image13.png/row-05" + }, + { + "id": "catalog-022", + "name": "Поселение Северное Тушино (ДДС района Северное Тушино города Москвы)", + "organization": "ДДС района Северное Тушино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image14.png/row-02" + }, + { + "id": "catalog-023", + "name": "Поселение Нагатинский Затон (ДДС района Нагатинский Затон города Москвы)", + "organization": "ДДС района Нагатинский Затон города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image14.png/row-03" + }, + { + "id": "catalog-024", + "name": "Поселение Дорогомилово (ДДС района Дорогомилово города Москвы)", + "organization": "ДДС района Дорогомилово города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image14.png/row-04" + }, + { + "id": "catalog-025", + "name": "Поселение Коптево (ДДС района Коптево города Москвы)", + "organization": "ДДС района Коптево города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image15.png/row-01" + }, + { + "id": "catalog-026", + "name": "Поселение Крылатское (ДДС района Крылатское города Москвы)", + "organization": "ДДС района Крылатское города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image15.png/row-02" + }, + { + "id": "catalog-027", + "name": "Поселение Тверской (ДДС Тверского района города Москвы)", + "organization": "ДДС Тверского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image15.png/row-03" + }, + { + "id": "catalog-028", + "name": "Поселение Внуково (ДДС района Внуково города Москвы)", + "organization": "ДДС района Внуково города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image15.png/row-04" + }, + { + "id": "catalog-029", + "name": "Поселение Ярославский (ДДС Ярославского района города Москвы)", + "organization": "ДДС Ярославского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image16.png/row-01" + }, + { + "id": "catalog-030", + "name": "Поселение ЮАО (ДДС префектуры Южного административного округа города Москвы)", + "organization": "ДДС префектуры Южного административного округа города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image16.png/row-02" + }, + { + "id": "catalog-031", + "name": "Поселение Михайлово-Ярцевское (ДДС поселения Михайлово-Ярцевское города Москвы)", + "organization": "ДДС поселения Михайлово-Ярцевское города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image16.png/row-03" + }, + { + "id": "catalog-032", + "name": "Поселение Некрасовка (ДДС района Некрасовка города Москвы)", + "organization": "ДДС района Некрасовка города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image16.png/row-04" + }, + { + "id": "catalog-033", + "name": "Поселение Выхино-Жулебино (ДДС района Выхино-Жулебино города Москвы)", + "organization": "ДДС района Выхино-Жулебино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image17.png/row-01" + }, + { + "id": "catalog-034", + "name": "Поселение Москворечье-Сабурово (ДДС района Москворечье-Сабурово города Москвы)", + "organization": "ДДС района Москворечье-Сабурово города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image17.png/row-02" + }, + { + "id": "catalog-035", + "name": "Поселение ЮЗАО (ДДС префектура Юго-Западного административного округа города Москвы)", + "organization": "ДДС префектура Юго-Западного административного округа города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image17.png/row-03" + }, + { + "id": "catalog-036", + "name": "Поселение Донской (ДДС Донского района города Москвы)", + "organization": "ДДС Донского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image18.png/row-01" + }, + { + "id": "catalog-037", + "name": "Поселение Куркино (ДДС района Куркино города Москвы)", + "organization": "ДДС района Куркино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image18.png/row-02" + }, + { + "id": "catalog-038", + "name": "Поселение Южное Бутово (ДДС района Южное Бутово города Москвы)", + "organization": "ДДС района Южное Бутово города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image18.png/row-03" + }, + { + "id": "catalog-039", + "name": "Поселение Лианозово (ДДС района Лианозово города Москвы)", + "organization": "ДДС района Лианозово города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image18.png/row-04" + }, + { + "id": "catalog-040", + "name": "Деп. Обр. (Московский Департамент Образования)", + "organization": "Московский Департамент Образования", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image19.png/row-01" + }, + { + "id": "catalog-041", + "name": "Поселение Лефортово (ДДС района Лефортово города Москвы)", + "organization": "ДДС района Лефортово города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image19.png/row-02" + }, + { + "id": "catalog-042", + "name": "Поселение Замоскворечье (ДДС района Замоскворечье города Москвы)", + "organization": "ДДС района Замоскворечье города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image19.png/row-03" + }, + { + "id": "catalog-043", + "name": "Поселение СВАО (ДДС префектуры Северо-Восточного административного округа города Москвы)", + "organization": "ДДС префектуры Северо-Восточного административного округа города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image19.png/row-04" + }, + { + "id": "catalog-044", + "name": "ОГДЦ", + "organization": null, + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image2.png/row-01" + }, + { + "id": "catalog-045", + "name": "ЦОДД (ГКУ \"Центр организации дорожного движения\")", + "organization": "ГКУ \"Центр организации дорожного движения\"", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image2.png/row-02" + }, + { + "id": "catalog-046", + "name": "Гормост (Гормост)", + "organization": "Гормост", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image2.png/row-03" + }, + { + "id": "catalog-047", + "name": "Мосгортранс", + "organization": null, + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image2.png/row-04" + }, + { + "id": "catalog-048", + "name": "Автодороги", + "organization": null, + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image2.png/row-05" + }, + { + "id": "catalog-049", + "name": "Поселение Сокольники (ДДС района Сокольники города Москвы)", + "organization": "ДДС района Сокольники города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image20.png/row-01" + }, + { + "id": "catalog-050", + "name": "Поселение Измайлово (ДДС района Измайлово города Москвы)", + "organization": "ДДС района Измайлово города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image20.png/row-02" + }, + { + "id": "catalog-051", + "name": "Поселение Богородское (ДДС района Богородское города Москвы)", + "organization": "ДДС района Богородское города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image20.png/row-03" + }, + { + "id": "catalog-052", + "name": "Поселение Преображенский (ДДС района Преображенское города Москвы)", + "organization": "ДДС района Преображенское города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image21.png/row-01" + }, + { + "id": "catalog-053", + "name": "Поселение Восточное Измайлово (ДДС района Восточное Измайлово города Москвы)", + "organization": "ДДС района Восточное Измайлово города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image21.png/row-02" + }, + { + "id": "catalog-054", + "name": "Поселение Ростокино (ДДС района Ростокино города Москвы)", + "organization": "ДДС района Ростокино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image21.png/row-03" + }, + { + "id": "catalog-055", + "name": "Поселение Соколиная гора (ДДС района Соколиная гора города Москвы)", + "organization": "ДДС района Соколиная гора города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image21.png/row-04" + }, + { + "id": "catalog-056", + "name": "Поселение Хорошево-Мневники (ДДС района Хорошево-Мневники города Москвы)", + "organization": "ДДС района Хорошево-Мневники города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image22.png/row-01" + }, + { + "id": "catalog-057", + "name": "Поселение Гольяново (ДДС района Гольяново города Москвы)", + "organization": "ДДС района Гольяново города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image22.png/row-02" + }, + { + "id": "catalog-058", + "name": "Поселение Северное Измайлово (ДДС района Северное Измайлово города Москвы)", + "organization": "ДДС района Северное Измайлово города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image22.png/row-03" + }, + { + "id": "catalog-059", + "name": "Поселение Марфино (ДДС района Марфино города Москвы)", + "organization": "ДДС района Марфино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image22.png/row-04" + }, + { + "id": "catalog-060", + "name": "Поселение Метрогородок (ДДС района Метрогородок города Москвы)", + "organization": "ДДС района Метрогородок города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image23.png/row-01" + }, + { + "id": "catalog-061", + "name": "Поселение Фили-Давыдково (ДДС района Фили-Давыдково города Москвы)", + "organization": "ДДС района Фили-Давыдково города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image23.png/row-02" + }, + { + "id": "catalog-062", + "name": "Поселение Южное Медведково (ДДС района Южное Медведково города Москвы)", + "organization": "ДДС района Южное Медведково города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image23.png/row-03" + }, + { + "id": "catalog-063", + "name": "Поселение Рязановское (ДДС поселения Рязановское в городе Москве)", + "organization": "ДДС поселения Рязановское в городе Москве", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image24.png/row-01" + }, + { + "id": "catalog-064", + "name": "Деп. природопользования (Департамент природопользования и охраны окружающей среды города Москвы)", + "organization": "Департамент природопользования и охраны окружающей среды города Москвы", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image24.png/row-02" + }, + { + "id": "catalog-065", + "name": "Поселение Тимирязевский (ДДС Тимирязевского района города Москвы)", + "organization": "ДДС Тимирязевского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image24.png/row-03" + }, + { + "id": "catalog-066", + "name": "Поселение Бирюлево Западное (ДДС района Бирюлево Западное города Москвы)", + "organization": "ДДС района Бирюлево Западное города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image24.png/row-04" + }, + { + "id": "catalog-067", + "name": "Поселение Ивановский (ДДС Ивановского района города Москвы)", + "organization": "ДДС Ивановского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image25.png/row-01" + }, + { + "id": "catalog-068", + "name": "Поселение Ново-Переделкино (ДДС района Ново-Переделкино города Москвы)", + "organization": "ДДС района Ново-Переделкино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image25.png/row-02" + }, + { + "id": "catalog-069", + "name": "Поселение Восточное Дегунино (ДДС района Восточное Дегунино города Москвы)", + "organization": "ДДС района Восточное Дегунино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image25.png/row-03" + }, + { + "id": "catalog-070", + "name": "Поселение Ховрино (ДДС района Ховрино города Москвы)", + "organization": "ДДС района Ховрино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image26.png/row-01" + }, + { + "id": "catalog-071", + "name": "Поселение Головинский (ДДС Головинского района города Москвы)", + "organization": "ДДС Головинского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image26.png/row-02" + }, + { + "id": "catalog-072", + "name": "Поселение Бескудниково (ДДС Бескудниковского района города Москвы)", + "organization": "ДДС Бескудниковского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image26.png/row-03" + }, + { + "id": "catalog-073", + "name": "Поселение Левобережный (ДДС района Левобережный города Москвы)", + "organization": "ДДС района Левобережный города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image26.png/row-04" + }, + { + "id": "catalog-074", + "name": "Поселение Дмитровский (ДДС Дмитровского района города Москвы)", + "organization": "ДДС Дмитровского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image27.png/row-01" + }, + { + "id": "catalog-075", + "name": "Поселение Сокол (ДДС района Сокол города Москвы)", + "organization": "ДДС района Сокол города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image27.png/row-02" + }, + { + "id": "catalog-076", + "name": "Поселение Молжаниновский (ДДС Молжаниновского района города Москвы)", + "organization": "ДДС Молжаниновского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image27.png/row-03" + }, + { + "id": "catalog-077", + "name": "Поселение Хамовники (ДДС района Хамовники города Москвы)", + "organization": "ДДС района Хамовники города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image27.png/row-04" + }, + { + "id": "catalog-078", + "name": "Поселение Басманный (ДДС Басманного района города Москвы)", + "organization": "ДДС Басманного района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image28.png/row-01" + }, + { + "id": "catalog-079", + "name": "Поселение Якиманка (ДДС района Якиманка города Москвы)", + "organization": "ДДС района Якиманка города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image28.png/row-02" + }, + { + "id": "catalog-080", + "name": "Поселение Мещанский (ДДС Мещанского района города Москвы)", + "organization": "ДДС Мещанского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image28.png/row-03" + }, + { + "id": "catalog-081", + "name": "Поселение Пресненский (ДДС Пресненского района города Москвы)", + "organization": "ДДС Пресненского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image28.png/row-04" + }, + { + "id": "catalog-082", + "name": "Служба 102 (Дежурная часть ГУ МВД России по г.Москве)", + "organization": "Дежурная часть ГУ МВД России по г.Москве", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image29.png/row-02" + }, + { + "id": "catalog-083", + "name": "Поселение Мосрентген (ДДС поселения «Мосрентген» в городе Москве)", + "organization": "ДДС поселения «Мосрентген» в городе Москве", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image29.png/row-03" + }, + { + "id": "catalog-084", + "name": "Поселение Ясенево (ДДС района Ясенево города Москвы)", + "organization": "ДДС района Ясенево города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image29.png/row-04" + }, + { + "id": "catalog-085", + "name": "Мосводоканал (АО \"Мосводоканал\")", + "organization": "АО \"Мосводоканал\"", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image3.png/row-01" + }, + { + "id": "catalog-086", + "name": "Россети МР", + "organization": null, + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image3.png/row-02" + }, + { + "id": "catalog-087", + "name": "МОЭК", + "organization": null, + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image3.png/row-03" + }, + { + "id": "catalog-088", + "name": "Деп. ЖКХ (Департамент ЖКХ)", + "organization": "Департамент ЖКХ", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image3.png/row-04" + }, + { + "id": "catalog-089", + "name": "Метро", + "organization": null, + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image3.png/row-05" + }, + { + "id": "catalog-090", + "name": "МГТС (Московская городская телефонная сеть)", + "organization": "Московская городская телефонная сеть", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image30.png/row-01" + }, + { + "id": "catalog-091", + "name": "Поселение Щербинка (ДДС городского округа Щербинка города Москвы)", + "organization": "ДДС городского округа Щербинка города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image30.png/row-02" + }, + { + "id": "catalog-092", + "name": "Поселение Капотня (ДДС района Капотня города Москвы)", + "organization": "ДДС района Капотня города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image30.png/row-03" + }, + { + "id": "catalog-093", + "name": "Поселение Троицк (ДДС городского округа Троицк города Москвы)", + "organization": "ДДС городского округа Троицк города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image30.png/row-04" + }, + { + "id": "catalog-094", + "name": "Поселение Нагатино-Садовники (ДДС района Нагатино Садовники)", + "organization": "ДДС района Нагатино Садовники", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image31.png/row-01" + }, + { + "id": "catalog-095", + "name": "Поселение Клёновское (ДДС поселения Клёновское в городе Москве)", + "organization": "ДДС поселения Клёновское в городе Москве", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image31.png/row-02" + }, + { + "id": "catalog-096", + "name": "Поселение Нагорный (ДДС района Нагорный города Москвы)", + "organization": "ДДС района Нагорный города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image31.png/row-03" + }, + { + "id": "catalog-097", + "name": "Поселение Бабушкинский (ДДС Бабушкинского района города Москвы)", + "organization": "ДДС Бабушкинского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image31.png/row-04" + }, + { + "id": "catalog-098", + "name": "Поселение Бутырский (ДДС Бутырского района города Москвы)", + "organization": "ДДС Бутырского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image32.png/row-01" + }, + { + "id": "catalog-099", + "name": "Поселение Лосиноостровский (ДДС Лосиноостровского района города Москвы)", + "organization": "ДДС Лосиноостровского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image32.png/row-02" + }, + { + "id": "catalog-100", + "name": "Поселение Останкинский (ДДС Останкинского района города Москвы)", + "organization": "ДДС Останкинского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image32.png/row-03" + }, + { + "id": "catalog-101", + "name": "Поселение Отрадное (ДДС района Отрадное города Москвы)", + "organization": "ДДС района Отрадное города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image33.png/row-01" + }, + { + "id": "catalog-102", + "name": "Поселение Свиблово (ДДС района Свиблово города Москвы)", + "organization": "ДДС района Свиблово города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image33.png/row-02" + }, + { + "id": "catalog-103", + "name": "Поселение Северное Медведково (ДДС района Северное Медведково города Москвы)", + "organization": "ДДС района Северное Медведково города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image33.png/row-03" + }, + { + "id": "catalog-104", + "name": "Поселение Северный (ДДС района Северный города Москвы)", + "organization": "ДДС района Северный города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image33.png/row-04" + }, + { + "id": "catalog-105", + "name": "Поселение Орехово-Борисово Северное (ДДС Орехово-Борисово Северное города Москвы)", + "organization": "ДДС Орехово-Борисово Северное города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image34.png/row-01" + }, + { + "id": "catalog-106", + "name": "Поселение Перово (ДДС района Перово города Москвы)", + "organization": "ДДС района Перово города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image34.png/row-02" + }, + { + "id": "catalog-107", + "name": "Поселение Алтуфьевский (ДДС Алтуфьевского района города Москвы)", + "organization": "ДДС Алтуфьевского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image34.png/row-03" + }, + { + "id": "catalog-108", + "name": "Поселение Можайский (ДДС Можайского района города Москвы)", + "organization": "ДДС Можайского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image34.png/row-04" + }, + { + "id": "catalog-109", + "name": "Поселение Раменки (ДДС района Раменки города Москвы)", + "organization": "ДДС района Раменки города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image35.png/row-03" + }, + { + "id": "catalog-110", + "name": "Поселение Алексеевский (ДДС Алексеевского района города Москвы)", + "organization": "ДДС Алексеевского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image35.png/row-04" + }, + { + "id": "catalog-111", + "name": "Поселение Кунцево (ДДС района Кунцево города Москвы)", + "organization": "ДДС района Кунцево города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image36.png/row-01" + }, + { + "id": "catalog-112", + "name": "Поселение Тропарево-Никулино (ДДС района Тропарево-Никулино города Москвы)", + "organization": "ДДС района Тропарево-Никулино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image36.png/row-02" + }, + { + "id": "catalog-113", + "name": "Поселение Восточный (ДДС района Восточный города Москвы)", + "organization": "ДДС района Восточный города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image36.png/row-03" + }, + { + "id": "catalog-114", + "name": "Поселение Аэропорт (ДДС района Аэропорт города Москвы)", + "organization": "ДДС района Аэропорт города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image37.png/row-01" + }, + { + "id": "catalog-115", + "name": "Поселение Западное Дегунино (ДДС района Западное Дегунино города Москвы)", + "organization": "ДДС района Западное Дегунино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image37.png/row-02" + }, + { + "id": "catalog-116", + "name": "Поселение Войковский (ДДС Войковского района города Москвы)", + "organization": "ДДС Войковского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image37.png/row-03" + }, + { + "id": "catalog-117", + "name": "Поселение Хорошевский (ДДС Хорошевского района города Москвы)", + "organization": "ДДС Хорошевского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image37.png/row-04" + }, + { + "id": "catalog-118", + "name": "Поселение Беговой (ДДС района Беговой города Москвы)", + "organization": "ДДС района Беговой города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image38.png/row-01" + }, + { + "id": "catalog-119", + "name": "Поселение Текстильщики (ДДС района Текстильщики города Москвы)", + "organization": "ДДС района Текстильщики города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image38.png/row-02" + }, + { + "id": "catalog-120", + "name": "Поселение Московское (ДДС поселения Московский в городе Москве)", + "organization": "ДДС поселения Московский в городе Москве", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image38.png/row-03" + }, + { + "id": "catalog-121", + "name": "Поселение Братеево (ДДС района Братеево города Москвы)", + "organization": "ДДС района Братеево города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image38.png/row-04" + }, + { + "id": "catalog-122", + "name": "Поселение Филимонковское (ДДС поселения Филимонковское в городе Москве)", + "organization": "ДДС поселения Филимонковское в городе Москве", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image39.png/row-01" + }, + { + "id": "catalog-123", + "name": "Канал им. Москвы (ФГБУ \"Канал имени Москвы\")", + "organization": "ФГБУ \"Канал имени Москвы\"", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image39.png/row-02" + }, + { + "id": "catalog-124", + "name": "Поселение Люблино (ДДС района Люблино города Москвы)", + "organization": "ДДС района Люблино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image39.png/row-03" + }, + { + "id": "catalog-125", + "name": "Поселение Марушкинское (ДДС поселения Марушкинское в городе Москве)", + "organization": "ДДС поселения Марушкинское в городе Москве", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image39.png/row-04" + }, + { + "id": "catalog-126", + "name": "112 Мос. обл. (112 Московской области)", + "organization": "112 Московской области", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image4.png/row-01" + }, + { + "id": "catalog-127", + "name": "ФГУП РСВО (Российские сети вещания и оповещения)", + "organization": "Российские сети вещания и оповещения", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image4.png/row-02" + }, + { + "id": "catalog-128", + "name": "ОЭК (Объединенная энергетическая компания)", + "organization": "Объединенная энергетическая компания", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image4.png/row-03" + }, + { + "id": "catalog-129", + "name": "Мослифт (Лифт МСК)", + "organization": "Лифт МСК", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image4.png/row-04" + }, + { + "id": "catalog-130", + "name": "Мосводосток (Мосводосток)", + "organization": "Мосводосток", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image4.png/row-05" + }, + { + "id": "catalog-131", + "name": "Деп. труда и соц.защиты (Департамент труда и социальной защиты населения города Москвы)", + "organization": "Департамент труда и социальной защиты населения города Москвы", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image40.png/row-02" + }, + { + "id": "catalog-132", + "name": "Поселение Орехово-Борисово Южное (ДДС района Орехово-Борисово Южное города Москвы)", + "organization": "ДДС района Орехово-Борисово Южное города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image40.png/row-03" + }, + { + "id": "catalog-133", + "name": "Поселение Даниловский (ДДС Даниловского района города Москвы)", + "organization": "ДДС Даниловского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image40.png/row-04" + }, + { + "id": "catalog-134", + "name": "Поселение Роговское (ДДС поселения Роговское в городе Москве)", + "organization": "ДДС поселения Роговское в городе Москве", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image41.png/row-01" + }, + { + "id": "catalog-135", + "name": "Поселение ЗАО (ДДС префектуры Западного административного округа города Москвы)", + "organization": "ДДС префектуры Западного административного округа города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image41.png/row-02" + }, + { + "id": "catalog-136", + "name": "Поселение Нижегородский (ДДС Нижегородского района города Москвы)", + "organization": "ДДС Нижегородского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image41.png/row-03" + }, + { + "id": "catalog-137", + "name": "Поселение Щаповское (ДДС поселения Щаповское в городе Москве)", + "organization": "ДДС поселения Щаповское в городе Москве", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image41.png/row-04" + }, + { + "id": "catalog-138", + "name": "Поселение Южнопортовый (ДДС Южнопортового района города Москвы)", + "organization": "ДДС Южнопортового района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image42.png/row-01" + }, + { + "id": "catalog-139", + "name": "ЭВАЖД (ГБУ \"Учреждение по эксплуатации высотных административных и жилых домов\")", + "organization": "ГБУ \"Учреждение по эксплуатации высотных административных и жилых домов\"", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image42.png/row-02" + }, + { + "id": "catalog-140", + "name": "Поселение ЦАО (ДДС префектуры Центрального административного округа)", + "organization": "ДДС префектуры Центрального административного округа", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image42.png/row-03" + }, + { + "id": "catalog-141", + "name": "Поселение Черемушки (ДДС района Черемушки города Москвы)", + "organization": "ДДС района Черемушки города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image43.png/row-01" + }, + { + "id": "catalog-142", + "name": "Поселение Марьина роща (ДДС района Марьина роща города Москвы)", + "organization": "ДДС района Марьина роща города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image43.png/row-02" + }, + { + "id": "catalog-143", + "name": "Поселение Красносельский (ДДС Красносельского района города Москвы)", + "organization": "ДДС Красносельского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image43.png/row-03" + }, + { + "id": "catalog-144", + "name": "Поселение Строгино (ДДС района Строгино города Москвы)", + "organization": "ДДС района Строгино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image43.png/row-04" + }, + { + "id": "catalog-145", + "name": "Поселение СЗАО (ДДС префектуры Северо-Западного административного округа города Москвы)", + "organization": "ДДС префектуры Северо-Западного административного округа города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image44.png/row-01" + }, + { + "id": "catalog-146", + "name": "Поселение Бирюлево Восточное (ДДС района Бирюлево Восточное города Москвы)", + "organization": "ДДС района Бирюлево Восточное города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image44.png/row-02" + }, + { + "id": "catalog-147", + "name": "Поселение Чертаново Центральное (ДДС района Чертаново Центральное города Москвы)", + "organization": "ДДС района Чертаново Центральное города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image44.png/row-03" + }, + { + "id": "catalog-148", + "name": "Поселение Гагаринский (ДДС Гагаринского района города Москвы)", + "organization": "ДДС Гагаринского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image44.png/row-04" + }, + { + "id": "catalog-149", + "name": "Поселение Обручевский (ДДС Обручевского района города Москвы)", + "organization": "ДДС Обручевского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image45.png/row-01" + }, + { + "id": "catalog-150", + "name": "Поселение Зюзино (ДДС района Зюзино города Москвы)", + "organization": "ДДС района Зюзино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image45.png/row-02" + }, + { + "id": "catalog-151", + "name": "Поселение Коньково (ДДС района Коньково города Москвы)", + "organization": "ДДС района Коньково города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image45.png/row-03" + }, + { + "id": "catalog-152", + "name": "Поселение Котловка (ДДС района Котловка города Москвы)", + "organization": "ДДС района Котловка города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image45.png/row-04" + }, + { + "id": "catalog-153", + "name": "Поселение Северное Бутово (ДДС района Северное Бутово города Москвы)", + "organization": "ДДС района Северное Бутово города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image46.png/row-01" + }, + { + "id": "catalog-154", + "name": "Поселение Косино-Ухтомский (ДДС района Косино-Ухтомский города Москвы)", + "organization": "ДДС района Косино-Ухтомский города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image46.png/row-02" + }, + { + "id": "catalog-155", + "name": "Поселение Солнцево (ДДС района Солнцево города Москвы)", + "organization": "ДДС района Солнцево города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image46.png/row-03" + }, + { + "id": "catalog-156", + "name": "Поселение Таганский (ДДС района Таганский города Москвы)", + "organization": "ДДС района Таганский города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image47.png/row-01" + }, + { + "id": "catalog-157", + "name": "Поселение Очаково-Матвеевское (ДДС района Очаково-Матвеевское города Москвы)", + "organization": "ДДС района Очаково-Матвеевское города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image47.png/row-02" + }, + { + "id": "catalog-158", + "name": "Поселение Южное Тушино (ДДС района Южное Тушино города Москвы)", + "organization": "ДДС района Южное Тушино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image47.png/row-03" + }, + { + "id": "catalog-159", + "name": "Поселение Филевский парк (ДДС района Филевский парк города Москвы)", + "organization": "ДДС района Филевский парк города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image47.png/row-04" + }, + { + "id": "catalog-160", + "name": "Поселение ТИНАО (ДДС префектуры Троицкого и Новомосковского округов города Москвы)", + "organization": "ДДС префектуры Троицкого и Новомосковского округов города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image48.png/row-01" + }, + { + "id": "catalog-161", + "name": "Поселение Проспект Вернадского (ДДС района Проспект Вернадского города Москвы)", + "organization": "ДДС района Проспект Вернадского города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image48.png/row-02" + }, + { + "id": "catalog-162", + "name": "Поселение Десеновское (ДДС поселения Десеновское в городе Москве)", + "organization": "ДДС поселения Десеновское в городе Москве", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image48.png/row-03" + }, + { + "id": "catalog-163", + "name": "Поселение Арбат (ДДС района Арбат города Москвы)", + "organization": "ДДС района Арбат города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image48.png/row-04" + }, + { + "id": "catalog-164", + "name": "Дежурно-диспетчерская служба (Дежурно-диспетчерская служба)", + "organization": "Дежурно-диспетчерская служба", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image49.png/row-02" + }, + { + "id": "catalog-165", + "name": "Поселение Сосенское (ДДС поселения Сосенское в городе Москве)", + "organization": "ДДС поселения Сосенское в городе Москве", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image49.png/row-03" + }, + { + "id": "catalog-166", + "name": "Поселение Кокошкино (ДДС поселения Кокошкино в городе Москве)", + "organization": "ДДС поселения Кокошкино в городе Москве", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image49.png/row-04" + }, + { + "id": "catalog-167", + "name": "Центррегионводхоз (Центррегионводхоз)", + "organization": "Центррегионводхоз", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image49.png/row-05" + }, + { + "id": "catalog-168", + "name": "Москоллектор (Москоллектор)", + "organization": "Москоллектор", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image5.png/row-01" + }, + { + "id": "catalog-169", + "name": "Воен. комендатура (Воен. комендатура)", + "organization": "Воен. комендатура", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image5.png/row-02" + }, + { + "id": "catalog-170", + "name": "Поселение Чертаново Северное (ДДС района Чертаново Северное города Москвы)", + "organization": "ДДС района Чертаново Северное города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image5.png/row-03" + }, + { + "id": "catalog-171", + "name": "Поселение Академический (ДДС Академического района города Москвы)", + "organization": "ДДС Академического района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image5.png/row-04" + }, + { + "id": "catalog-172", + "name": "Поселение Воскресенское (ДДС поселения Воскресенское в городе Москве)", + "organization": "ДДС поселения Воскресенское в городе Москве", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image50.png/row-02" + }, + { + "id": "catalog-173", + "name": "Поселение Краснопахорское (ДДС поселения Краснопахорское в городе Москве)", + "organization": "ДДС поселения Краснопахорское в городе Москве", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image50.png/row-03" + }, + { + "id": "catalog-174", + "name": "Поселение Вороновское (ДДС поселения Вороновское в городе Москве)", + "organization": "ДДС поселения Вороновское в городе Москве", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image50.png/row-04" + }, + { + "id": "catalog-175", + "name": "Мособлгаз (Мособлгаз)", + "organization": "Мособлгаз", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image50.png/row-05" + }, + { + "id": "catalog-176", + "name": "Поселение Внуковское (ДДС поселения Внуковское города Москвы)", + "organization": "ДДС поселения Внуковское города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image51.png/row-02" + }, + { + "id": "catalog-177", + "name": "Поселение Новофедоровское (ДДС поселения Новофедоровское города Москвы)", + "organization": "ДДС поселения Новофедоровское города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image51.png/row-03" + }, + { + "id": "catalog-178", + "name": "Поселение Киевский (ДДС поселения Киевский города Москвы)", + "organization": "ДДС поселения Киевский города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image51.png/row-04" + }, + { + "id": "catalog-179", + "name": "ГБУ АД ВАО (ГБУ Автодороги ВАО)", + "organization": "ГБУ Автодороги ВАО", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image51.png/row-05" + }, + { + "id": "catalog-180", + "name": "ГБУ АД СВАО (ГБУ Автодороги СВАО)", + "organization": "ГБУ Автодороги СВАО", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image52.png/row-02" + }, + { + "id": "catalog-181", + "name": "ГБУ АД ЮВАО (ГБУ Автодороги ЮВАО)", + "organization": "ГБУ Автодороги ЮВАО", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image52.png/row-03" + }, + { + "id": "catalog-182", + "name": "ГБУ АД ЗелАО (ГБУ Автодороги ЗелАО)", + "organization": "ГБУ Автодороги ЗелАО", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image52.png/row-04" + }, + { + "id": "catalog-183", + "name": "ГБУ АД ЮЗАО (ГБУ Автодороги ЮЗАО)", + "organization": "ГБУ Автодороги ЮЗАО", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image52.png/row-05" + }, + { + "id": "catalog-184", + "name": "ГБУ АД ЮАО (ГБУ Автодороги ЮАО)", + "organization": "ГБУ Автодороги ЮАО", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image52.png/row-06" + }, + { + "id": "catalog-185", + "name": "ГБУ АД САО (ГБУ Автодороги САО)", + "organization": "ГБУ Автодороги САО", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image53.png/row-02" + }, + { + "id": "catalog-186", + "name": "ГБУ АД СЗАО (ГБУ Автодороги СЗАО)", + "organization": "ГБУ Автодороги СЗАО", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image53.png/row-03" + }, + { + "id": "catalog-187", + "name": "ГБУ АД ЦАО (ГБУ Автодороги ЦАО)", + "organization": "ГБУ Автодороги ЦАО", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image53.png/row-04" + }, + { + "id": "catalog-188", + "name": "ГБУ АД ЗАО (ГБУ Автодороги ЗАО)", + "organization": "ГБУ Автодороги ЗАО", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image53.png/row-05" + }, + { + "id": "catalog-189", + "name": "Департамент культуры города Москвы (Департамент культуры города Москвы)", + "organization": "Департамент культуры города Москвы", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image54.png/row-01" + }, + { + "id": "catalog-190", + "name": "ГКУ ЦСА (Центр социальной помощи)", + "organization": "Центр социальной помощи", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image54.png/row-02" + }, + { + "id": "catalog-191", + "name": "Мостуризм (Комитет по туризму города Москвы)", + "organization": "Комитет по туризму города Москвы", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image54.png/row-03" + }, + { + "id": "catalog-192", + "name": "Департамент строительства (Департамент строительства)", + "organization": "Департамент строительства", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image54.png/row-04" + }, + { + "id": "catalog-193", + "name": "Комитет ветеринарии (Комитет ветеринарии города Москвы)", + "organization": "Комитет ветеринарии города Москвы", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image55.png/row-04" + }, + { + "id": "catalog-194", + "name": "Мосжилинспекция (Государственная жилищная инспекция города Москвы)", + "organization": "Государственная жилищная инспекция города Москвы", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image55.png/row-05" + }, + { + "id": "catalog-195", + "name": "Поселение Ломоносовский (ДДС Ломоносовского района города Москвы)", + "organization": "ДДС Ломоносовского района города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image6.png/row-01" + }, + { + "id": "catalog-196", + "name": "Поселение Бибирево (ДДС района Бибирево города Москвы)", + "organization": "ДДС района Бибирево города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image6.png/row-02" + }, + { + "id": "catalog-197", + "name": "Поселение ЮВАО (ДДС префектуры Юго-Восточного административного округа города Москвы)", + "organization": "ДДС префектуры Юго-Восточного административного округа города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image6.png/row-03" + }, + { + "id": "catalog-198", + "name": "Поселение ВАО (ДДС префектуры Восточного административного округа города Москвы)", + "organization": "ДДС префектуры Восточного административного округа города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image6.png/row-04" + }, + { + "id": "catalog-199", + "name": "Поселение Зябликово (ДДС района Зябликово города Москвы)", + "organization": "ДДС района Зябликово города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image7.png/row-01" + }, + { + "id": "catalog-200", + "name": "Поселение Силино (ДДС района Силино города Москвы)", + "organization": "ДДС района Силино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image7.png/row-02" + }, + { + "id": "catalog-201", + "name": "ОАТИ (Объединение Административно-Технических Инспекций города Москвы)", + "organization": "Объединение Административно-Технических Инспекций города Москвы", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image7.png/row-03" + }, + { + "id": "catalog-202", + "name": "ГБУ МСППН (Московская служба психологической помощи населению ГБУ города Москвы)", + "organization": "Московская служба психологической помощи населению ГБУ города Москвы", + "service_level": null, + "source_reference": "СЛУЖБЫ 112.docx#word/media/image7.png/row-04" + }, + { + "id": "catalog-203", + "name": "Упр. района Вешняки (ДДС района Вешняки города Москвы)", + "organization": "ДДС района Вешняки города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image8.png/row-01" + }, + { + "id": "catalog-204", + "name": "Поселение Крюково (ДДС района Крюково города Москвы)", + "organization": "ДДС района Крюково города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image8.png/row-02" + }, + { + "id": "catalog-205", + "name": "Поселение Матушкино (ДДС района Матушкино города Москвы)", + "organization": "ДДС района Матушкино города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image8.png/row-03" + }, + { + "id": "catalog-206", + "name": "Поселение Новогиреево (ДДС района Новогиреево города Москвы)", + "organization": "ДДС района Новогиреево города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image9.png/row-01" + }, + { + "id": "catalog-207", + "name": "Поселение Савелки (ДДС района Савелки города Москвы)", + "organization": "ДДС района Савелки города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image9.png/row-02" + }, + { + "id": "catalog-208", + "name": "Поселение Старое Крюково (ДДС района Старое Крюково города Москвы)", + "organization": "ДДС района Старое Крюково города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image9.png/row-03" + }, + { + "id": "catalog-209", + "name": "Поселение Чертаново Южное (ДДС района Чертаново Южное города Москвы)", + "organization": "ДДС района Чертаново Южное города Москвы", + "service_level": "ДДС", + "source_reference": "СЛУЖБЫ 112.docx#word/media/image9.png/row-04" + } + ] +} diff --git a/backend/app/domain/taxonomy.py b/backend/app/domain/taxonomy.py index 3de1537..476212f 100644 --- a/backend/app/domain/taxonomy.py +++ b/backend/app/domain/taxonomy.py @@ -7,8 +7,10 @@ from datetime import datetime from enum import StrEnum +from typing import Any +from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, model_validator class ErrorCode(StrEnum): @@ -152,6 +154,22 @@ COMPETENCY_LABELS: dict[Competency, str] = { } +class FindingDecision(StrEnum): + """Решение преподавателя по отметке на разборе.""" + + CONFIRMED = "confirmed" + DISMISSED = "dismissed" + + +class FindingReview(BaseModel): + """Кто, когда и почему подтвердил или снял отметку. Сама отметка остаётся.""" + + decision: FindingDecision + reason: str + author: str + at: datetime + + class Finding(BaseModel): """Отметка в разборе. `fact` и `norm` — то самое обоснование.""" @@ -164,3 +182,32 @@ class Finding(BaseModel): competency: Competency | None = None transcript_ref: str | None = None at: datetime | None = None + #: Метрика, чей провал объясняет отметка. Без неё связь с метриками идёт + #: по методике METRIC_MAP в пределах той же карточки и службы. + metric_key: str | None = None + #: Номер карточки очереди ДДС (с 1) и служба, к которым относится отметка. + card: int | None = None + service: str | None = None + #: Автор ручной отметки преподавателя (`source=instructor`). + author: str | None = None + #: Ключ идемпотентности ручной отметки: повтор запроса не добавляет её снова. + client_id: UUID | None = None + #: Все решения по отметке по порядку; действует последнее. Прежние не + #: затираются: «кто, когда, почему» нужно по каждой правке, а не по итогу. + reviews: list[FindingReview] = [] + + @model_validator(mode="before") + @classmethod + def _single_review(cls, data: Any) -> Any: + # Разборы, сохранённые до истории решений, хранили одно поле `review`. + if isinstance(data, dict) and "review" in data: + data = dict(data) + single = data.pop("review") + if single and not data.get("reviews"): + data["reviews"] = [single] + return data + + @property + def review(self) -> FindingReview | None: + """Действующее решение — последнее.""" + return self.reviews[-1] if self.reviews else None diff --git a/backend/app/scoring/card.py b/backend/app/scoring/card.py index 7bc761a..37ce36b 100644 --- a/backend/app/scoring/card.py +++ b/backend/app/scoring/card.py @@ -30,6 +30,7 @@ def evaluate_card(scenario: Scenario, kio: KIO) -> GostResult: source=FindingSource.KIO if code.value == "E5" else FindingSource.GROUND_TRUTH, summary=f"{title}: {fact}; требуется {norm}", fact=fact, norm=norm, ref=ref, competency=competency, + metric_key=key, )) if truth.incident_code: diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py index 8ad28f1..0342234 100644 --- a/backend/app/scoring/dispatcher.py +++ b/backend/app/scoring/dispatcher.py @@ -11,11 +11,14 @@ привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу. """ +import math + from app.domain.statuses import ( COMMENT_REQUIRED, PRIMARY, REFUSAL_COMMENT_REQUIRED, SERVICE_STATUS_LABELS, + PhoneReportRecord, ServiceStatus, StatusEntry, current, @@ -23,19 +26,113 @@ from app.domain.statuses import ( from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource from app.domain.events import Metric from app.domain.timers import GOST_REF, TimerCode +from app.scoring.timing import TimeNorm, time_metric + +MEMO_REF = "памятка «Работа на АРМ-112», раздел «Статусы реагирования»" #: Статусы, означающие, что реагирование действительно шло. PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING) +#: Статус, который диспетчер отмечает по докладу бригады этой фазы. +REPORT_STATUS = { + "dispatched": ServiceStatus.RESPONDING, + "arrived": ServiceStatus.ARRIVED, + "working": ServiceStatus.WORKING, + "completed": ServiceStatus.COMPLETED, +} +REPORT_LABELS = { + "dispatched": "о выезде", + "arrived": "о прибытии", + "working": "о начале работ", + "completed": "о завершении работ", +} +#: Лимит реакции на доклад по умолчанию; занятие задаёт свой в критериях. +REPORT_REACTION_MS = 45_000 +#: Реакция на доклад — норматив времени, как отработка карточки, но отметка +#: D6 из памятки: опоздавший статус — тот же неполный ход работ. +DDS_REPORT_REACTION = TimeNorm( + key="dds_report_reaction", + title="реакция на доклад бригады", + summary="статус по докладу бригады отмечен с нарушением норматива", + action="отметить статус по докладу", + unmeasured="не отмечен", + incomplete="не отмечен", + code=ErrorCode.D6, + source=FindingSource.DISPATCHER, + ref=MEMO_REF, +) +#: Терминальный отказ: работ не было, поэтому неотмеченный по докладу статус +#: не штрафуется ни отметкой, ни метрикой. +TERMINAL_REFUSAL = {ServiceStatus.DECLINED, ServiceStatus.REFUSED} -def _finding(code: ErrorCode, summary: str, fact: str, norm: str | None = None) -> Finding: + +def report_reactions( + marks: list[StatusEntry], reports: list[PhoneReportRecord], +) -> list[tuple[PhoneReportRecord, int | None]]: + """Задержка статуса после каждого доклада бригады, мс; None — статус не отмечен. + + Отсчёт — от серверного времени доклада. Статус, отмеченный раньше доклада, + задержки не имеет: диспетчер узнал о ходе работ по другому каналу. + """ + result: list[tuple[PhoneReportRecord, int | None]] = [] + for report in reports: + status = REPORT_STATUS.get(report.phase) + if status is None: + continue + times = [mark.at for mark in marks if mark.status is status] + if any(at <= report.at for at in times): + result.append((report, 0)) + continue + after = min(times, default=None) + result.append(( + report, + int((after - report.at).total_seconds() * 1000) if after is not None else None, + )) + return result + + +def report_reaction( + service: str, marks: list[StatusEntry], reports: list[PhoneReportRecord], limit_ms: int, +) -> tuple[Metric, Finding | None] | None: + """Метрика и отметка D6 по самой поздней реакции службы на доклады бригады. + + Одна функция для отметок и для балла: иначе метрика провалится там, + где отметки нет. None — реагировать было не на что. + """ + refused = current(marks, service) in TERMINAL_REFUSAL + reactions = [(report, delay) for report, delay in report_reactions(marks, reports) + if delay is not None or not refused] + if not reactions: + return None + report, delay = max(reactions, key=lambda item: math.inf if item[1] is None else item[1]) + metric, finding = time_metric(DDS_REPORT_REACTION, delay, limit_ms, completed=True) + label = SERVICE_STATUS_LABELS[REPORT_STATUS[report.phase]] + detail = "отмечен не позже доклада" if delay == 0 else metric.fact + fact = (f"статус «{label}» по докладу бригады «{report.crew}» " + f"{REPORT_LABELS[report.phase]}: {detail}") + if len(reactions) > 1: + late = sum(item is None or item > limit_ms for _, item in reactions) + fact += f"; с нарушением {late} из {len(reactions)} докладов" + metric = metric.model_copy(update={ + "title": f"{service}: {metric.title}", "fact": fact, "service": service, + }) + if finding is not None: + finding = finding.model_copy(update={ + "summary": f"{service}: {finding.summary}", "fact": fact, "service": service, + }) + return metric, finding + + +def _finding(service: str, code: ErrorCode, summary: str, fact: str, + norm: str | None = None) -> Finding: return Finding( + service=service, code=code, source=FindingSource.DISPATCHER, summary=summary, fact=fact, norm=norm, - ref="памятка «Работа на АРМ-112», раздел «Статусы реагирования»", + ref=MEMO_REF, competency=( Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD ), @@ -91,6 +188,8 @@ def evaluate_dispatcher( reply_text: str = "", expected_decision: str = "accept", expected_decision_reason: str | None = None, + phone_reports: list[PhoneReportRecord] | None = None, + report_reaction_ms: int = REPORT_REACTION_MS, ) -> list[Finding]: """Отметки по работе диспетчера. Пустой список — работа без нарушений. @@ -117,7 +216,7 @@ def evaluate_dispatcher( ] if missing_comment_statuses: findings.append(_finding( - ErrorCode.D5, + service, ErrorCode.D5, f"{service}: к статусу не добавлены основание и сведения", fact="не заполнены комментарии: " + ", ".join(missing_comment_statuses), norm="к каждой ручной отметке добавить основание и содержание полученных сведений", @@ -132,7 +231,7 @@ def evaluate_dispatcher( ] if incomplete_comment_statuses: findings.append(_finding( - ErrorCode.D5, + service, ErrorCode.D5, f"{service}: комментарии к статусам не разделяют основание и сведения", fact="неполные комментарии: " + ", ".join(incomplete_comment_statuses), norm="в каждом комментарии заполнить отдельные поля «Основание» и «Сведения»", @@ -152,7 +251,7 @@ def evaluate_dispatcher( ] if missing_recipients: findings.append(_finding( - ErrorCode.D5, + service, ErrorCode.D5, f"{service}: отдельный комментарий не указывает получателя сведений", fact="; ".join(missing_recipients)[:500], norm=( @@ -166,7 +265,7 @@ def evaluate_dispatcher( if primary is None: findings.append( _finding( - ErrorCode.D1, + service, ErrorCode.D1, f"{service}: статус реагирования не проставлен", fact=( f"прошло {elapsed_ms // 1000} с" @@ -181,7 +280,7 @@ def evaluate_dispatcher( if elapsed_ms is None or elapsed_ms > deadline_ms: findings.append( _finding( - ErrorCode.D1, + service, ErrorCode.D1, f"{service}: первичный статус проставлен с нарушением срока", fact=( f"прошло {elapsed_ms // 1000} с" @@ -197,7 +296,7 @@ def evaluate_dispatcher( if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip(): findings.append( _finding( - ErrorCode.D4, + service, ErrorCode.D4, f"{service}: «{SERVICE_STATUS_LABELS[mark.status]}» без комментария", fact="причина отказа не указана", norm="комментарий к отказу обязателен", @@ -209,7 +308,7 @@ def evaluate_dispatcher( if latest is ServiceStatus.DECLINED and expected_decision == "accept": findings.append( _finding( - ErrorCode.D3, + service, ErrorCode.D3, f"{service}: отказ от происшествия, которое в её компетенции", fact="служба есть в списке оповещения по ЕКП", norm="принять согласно эталону сценария", @@ -218,7 +317,7 @@ def evaluate_dispatcher( elif latest is ServiceStatus.ACCEPTED and expected_decision == "decline": findings.append( _finding( - ErrorCode.D2, + service, ErrorCode.D2, f"{service}: принято вопреки эталону сценария", fact="карточка принята службой", norm=expected_decision_reason or "отказать с указанной причиной", @@ -232,7 +331,7 @@ def evaluate_dispatcher( if service not in crew_assignments: findings.append( _finding( - ErrorCode.D2, + service, ErrorCode.D2, f"{service}: «Принята» без назначения бригады и хода реагирования", fact="бригада не выбрана, после приёма статусов не было", norm="необходимую бригаду выбирает ДДС; ход отмечается по факту", @@ -241,7 +340,7 @@ def evaluate_dispatcher( else: findings.append( _finding( - ErrorCode.D2, + service, ErrorCode.D2, f"{service}: «Принята», но о реагировании ничего не отмечено", fact="после приёма статусов не было", norm="статус должен соответствовать фактическому состоянию заявки", @@ -252,7 +351,7 @@ def evaluate_dispatcher( ) and service not in crew_assignments: findings.append( _finding( - ErrorCode.D2, + service, ErrorCode.D2, f"{service}: ход реагирования без назначения бригады", fact="статусы хода работ проставлены, бригада не выбрана", norm="необходимую бригаду выбирает ДДС; ход отмечается по факту", @@ -262,15 +361,18 @@ def evaluate_dispatcher( # D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке # это приводит к повторным звонкам и скрывает от других служб факт реакции. # REFUSED — отдельный допустимый терминальный статус с обязательной причиной. + reports = [report for report in phone_reports or () if report.service == service] missing_progress = [status for status in PROGRESS if status not in {m.status for m in marks}] if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED} and (missing_progress or latest is not ServiceStatus.COMPLETED)): missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress) + reported_done = any(report.phase == "completed" for report in reports) findings.append( _finding( - ErrorCode.D6, + service, ErrorCode.D6, f"{service}: ход реагирования не доведён до конца", - fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"), + fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта") + + ("; бригада доложила о завершении работ" if reported_done else ""), norm=( "отметить по факту начало реагирования, прибытие, проведение работ " "и завершение; если работы не проводились — оформить отказ с причиной" @@ -278,6 +380,11 @@ def evaluate_dispatcher( ) ) + # D6 — статус по докладу бригады отмечен позже лимита или не отмечен. + reaction = report_reaction(service, marks, reports, report_reaction_ms) + if reaction is not None and reaction[1] is not None: + findings.append(reaction[1]) + return findings @@ -288,6 +395,7 @@ def dispatcher_metrics( expected_decision_reason: str | None = None, *, services: list[str] | None = None, + report_reaction_ms: int = REPORT_REACTION_MS, ) -> list[Metric]: """Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму. @@ -304,7 +412,7 @@ def dispatcher_metrics( def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0): metrics.append(Metric( - key=key, title=f"{service}: {title}", fact=fact, norm=norm, + key=key, title=f"{service}: {title}", fact=fact, norm=norm, service=service, ref=GOST_REF if key == "dds_ack" else "памятка «Работа на АРМ-112», статусы реагирования", passed=passed, weight=weight, )) @@ -318,6 +426,12 @@ def dispatcher_metrics( f"≤ {deadline_ms // 1000} с") if primary is None: continue + # Метрика есть только там, где бригада докладывала: без докладов + # реагировать не на что, и отсутствие звонков не штраф. + reports = [report for report in card.phone_reports if report.service == service] + reaction = report_reaction(service, marks, reports, report_reaction_ms) + if reaction is not None: + metrics.append(reaction[0]) expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept" else ServiceStatus.DECLINED) expected_label = "Принята" if expected_decision == "accept" else "Не принята" diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py index 6452c0a..cc03471 100644 --- a/backend/app/scoring/export.py +++ b/backend/app/scoring/export.py @@ -11,6 +11,7 @@ from html import escape from pathlib import Path from app.domain.events import SessionReport +from app.domain.taxonomy import Finding, FindingDecision, FindingSource def _cell(value: object) -> str: @@ -18,12 +19,27 @@ def _cell(value: object) -> str: if value is None: return "" result = str(value) - probe = result.lstrip(" \t\r\n\ufeff\u200b") + probe = result.lstrip(" \t\r\n\N{ZERO WIDTH NO-BREAK SPACE}\N{ZERO WIDTH SPACE}") if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")): return "'" + result return result +def _finding_origin(item: Finding) -> str: + if item.source is FindingSource.INSTRUCTOR: + return f"; добавлена преподавателем {item.author or ''}".rstrip() + return "" + + +def _finding_decision(item: Finding) -> str: + """Решения по отметке рядом с ней, все по порядку: действует последнее.""" + return "".join( + f"; {'снята' if review.decision is FindingDecision.DISMISSED else 'подтверждена'} " + f"преподавателем {review.author}: {review.reason}" + for review in item.reviews + ) + + def to_csv(report: SessionReport) -> bytes: """UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки.""" output = io.StringIO(newline="") @@ -38,12 +54,15 @@ def to_csv(report: SessionReport) -> bytes: row("Занятие", "", "Режим", report.mode.value) row("Занятие", "", "Попытка", report.attempt) row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds) + row("Критерии", "", "Лимит реакции на доклад бригады, с", + report.criteria.dds_report_reaction_limit_seconds) row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors) row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет") row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные") row("Критерии", "", "Нарушенных метрик", report.failed_metrics) row("Критерии", "", "Зачёт", "да" if report.passed else "нет") row("Оценка", "", "Автоматическая", report.score_auto) + row("Оценка", "", "После разбора отметок", report.score_reviewed) row("Оценка", "", "Итоговая", report.score_final) row("Оценка", "", "Изменена преподавателем", report.overridden_by) row("Оценка", "", "Причина изменения", report.override_comment) @@ -54,7 +73,9 @@ def to_csv(report: SessionReport) -> bytes: f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; " f"вес: {item.weight:g}{credit}; источник: {item.ref or ''}") for number, item in enumerate(report.findings, 1): - row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}") + row("Ошибки", number, item.code.value, item.summary, + f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}" + f"{_finding_origin(item)}{_finding_decision(item)}") for number, item in enumerate(report.competencies, 1): row("Компетенции", number, item.competency, item.value) for number, item in enumerate(report.transcript, 1): @@ -77,7 +98,7 @@ def to_csv(report: SessionReport) -> bytes: row("Сверка самооценки", "", label, ", ".join(values)) for number, item in enumerate(report.notes, 1): row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author) - return ("\ufeff" + output.getvalue()).encode("utf-8") + return ("\N{ZERO WIDTH NO-BREAK SPACE}" + output.getvalue()).encode("utf-8") def _font_paths() -> tuple[Path, Path | None]: @@ -151,6 +172,7 @@ def to_pdf(report: SessionReport) -> bytes: ("Режим", report.mode.value), ("Попытка", report.attempt), ("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"), + ("Лимит реакции на доклад бригады", f"{report.criteria.dds_report_reaction_limit_seconds} с"), ("Допустимо ошибок", report.criteria.allowed_errors), ("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"), ("Переопределения весов", report.criteria.score_weights or "стандартные"), @@ -160,6 +182,9 @@ def to_pdf(report: SessionReport) -> bytes: ("Итоговая оценка", report.score_final), ): story.append(Paragraph(f"{escape(label)}: {escape(str(value))}", base)) + # Когда итог правили вручную, без этой строки не понять, какой балл дал разбор. + if report.score_reviewed is not None: + story.append(Paragraph(f"После разбора отметок: {report.score_reviewed}", base)) if report.overridden_by: story.append(Paragraph(f"Оценка изменена: {escape(report.overridden_by)}", base)) if report.override_comment: @@ -183,7 +208,9 @@ def to_pdf(report: SessionReport) -> bytes: story.append(p("Ошибок не выявлено", muted)) for item in report.findings: story.append(p(f"{item.code.value} - {item.summary}")) - story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. Источник: {item.ref or 'не указан'}", muted)) + story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. " + f"Источник: {item.ref or 'не указан'}" + f"{_finding_origin(item)}{_finding_decision(item)}", muted)) section("Компетенции") for item in report.competencies: diff --git a/backend/app/scoring/gost.py b/backend/app/scoring/gost.py index b1bcce6..3ed2ac9 100644 --- a/backend/app/scoring/gost.py +++ b/backend/app/scoring/gost.py @@ -73,6 +73,7 @@ class _Builder: norm=norm, ref=ref, competency=competency, + metric_key=key, ) ) @@ -174,6 +175,7 @@ def evaluate( norm=f"эталонный вопрос: «{question}»" if question else "обязательный факт сценария", ref="чек-лист сценария", competency=Competency.INTERVIEW, + metric_key="checklist_completeness", ) ) diff --git a/backend/app/scoring/report.py b/backend/app/scoring/report.py index 764dfd9..c48fac4 100644 --- a/backend/app/scoring/report.py +++ b/backend/app/scoring/report.py @@ -106,6 +106,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport: self_assessment_diff=difference, notes=[InstructorNoteShown.model_validate(note) for note in state.notes], score_auto=score.get("score_auto", 0.0), + score_reviewed=score.get("score_reviewed"), score_final=score.get("score_final", score.get("score_auto", 0.0)), overridden_by=score.get("overridden_by"), override_comment=score.get("override_comment"), diff --git a/backend/app/scoring/review.py b/backend/app/scoring/review.py new file mode 100644 index 0000000..67425a2 --- /dev/null +++ b/backend/app/scoring/review.py @@ -0,0 +1,193 @@ +"""Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку. + +Баллы по-прежнему считает код. Преподаватель меняет входы — какие отметки +действуют, — а не формулу: балл пересчитывается теми же метриками и весами. +Связь «отметка → метрика» — методика `METRIC_MAP` в пределах карточки и службы; +если генератор знает метрику точно, она записана в `Finding.metric_key`. + +Функции чистые и работают со словарём сохранённой оценки: живое занятие, +строка `Score` и архивный разбор меняются одним и тем же путём, как правка +итога в `apply_score_override`. +""" + +from app.domain.events import Metric +from app.domain.taxonomy import Finding, FindingDecision, FindingReview, FindingSource +from app.scoring.competency import radar +from app.scoring.gost import GostResult +from app.scoring.taxonomy import INSTRUCTOR_METRIC, METRIC_MAP + +__all__ = ["INSTRUCTOR_METRIC", "add_finding", "has_finding", "linked", "review_finding", + "reviewed_metrics"] + + +def _in_scope(finding: Finding, metric: Metric) -> bool: + if finding.card != metric.card: + return False + return finding.service is None or metric.service is None or finding.service == metric.service + + +def linked(finding: Finding, metric: Metric) -> bool: + """Отметка объясняет провал этой метрики.""" + if not _in_scope(finding, metric): + return False + if finding.metric_key is not None: + return finding.metric_key == metric.key + mapping = METRIC_MAP.get(metric.key) + return mapping is not None and mapping[0] is finding.code + + +def _dismissed(finding: Finding) -> bool: + return finding.review is not None and finding.review.decision is FindingDecision.DISMISSED + + +def _credit(metric: Metric) -> float: + return metric.credit if metric.credit is not None else float(metric.passed) + + +def reviewed_metrics(metrics: list[Metric], findings: list[Finding], + instructor_weight: float = 1.0) -> list[Metric]: + """Метрики с учётом решений преподавателя. + + Проваленная метрика засчитывается, только если все её автоматические + отметки сняты. Ручная отметка проваливает связанные метрики; если их нет, + появляется отдельная метрика преподавателя. Исходный вердикт остаётся в + `passed_auto`/`credit_auto`, повторный пересчёт начинается с него. + """ + base = [ + metric.model_copy(update={ + "passed": metric.passed_auto if metric.passed_auto is not None else metric.passed, + "credit": metric.credit_auto if metric.passed_auto is not None else metric.credit, + "passed_auto": None, + "credit_auto": None, + }) + for metric in metrics + if metric.key != INSTRUCTOR_METRIC + ] + result: list[Metric] = [] + for metric in base: + links = [finding for finding in findings if linked(finding, metric)] + manual = [finding for finding in links + if finding.source is FindingSource.INSTRUCTOR and not _dismissed(finding)] + automatic = [finding for finding in links if finding.source is not FindingSource.INSTRUCTOR] + if manual and _credit(metric) > 0: + metric = metric.model_copy(update={ + "passed": False, "credit": 0.0, + "passed_auto": metric.passed, "credit_auto": metric.credit, + }) + elif (not manual and _credit(metric) < 1 and automatic + and all(_dismissed(finding) for finding in automatic)): + metric = metric.model_copy(update={ + "passed": True, "credit": 1.0, + "passed_auto": metric.passed, "credit_auto": metric.credit, + }) + result.append(metric) + + for finding in findings: + if (finding.source is FindingSource.INSTRUCTOR and not _dismissed(finding) + and not any(linked(finding, metric) for metric in base)): + result.append(Metric( + key=INSTRUCTOR_METRIC, + title=f"Отметка преподавателя: {finding.summary}", + fact=finding.fact, + norm=finding.norm or "по решению преподавателя", + ref=finding.ref, + passed=False, + weight=instructor_weight, + card=finding.card, + service=finding.service, + )) + return result + + +def _instructor_weight(report: dict) -> float: + """Вес метрики преподавателя из весов занятия: веса настраивает преподаватель.""" + criteria = (report.get("full_report") or {}).get("criteria") or {} + return float((criteria.get("score_weights") or {}).get(INSTRUCTOR_METRIC, 1.0)) + + +def _codes(findings: list[Finding]) -> dict[str, int]: + """Коды действующих отметок: по ним строятся частые ошибки группы и профиль.""" + codes: dict[str, int] = {} + for finding in findings: + if not _dismissed(finding): + codes[finding.code.value] = codes.get(finding.code.value, 0) + 1 + return codes + + +def _cards(cards: list[dict], metrics: list[Metric], findings: list[Finding]) -> list[dict]: + """Карточки очереди ДДС с тем же разбором: их метрики — часть метрик занятия.""" + updated = [] + for number, card in enumerate(cards, 1): + own = [metric for metric in metrics if metric.card == number] + updated.append({ + **card, + "metrics": [metric.model_dump(mode="json") for metric in own], + "findings": [finding.model_dump(mode="json") + for finding in findings if finding.card == number], + "score_reviewed": GostResult(metrics=own).score, + }) + return updated + + +def _rescore(report: dict, findings: list[Finding]) -> dict: + metrics = reviewed_metrics( + [Metric.model_validate(item) for item in report.get("metrics", [])], findings, + _instructor_weight(report), + ) + score = GostResult(metrics=metrics).score + fields = { + "findings": [finding.model_dump(mode="json") for finding in findings], + "metrics": [metric.model_dump(mode="json") for metric in metrics], + "competencies": [item.model_dump() for item in radar(metrics)], + "score_reviewed": score, + } + # Правка итога — последнее слово преподавателя; решение по отметке её не отменяет. + if not report.get("overridden_by"): + fields["score_final"] = score + updated = {**report, **fields} + if "summary" in report: + updated["summary"] = {**(report.get("summary") or {}), "codes": _codes(findings)} + if report.get("card_results"): + updated["card_results"] = _cards(report["card_results"], metrics, findings) + archived = report.get("full_report") + if isinstance(archived, dict): + failed = sum(not metric.passed and metric.weight > 0 for metric in metrics) + allowed = (archived.get("criteria") or {}).get("allowed_errors", 0) + updated["full_report"] = { + **archived, **fields, + "card_results": _cards(archived.get("card_results") or [], metrics, findings), + "failed_metrics": failed, + "passed": failed <= allowed, + "score_final": updated.get("score_final", archived.get("score_final")), + } + return updated + + +def _findings(report: dict) -> list[Finding]: + return [Finding.model_validate(item) for item in report.get("findings", [])] + + +def review_finding(report: dict, index: int, review: FindingReview) -> dict: + """Подтвердить или снять отметку. Решение дописывается, действует последнее.""" + findings = _findings(report) + if not 0 <= index < len(findings): + raise LookupError(f"нет отметки {index}") + findings[index] = findings[index].model_copy( + update={"reviews": [*findings[index].reviews, review]}) + return _rescore(report, findings) + + +def has_finding(report: dict, client_id) -> bool: + """Ручная отметка с этим ключом уже в разборе — повтор запроса.""" + return client_id is not None and any( + finding.client_id == client_id for finding in _findings(report)) + + +def add_finding(report: dict, finding: Finding) -> dict: + """Добавить отметку преподавателя; она штрафует связанную метрику.""" + if finding.source is not FindingSource.INSTRUCTOR: + raise ValueError("вручную добавляется только отметка преподавателя") + # Повтор (ретрай сети, вторая вкладка) не штрафует второй раз. + if has_finding(report, finding.client_id): + return report + return _rescore(report, [*_findings(report), finding]) diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py index 28f8dc8..55e750b 100644 --- a/backend/app/scoring/taxonomy.py +++ b/backend/app/scoring/taxonomy.py @@ -31,10 +31,16 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = { "dds_progress": (ErrorCode.D6, Competency.CARD), "dds_completion": (ErrorCode.D6, Competency.CARD), "dds_reply": (ErrorCode.D5, Competency.COMMUNICATION), + "dds_report_reaction": (ErrorCode.D6, Competency.NORMS), "dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION), "description_grammar": (ErrorCode.E4, Competency.COMMUNICATION), } +#: Метрика ручной отметки, которой нечего провалить (у кода нет метрики в +#: METRIC_MAP или в карточке она не считалась). Кода у неё нет: код несёт сама +#: отметка. Вес задаёт преподаватель в весах занятия, по умолчанию 1. +INSTRUCTOR_METRIC = "instructor_finding" + #: Вес метрики в детерминированной оценке. #: #: **Предварительные значения, требуют утверждения методистом.** Без весов все @@ -59,4 +65,5 @@ METRIC_WEIGHTS: dict[str, float] = { "description_grammar": 1.0, "card_fill_time": 1.5, "dds_work_time": 1.5, + "dds_report_reaction": 1.5, } diff --git a/backend/app/scoring/timing.py b/backend/app/scoring/timing.py index 31b2a8a..a2cd944 100644 --- a/backend/app/scoring/timing.py +++ b/backend/app/scoring/timing.py @@ -23,8 +23,8 @@ def time_credit(elapsed_ms: int | None, limit_ms: int) -> float: @dataclass(frozen=True) class TimeNorm: - """Формулировки норматива времени: карточка 112 и карточка ДДС различаются - только словами, расчёт и отметка E3 у них общие.""" + """Формулировки норматива времени: нормативы различаются словами и кодом + отметки, расчёт вклада и формат факта у них общие.""" key: str title: str @@ -32,6 +32,10 @@ class TimeNorm: action: str unmeasured: str incomplete: str + code: ErrorCode = ErrorCode.E3 + source: FindingSource = FindingSource.TIMERS + competency: Competency = Competency.NORMS + ref: str = TIME_REF CARD_FILL_TIME = TimeNorm( @@ -55,7 +59,7 @@ DDS_WORK_TIME = TimeNorm( def time_metric( norm: TimeNorm, elapsed_ms: int | None, limit_ms: int, *, completed: bool ) -> tuple[Metric, Finding | None]: - """Метрика норматива и отметка E3, если он не выполнен. + """Метрика норматива и отметка (по умолчанию E3), если он не выполнен. Незавершённая работа не получает временного вклада: быстрое, но брошенное заполнение не должно выглядеть скоростью. @@ -64,13 +68,20 @@ def time_metric( fact = norm.unmeasured else: delta_ms = elapsed_ms - limit_ms + # Превышение до полусекунды при округлении до целых читалось бы + # как «45 с (+0 с)» при нормативе 45 с — тогда нужна десятая доля. + precise = 0 < delta_ms <= 500 + + def seconds(ms: int) -> str: + return f"{ms / 1000:.1f}" if precise else f"{round(ms / 1000)}" + if delta_ms > 0: - deviation = f"+{round(delta_ms / 1000)} с сверх норматива" + deviation = f"+{seconds(delta_ms)} с сверх норматива" elif delta_ms < 0: deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива" else: deviation = "точно в норматив" - fact = f"{round(elapsed_ms / 1000)} с ({deviation})" + fact = f"{seconds(elapsed_ms)} с ({deviation})" if not completed: fact = f"{norm.incomplete}; {fact}" norm_text = f"{norm.action} за {round(limit_ms / 1000)} с" @@ -80,7 +91,7 @@ def time_metric( title=norm.title, fact=fact, norm=norm_text, - ref=TIME_REF, + ref=norm.ref, passed=passed, weight=METRIC_WEIGHTS[norm.key], credit=time_credit(elapsed_ms, limit_ms) if completed else 0.0, @@ -88,11 +99,12 @@ def time_metric( if passed: return metric, None return metric, Finding( - code=ErrorCode.E3, - source=FindingSource.TIMERS, + code=norm.code, + source=norm.source, summary=norm.summary, fact=fact, norm=norm_text, - ref=TIME_REF, - competency=Competency.NORMS, + ref=norm.ref, + competency=norm.competency, + metric_key=norm.key, ) diff --git a/backend/app/session/dds.py b/backend/app/session/dds.py index 67832de..48fc334 100644 --- a/backend/app/session/dds.py +++ b/backend/app/session/dds.py @@ -446,6 +446,7 @@ class DdsDesk(BaseModel): services[0], ServiceStatus.ACCEPTED, command.comment, author="диспетчер", ) + session.consecutive_refusals = 0 except StatusError: pass # статус уже стоит: повторное нажатие ничего не меняет case "card.status": @@ -462,6 +463,13 @@ class DdsDesk(BaseModel): # Первичный статус останавливает норматив 30 секунд. if command.status in PRIMARY: card.on_event("card.ack") + # Сигнал реестра "повторные отказы" считает подряд идущие + # отказы, а не сумму за занятие. + session.consecutive_refusals = ( + session.consecutive_refusals + 1 + if command.status is ServiceStatus.DECLINED + else 0 + ) if command.status in { ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED, }: diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py index a8b22df..c7a30d9 100644 --- a/backend/app/session/finish.py +++ b/backend/app/session/finish.py @@ -26,11 +26,14 @@ from app.session.hub import hub from app.session.score import ScoringSnapshot, score_session, scoring_scenario from app.session.state import DdsCardRecord, DdsLiveCard, now_utc from app.session.store import ( + FindingAdded, + FindingReviewed, LessonEnded, LessonResumed, ScoreArchived, ScoreCalculated, ScoreOverridden, + apply_finding_change, apply_score_override, ) @@ -46,6 +49,7 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord: services = state.card_services(card) decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK] dds_decision = card.scenario.dds_decision + reaction_ms = state.criteria.dds_report_reaction_limit_seconds * 1000 findings = evaluate_dispatcher( entries=card.status_log, services=services, @@ -55,10 +59,12 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord: reply_text=card.reply_text, expected_decision=dds_decision.expected, expected_decision_reason=dds_decision.reason, + phone_reports=card.phone_reports, + report_reaction_ms=reaction_ms, ) metrics = dispatcher_metrics( card, decision_limit_ms, dds_decision.expected, dds_decision.reason, - services=services, + services=services, report_reaction_ms=reaction_ms, ) weighted = GostResult(metrics=metrics, findings=findings) work_limit_ms = card.timers.limits[TimerCode.DDS_WORK] @@ -101,9 +107,11 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord: card_id=card.card_id, scenario_id=card.scenario.id, reply_text=card.reply_text, - metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}"}) + metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}", + "card": number}) for metric in weighted.metrics], - findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"}) + findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}", + "card": number}) for finding in weighted.findings], actions=actions, duration_ms=max(0, int((now_utc() - card.dispatched_at).total_seconds() * 1000)), @@ -247,3 +255,14 @@ def override_score(state, override: ScoreOverridden) -> None: hub.record(state.session_id, override) state.score = apply_score_override(state.score, override) hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id)) + + +def change_findings(state, change: FindingReviewed | FindingAdded) -> None: + """Решение преподавателя по отметке — тем же путём, что правка итога. + + Вызывается внутри `hub.operation`: отметки, пересчитанный балл, архивный + разбор и строка аудита уходят одним commit. + """ + state.score = apply_finding_change(state.score, change) + hub.record(state.session_id, change) + hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id)) diff --git a/backend/app/session/hub.py b/backend/app/session/hub.py index c894660..c43c756 100644 --- a/backend/app/session/hub.py +++ b/backend/app/session/hub.py @@ -24,6 +24,7 @@ from app.domain.events import ( ) from app.session.state import SessionState from app.session.store import MemorySessionStore, Record, SessionLeaseLost, SessionStore +from app.session.timers import now_utc #: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие: #: очередь ограничена, переполнение роняет соединение, а не сессию. @@ -53,6 +54,9 @@ class SessionHub: self._observers: dict[UUID, set[asyncio.Queue]] = {} self._trainees: dict[UUID, set[asyncio.Queue]] = {} self._stations: dict[UUID, set[asyncio.Queue]] = {} + self._trainee_calls: dict[UUID, set[asyncio.Queue]] = {} + self._trainee_stations: dict[UUID, set[asyncio.Queue]] = {} + self._pending_adoptions: dict[UUID, SessionState] = {} self._tickers: dict[UUID, asyncio.Task] = {} # Задачи, порождённые внутри операции, наследуют контекст; после # закрытия операции их события идут напрямую (`_Operation.open`). @@ -116,6 +120,7 @@ class SessionHub: def drop(self, session_id: UUID) -> None: self._sessions.pop(session_id, None) + self._pending_adoptions.pop(session_id, None) self.stop_ticker(session_id) def live_count(self) -> int: @@ -135,9 +140,10 @@ class SessionHub: if not self.store.persistent: return 0 restored = await self.store.restore_active() + adopted = 0 for state in restored: - self._adopt(state) - return len(restored) + adopted += await self._adopt(state) + return adopted async def maintain_lease(self) -> None: """Один оборот супервизора: продлить свои lease, подхватить просроченные чужие. @@ -154,6 +160,18 @@ class SessionHub: log.warning("продление lease занятия %s не удалось", state.session_id, exc_info=True) await self.fence(state) + for state in list(self._pending_adoptions.values()): + try: + await self.store.commit(state) + except SessionLeaseLost: + self._pending_adoptions.pop(state.session_id, None) + except Exception: + log.warning("повторное сохранение занятия %s после takeover не удалось", + state.session_id, exc_info=True) + else: + self._pending_adoptions.pop(state.session_id, None) + self.register(state) + self.start_ticker(state.session_id) try: claimed = await self.store.claim_expired() except Exception: # следующий оборот попробует снова @@ -163,17 +181,32 @@ class SessionHub: current = self._sessions.get(state.session_id) if current is not None and not current.lease_fenced: continue - self._adopt(state) + await self._adopt(state) async def supervise_lease(self, interval: float) -> None: while True: await asyncio.sleep(interval) await self.maintain_lease() - def _adopt(self, state: SessionState) -> None: + async def _adopt(self, state: SessionState) -> bool: self.stop_ticker(state.session_id) + if state.socket_connected_at_checkpoint: + # Разрыв произошёл при потере узла; старое время подключения не + # доказывает, что курсант отсутствовал всё это время. + state.socket_last_seen_at = now_utc() + state.socket_connected_at_checkpoint = False + try: + await self.store.commit(state) + except SessionLeaseLost: + return False + except Exception: + log.exception("не удалось сохранить присутствие после takeover %s", state.session_id) + self._pending_adoptions[state.session_id] = state + return False + self._pending_adoptions.pop(state.session_id, None) self.register(state) self.start_ticker(state.session_id) + return True async def save_all(self) -> None: """Снимок живых занятий при остановке узла: следующий владелец продолжит с него.""" @@ -285,6 +318,44 @@ class SessionHub: def station(self, session_id: UUID): return self._subscribe(self._stations, session_id) + async def _record_presence(self, session_id: UUID, *, station: bool) -> None: + state = self.get(session_id) + if state is None or state.ended or state.dds_phase != station: + return + async with self.operation(session_id): + state.socket_last_seen_at = now_utc() + present = self._trainee_stations if station else self._trainee_calls + state.socket_connected_at_checkpoint = bool(present.get(session_id)) + + @contextlib.asynccontextmanager + async def trainee_socket( + self, session_id: UUID, *, station: bool, trainee: bool, + ) -> AsyncIterator[asyncio.Queue]: + """Учесть только сокет курсанта; вещание преподавателю остаётся общим.""" + registry = self._stations if station else self._trainees + presence = self._trainee_stations if station else self._trainee_calls + with self._subscribe(registry, session_id) as queue: + if trainee: + presence.setdefault(session_id, set()).add(queue) + try: + if trainee: + try: + await self._record_presence(session_id, station=station) + except Exception: + if not self.is_lease_fenced(session_id): + raise + yield queue + finally: + if trainee: + presence[session_id].discard(queue) + if not presence[session_id]: + presence.pop(session_id) + try: + await self._record_presence(session_id, station=station) + except Exception: + if not self.is_lease_fenced(session_id): + raise + # ── вещание ── @staticmethod @@ -314,6 +385,12 @@ class SessionHub: def observer_count(self, session_id: UUID) -> int: return len(self._observers.get(session_id, set())) + def station_connected(self, session_id: UUID) -> bool: + return bool(self._trainee_stations.get(session_id)) + + def trainee_connected(self, session_id: UUID) -> bool: + return bool(self._trainee_calls.get(session_id)) + # ── такт таймеров ── def start_ticker(self, session_id: UUID) -> None: diff --git a/backend/app/session/pg_store.py b/backend/app/session/pg_store.py index 24b1e0f..7c0b37a 100644 --- a/backend/app/session/pg_store.py +++ b/backend/app/session/pg_store.py @@ -29,6 +29,8 @@ from app.domain.events import Mood, Speaker, TranscriptEntry from app.session.checkpoint import dump_state, load_state from app.session.state import SessionState, now_utc from app.session.store import ( + FindingAdded, + FindingReviewed, HintRecorded, LessonEnded, LessonIdentity, @@ -44,8 +46,10 @@ from app.session.store import ( SelfAssessed, SessionLeaseLost, UtteranceAppended, + apply_finding_change, apply_score_override, ) +from app.scoring.review import has_finding log = logging.getLogger(__name__) LEASE_SECONDS = 15 @@ -251,6 +255,40 @@ class PostgresSessionStore: detail=(f"{score.score_auto} → {record.score_final}; " f"comment_chars={len(record.comment)}"), )) + case FindingReviewed() | FindingAdded(): + await db.flush() + score = await db.scalar( + select(Score).where(Score.session_id == session_id).with_for_update() + ) + if score is None: + raise LookupError(f"нет оценки занятия {session_id}") + # Повтор ручной отметки под блокировкой: ни изменения, ни второй строки аудита. + if (isinstance(record, FindingAdded) + and has_finding(score.report or {}, record.finding.client_id)): + return + before = score.score_final + findings = (score.report or {}).get("findings", []) + score.report = apply_finding_change(dict(score.report or {}), record) + score.score_final = score.report.get("score_final", score.score_final) + if isinstance(record, FindingReviewed): + code = (findings[record.index].get("code", "?") + if 0 <= record.index < len(findings) else "?") + actor, action = record.review.author, "finding.review" + what = (f"#{record.index} {code} {record.review.decision.value}; " + f"причина: {record.review.reason}") + else: + finding = record.finding + actor, action = finding.author or "instructor", "finding.add" + where = f", карточка {finding.card}" if finding.card is not None else "" + what = (f"{finding.code.value}{where}; факт: {finding.fact}; " + f"норма: {finding.norm or ''}") + # Решение по отметке меняет чужой балл: аудит сам по себе должен + # ответить «кто, когда, что и почему», даже если разбор потом правили. + db.add(AuditLog( + actor=actor, role=record.role, action=action, + object_id=str(session_id), + detail=f"{what}; {before} → {score.score_final}", + )) await db.flush() @staticmethod diff --git a/backend/app/session/score.py b/backend/app/session/score.py index 10c637c..2ef6501 100644 --- a/backend/app/session/score.py +++ b/backend/app/session/score.py @@ -210,6 +210,7 @@ async def score_session( norm="грамматически корректное описание", ref="критерий занятия; правила русского языка", competency=Competency.COMMUNICATION, + metric_key="description_grammar", )) # Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут # рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии @@ -218,6 +219,7 @@ async def score_session( if live_card is not None: decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK] services = list(snapshot.call_card_services) + reaction_ms = snapshot.criteria.dds_report_reaction_limit_seconds * 1000 result.findings.extend(evaluate_dispatcher( entries=live_card.status_log, services=services, @@ -227,10 +229,12 @@ async def score_session( reply_text=live_card.reply_text, expected_decision=scenario.dds_decision.expected, expected_decision_reason=scenario.dds_decision.reason, + phone_reports=live_card.phone_reports, + report_reaction_ms=reaction_ms, )) result.metrics.extend(dispatcher_metrics( live_card, decision_limit_ms, scenario.dds_decision.expected, - scenario.dds_decision.reason, services=services, + scenario.dds_decision.reason, services=services, report_reaction_ms=reaction_ms, )) # Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой # занятия. Веса первого сценария поверх них испортили бы остальные. diff --git a/backend/app/session/state.py b/backend/app/session/state.py index d7f1862..1f2c850 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -130,6 +130,15 @@ class PersistedSession(BaseModel): #: a lost WebSocket acknowledgement cannot apply an operation twice. processed_station_commands: list[str] = Field(default_factory=list) text_revealed_facts: dict[str, str] = Field(default_factory=dict) + #: Подряд идущих первичных отказов без принятой карточки между ними — + #: сигнал реестра преподавателя, не влияет на балл. + consecutive_refusals: int = 0 + #: Последнее подключение/отключение сокета курсанта или начало фазы ДДС. + #: Сохраняется вместе с занятием, чтобы чтение реестра не меняло состояние. + socket_last_seen_at: datetime | None = None + #: На момент последнего checkpoint курсант был подключён на текущем АРМ. + #: После аварии узла момент потери связи неизвестен: окно начинается при takeover. + socket_connected_at_checkpoint: bool = False @field_validator("processed_station_commands") @classmethod @@ -241,6 +250,9 @@ class SessionState(PersistedSession): self.dispatched_at = now_utc() if self.exercise is Exercise.CALL: self._receive_call_card() + if self.handoff_to_dds: + self.socket_last_seen_at = self.dispatched_at + self.socket_connected_at_checkpoint = False return self.dispatched_card def _receive_call_card(self) -> None: diff --git a/backend/app/session/store.py b/backend/app/session/store.py index e1770fa..2823793 100644 --- a/backend/app/session/store.py +++ b/backend/app/session/store.py @@ -16,6 +16,8 @@ from typing import Protocol from uuid import UUID from app.domain.events import TranscriptEntry +from app.domain.taxonomy import Finding, FindingReview +from app.scoring.review import add_finding, review_finding from app.session.checkpoint import dump_state from app.session.state import SessionState @@ -90,6 +92,14 @@ class LessonPaused: role: str +@dataclass(frozen=True) +class FindingReviewed: + """Преподаватель подтвердил или снял отметку разбора.""" + index: int + review: FindingReview + role: str + + @dataclass(frozen=True) class LessonResumed: at: datetime @@ -99,9 +109,23 @@ class LessonResumed: paused_ms: int +@dataclass(frozen=True) +class FindingAdded: + """Преподаватель добавил свою отметку (`source=instructor`).""" + finding: Finding + role: str + + Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted | LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden - | LessonPaused | LessonResumed) + | LessonPaused | LessonResumed | FindingReviewed | FindingAdded) + + +def apply_finding_change(report: dict, change: "FindingReviewed | FindingAdded") -> dict: + """Решение по отметке одинаково для живой сессии, строки `Score` и архива.""" + if isinstance(change, FindingReviewed): + return review_finding(report, change.index, change.review) + return add_finding(report, change.finding) def apply_score_override(report: dict, override: ScoreOverridden) -> dict: @@ -217,6 +241,10 @@ class MemorySessionStore: if session_id not in self.scores: raise LookupError(f"нет оценки занятия {session_id}") self.scores[session_id] = apply_score_override(self.scores[session_id], record) + elif isinstance(record, FindingReviewed | FindingAdded): + if session_id not in self.scores: + raise LookupError(f"нет оценки занятия {session_id}") + self.scores[session_id] = apply_finding_change(self.scores[session_id], record) self.records.setdefault(session_id, []).extend(records) def snapshot(self, session_id: UUID) -> dict | None: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index efd808e..c2caf51 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -12,6 +12,20 @@ from pathlib import Path from urllib.parse import urlparse os.environ.setdefault("DEV_AUTH_BYPASS", "true") +# Тесты ждут локальную модель по умолчанию. Личный backend/.env с облачным +# провайдером иначе валит проверки офлайн-гарда; переменные окружения +# перекрывают .env, а живые проверки test_llm.py выставляют свои поверх. +# Рекомендации Vikhr выключены: на WSL2 подключение к закрытому loopback-порту +# висит до таймаута, и финиш занятия ждал бы 6 с. Тесты рекомендаций +# подменяют coach сами. +os.environ.update({ + "LLM_PROVIDER": "local", + "LLM_BASE_URL": "http://127.0.0.1:18080/v1", + "LLM_API_KEY": "", + "LLM_MODEL_CALLER": "Qwen3-1.7B", + "LLM_MODEL_JUDGE": "", + "ASSESSMENT_FEEDBACK_ENABLED": "false", +}) import pytest # noqa: E402 @@ -36,6 +50,19 @@ def postgres_access(): pytest.skip(f"PostgreSQL unavailable ({exc})") +@pytest.fixture(scope="module") +def template_caller(): + """Звонящий без модели: сквозные тесты занятия ждут заготовки и офлайн- + ответы. С моделью по умолчанию (loopback Qwen) фабрика выбрала бы + LlmCaller и ждала неподнятый сервер.""" + from app.dialog import factory + + settings = get_settings().model_copy(update={"llm_provider": "disabled"}) + with pytest.MonkeyPatch.context() as patch: + patch.setattr(factory, "get_settings", lambda: settings) + yield + + @pytest.fixture(autouse=True, scope="session") def _dev_auth(): """Флаг обхода читается один раз при создании настроек.""" diff --git a/backend/tests/test_auth_hardening.py b/backend/tests/test_auth_hardening.py index 05f22cc..eec963a 100644 --- a/backend/tests/test_auth_hardening.py +++ b/backend/tests/test_auth_hardening.py @@ -272,6 +272,40 @@ def test_peer_node_generation_sync_closes_revoked_websocket(monkeypatch): asyncio.run(run()) +def test_generation_sync_keeps_revocation_of_login_missing_from_db(monkeypatch): + # Демо-логины синтетические, в users их нет. Сброс поколения к 0 после + # выхода вернул бы силу cookie, выданной до выхода. + login = "demo-instructor" + + class FakeResult: + def all(self): + return [] + + class FakeDb: + async def execute(self, _query): + return FakeResult() + + class FakeSession: + async def __aenter__(self): + return FakeDb() + + async def __aexit__(self, *_args): + return None + + monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession()) + auth.prime_generations({}) + who = auth._demo_principal(Role.INSTRUCTOR) + request = SimpleNamespace(session={}) + auth._issue_session(request, who) + stale = dict(request.session) + + auth.invalidate_login(login) + asyncio.run(auth.sync_generations()) + + assert auth._session_principal(stale) is None + auth._generations.pop(login, None) + + def test_revocation_does_not_log_error_if_socket_already_disconnected(): class DisconnectedSocket: async def close(self, **_kwargs): diff --git a/backend/tests/test_dds_exercise.py b/backend/tests/test_dds_exercise.py index 519c2f2..70a4e49 100644 --- a/backend/tests/test_dds_exercise.py +++ b/backend/tests/test_dds_exercise.py @@ -2,7 +2,7 @@ import asyncio import time -from datetime import datetime +from datetime import datetime, timedelta from uuid import uuid4 import pytest @@ -396,6 +396,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa client, criteria={ "decision_time_limit_seconds": 45, + "dds_report_reaction_limit_seconds": 20, "allowed_errors": 50, "require_correct_grammar": False, "score_weights": {"dds_ack": 3.5}, @@ -454,6 +455,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa "decision_time_limit_seconds": 45, "card_fill_time_limit_seconds": 180, "dds_card_work_time_limit_seconds": 180, + "dds_report_reaction_limit_seconds": 20, "allowed_errors": 50, "require_correct_grammar": False, "score_weights": {"dds_ack": 3.5}, @@ -797,6 +799,118 @@ def test_complete_dds_workflow_with_training_calls_and_status_updates(client): ] assert all(report.crew == crew for report in state.desk.active.phone_reports) assert not score["findings"] + reaction = [m for m in score["metrics"] if m["key"] == "dds_report_reaction"] + assert len(reaction) == 1 and reaction[0]["passed"], "каждый доклад отмечен вовремя" + assert "с нарушением 0 из 4 докладов" in reaction[0]["fact"] + finally: + control.__exit__(None, None, None) + + +STATUS_COMMENT = "Основание: доклад бригады.\nСведения: передано старшему бригады." + + +def accept_with_crew(station): + read_until(station, "card.received") + snapshot = read_until(station, "station.state")["snapshot"] + service = snapshot["services"][0] + crew = next(option for option in snapshot["crew_options"] + if option.startswith(service + " — ")) + station.send_json({"type": "card.status", "service": service, "status": "accepted", + "comment": STATUS_COMMENT}) + read_until(station, "station.state") + station.send_json({"type": "crew.select", "crew": crew}) + read_until(station, "station.state") + return service + + +def send_status(station, service, status): + station.send_json({"type": "card.status", "service": service, "status": status, + "comment": STATUS_COMMENT}) + read_until(station, "station.state") + + +@pytest.mark.parametrize(("reaction", "passed"), [ + ("fast", True), ("slow", False), ("missing", False), ("early", True), +]) +def test_dds_report_reaction_goes_from_criteria_to_report(client, reaction, passed): + """Лимит из `scenario.start` доходит до метрики, отметки D6 и отчёта.""" + session_id, control = start(client, criteria={ + "dds_report_reaction_limit_seconds": 20, + "score_weights": {"dds_report_reaction": 3.5}, + }) + try: + state = hub.get(session_id) + with client.websocket_connect(f"/ws/station/{session_id}") as station: + service = accept_with_crew(station) + if reaction == "early": + send_status(station, service, "responding") + complete_phone_call(station, state, "dispatched") + read_until(station, "station.state") + if reaction in {"fast", "slow"}: + send_status(station, service, "responding") + if reaction == "slow": + # Доклад на 70 с раньше статуса: время доклада — серверное. + state.desk.active.phone_reports[0].at -= timedelta(seconds=70) + station.send_json({"type": "station.finish"}) + read_until(station, "score.ready") + + score = wait_for(lambda: hub.get(session_id).score) + metrics = [item for item in score["metrics"] if item["key"] == "dds_report_reaction"] + assert len(metrics) == 1 + metric = metrics[0] + assert metric["passed"] is passed + assert metric["weight"] == 3.5 + assert metric["norm"] == "отметить статус по докладу за 20 с" + assert "«Начало реагирования» по докладу бригады" in metric["fact"] + findings = [item for item in score["findings"] + if item["metric_key"] == "dds_report_reaction"] + assert [item["code"] for item in findings] == ([] if passed else ["D6"]) + assert all(item["competency"] == "norms" for item in findings) + if reaction == "slow": + assert "70 с (+50 с сверх норматива)" in metric["fact"] + assert findings[0]["fact"] == metric["fact"] + if reaction == "missing": + assert metric["fact"].endswith(": не отмечен") and metric["credit"] == 0.0 + if reaction == "early": + assert metric["fact"].endswith("отмечен не позже доклада") + + report = client.get(f"/api/sessions/{session_id}/report").json() + assert report["criteria"]["dds_report_reaction_limit_seconds"] == 20 + assert [item for item in report["metrics"] + if item["key"] == "dds_report_reaction"] == metrics + if reaction == "slow": + csv_text = client.get(f"/api/sessions/{session_id}/report.csv").content.decode("utf-8-sig") + assert "Лимит реакции на доклад бригады" in csv_text + assert "70 с (+50 с сверх норматива)" in csv_text + finally: + control.__exit__(None, None, None) + + +def test_refusal_after_crew_report_does_not_lower_score_without_finding(client): + """Бригада доложила о прибытии, но работы не проводились: отказ с причиной + — терминальный исход, неотмеченное «Прибытие» не штрафуется.""" + session_id, control = start(client) + try: + state = hub.get(session_id) + with client.websocket_connect(f"/ws/station/{session_id}") as station: + service = accept_with_crew(station) + complete_phone_call(station, state, "dispatched") + read_until(station, "station.state") + send_status(station, service, "responding") + complete_phone_call(station, state, "arrived") + read_until(station, "station.state") + send_status(station, service, "refused") + station.send_json({"type": "station.finish"}) + read_until(station, "score.ready") + + score = wait_for(lambda: hub.get(session_id).score) + assert [report.phase for report in state.desk.active.phone_reports] == [ + "dispatched", "arrived", + ] + metric = next(item for item in score["metrics"] if item["key"] == "dds_report_reaction") + assert metric["passed"], "прибытие при отказе не требуется" + assert "о выезде" in metric["fact"] and "докладов" not in metric["fact"] + assert not [item for item in score["findings"] if item["code"] == "D6"] finally: control.__exit__(None, None, None) diff --git a/backend/tests/test_dispatcher_scoring.py b/backend/tests/test_dispatcher_scoring.py index 8bc5c43..f4ba2df 100644 --- a/backend/tests/test_dispatcher_scoring.py +++ b/backend/tests/test_dispatcher_scoring.py @@ -1,13 +1,14 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path import pytest from pydantic import ValidationError -from app.domain.statuses import ServiceStatus, StatusEntry -from app.domain.taxonomy import ErrorCode +from app.domain.statuses import PhoneReportRecord, ServiceStatus, StatusEntry +from app.domain.taxonomy import Competency, ErrorCode, FindingSource from app.scenarios.loader import load_file from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher +from app.scoring.timing import time_credit from app.session.dds import DdsLiveCard, build_card from app.session.timers import SessionTimers @@ -294,3 +295,158 @@ def test_reasoned_refusal_after_acceptance_is_a_valid_terminal_path(): deadline_ms=30_000, elapsed_ms=0, ) assert ErrorCode.D6 not in [finding.code for finding in findings] + + +# ── Реакция на доклад бригады (lct-37) ── + +T0 = datetime(2026, 9, 27, 10, 0, tzinfo=timezone.utc) +COMMENT = "Основание: доклад старшего.\nСведения: передано дежурному." + + +def at_second(seconds: int) -> datetime: + return T0 + timedelta(seconds=seconds) + + +def mark(status: ServiceStatus, seconds: int) -> StatusEntry: + return StatusEntry(service=SERVICES[0], status=status, at=at_second(seconds), comment=COMMENT) + + +def crew_report(phase: str, seconds: int) -> PhoneReportRecord: + return PhoneReportRecord(service=SERVICES[0], crew="Бригада 12", phase=phase, + text="доклад", at=at_second(seconds)) + + +def reaction(status_log, reports, limit_ms=45_000): + card = live_card(status_log=status_log, crew_assignments={SERVICES[0]: "Бригада 12"}, + dispatched_at=T0) + card.phone_reports = reports + findings = evaluate_dispatcher( + entries=status_log, services=SERVICES, crew_assignments=card.crew_assignments, + deadline_ms=30_000, elapsed_ms=5_000, phone_reports=reports, + report_reaction_ms=limit_ms, + ) + metric = next((m for m in dispatcher_metrics(card, 30_000, services=SERVICES, + report_reaction_ms=limit_ms) + if m.key == "dds_report_reaction"), None) + return [f for f in findings if f.metric_key == "dds_report_reaction"], metric + + +def full_run(arrived_at: int) -> list[StatusEntry]: + return [mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20), + mark(ServiceStatus.ARRIVED, arrived_at), mark(ServiceStatus.WORKING, arrived_at + 10), + mark(ServiceStatus.COMPLETED, arrived_at + 20)] + + +def test_status_soon_after_crew_report_passes(): + findings, metric = reaction(full_run(arrived_at=110), [crew_report("arrived", 100)]) + + assert findings == [] + assert metric.passed and metric.weight == 1.5 + assert metric.fact == ( + "статус «Прибытие» по докладу бригады «Бригада 12» о прибытии: " + "10 с (на 35 с быстрее норматива)" + ) + assert metric.credit == time_credit(10_000, 45_000) + assert metric.title == f"{SERVICES[0]}: реакция на доклад бригады" + + +def test_status_later_than_limit_after_crew_report_is_d6_with_fact_and_norm(): + findings, metric = reaction(full_run(arrived_at=170), [crew_report("arrived", 100)]) + + assert [f.code for f in findings] == [ErrorCode.D6] + assert "«Прибытие»" in findings[0].fact and "70 с (+25 с сверх норматива)" in findings[0].fact + assert "о прибытии" in findings[0].fact, "факт ссылается на доклад" + assert findings[0].norm == metric.norm == "отметить статус по докладу за 45 с" + assert findings[0].competency is Competency.NORMS, "отметка и метрика в одной компетенции" + assert findings[0].source is FindingSource.DISPATCHER + assert findings[0].service == SERVICES[0] + assert not metric.passed and metric.fact == findings[0].fact + assert metric.credit == time_credit(70_000, 45_000) + + +def test_violation_by_a_fraction_of_a_second_is_not_shown_as_the_limit(): + status_log = full_run(arrived_at=170) + status_log[2] = status_log[2].model_copy( + update={"at": at_second(100) + timedelta(milliseconds=45_400)}, + ) + + findings, metric = reaction(status_log, [crew_report("arrived", 100)]) + + assert not metric.passed + assert "45.4 с (+0.4 с сверх норматива)" in findings[0].fact + + +def test_missing_status_after_crew_report_is_d6(): + status_log = [mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20)] + + findings, metric = reaction(status_log, [crew_report("arrived", 100)]) + + assert [f.code for f in findings] == [ErrorCode.D6] + assert "не отмечен" in findings[0].fact and "Бригада 12" in findings[0].fact + assert not metric.passed and metric.credit == 0.0 + assert metric.fact == "статус «Прибытие» по докладу бригады «Бригада 12» о прибытии: не отмечен" + + +def test_metric_takes_the_worst_report_and_counts_violations(): + reports = [crew_report("dispatched", 10), crew_report("arrived", 100), + crew_report("working", 175)] + + findings, metric = reaction(full_run(arrived_at=170), reports) + + assert len(findings) == 1, "одна отметка на службу, как у других нормативов" + assert "о прибытии: 70 с" in metric.fact + assert "с нарушением 1 из 3 докладов" in metric.fact + + +def test_status_set_before_the_report_and_custom_limit(): + findings, metric = reaction(full_run(arrived_at=90), [crew_report("arrived", 100)]) + assert findings == [] and metric.passed, "статус раньше доклада — не нарушение" + assert metric.fact.endswith("отмечен не позже доклада") and metric.credit == 1.0 + + strict, _ = reaction(full_run(arrived_at=130), [crew_report("arrived", 100)], limit_ms=20_000) + assert strict[0].norm == "отметить статус по докладу за 20 с" + + +def test_no_crew_reports_means_no_reaction_metric(): + findings, metric = reaction(full_run(arrived_at=110), []) + + assert findings == [] and metric is None + + +def test_refusal_after_crew_report_is_neither_d6_nor_failed_metric(): + status_log = [ + mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20), + StatusEntry(service=SERVICES[0], status=ServiceStatus.REFUSED, at=at_second(120), + comment="Основание: доклад старшего.\nСведения: бригаде отказано в доступе."), + ] + + findings, metric = reaction(status_log, [crew_report("arrived", 100)]) + + assert findings == [], "работ не было — статус «Прибытие» отмечать не нужно" + assert metric is None, "метрика не снижает балл без отметки" + + +def test_refusal_keeps_late_reaction_on_reported_status(): + status_log = [ + mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 80), + StatusEntry(service=SERVICES[0], status=ServiceStatus.REFUSED, at=at_second(120), + comment="Основание: доклад старшего.\nСведения: бригаде отказано в доступе."), + ] + + findings, metric = reaction(status_log, [crew_report("dispatched", 10), + crew_report("arrived", 100)]) + + assert [f.code for f in findings] == [ErrorCode.D6] + assert not metric.passed and "о выезде: 70 с" in metric.fact + assert "докладов" not in metric.fact, "неотмеченное прибытие при отказе не считается" + + +def test_unfinished_work_after_completion_report_names_the_report(): + status_log = full_run(arrived_at=110)[:-1] + findings = evaluate_dispatcher( + entries=status_log, services=SERVICES, crew_assignments={SERVICES[0]: "Бригада 12"}, + deadline_ms=30_000, elapsed_ms=5_000, phone_reports=[crew_report("completed", 150)], + ) + + unfinished = next(f for f in findings if f.summary.endswith("не доведён до конца")) + assert "бригада доложила о завершении работ" in unfinished.fact diff --git a/backend/tests/test_finding_review.py b/backend/tests/test_finding_review.py new file mode 100644 index 0000000..c8477f6 --- /dev/null +++ b/backend/tests/test_finding_review.py @@ -0,0 +1,644 @@ +"""Разбор по отметкам (lct-35): преподаватель подтверждает, снимает и добавляет отметку. + +Балл пересчитывается теми же метриками и весами: снятая отметка не штрафует +свою метрику, ручная — штрафует. Автоматическая оценка остаётся рядом. +""" + +import asyncio +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.api.auth import Principal +from app.api.http import sessions +from app.domain.events import Metric +from app.domain.roles import Role +from app.domain.taxonomy import ( + ErrorCode, + Finding, + FindingDecision, + FindingReview, + FindingSource, +) +from app.main import app +from app.scoring.export import to_csv +from app.scoring.review import INSTRUCTOR_METRIC, add_finding, review_finding +from app.session import finish as finish_module +from app.session.hub import hub +from app.session.store import FindingAdded, FindingReviewed, MemorySessionStore + +from tests.test_dds_desk_scores import dds_exercise_early_finish + +AT = datetime(2026, 9, 27, 12, 0, tzinfo=UTC) + + +def review(decision: FindingDecision) -> FindingReview: + return FindingReview(decision=decision, reason="доклад пришёл по рации", author="teacher", at=AT) + + +def report_with(metrics: list[Metric], findings: list[Finding]) -> dict: + return { + "score_auto": 50.0, + "metrics": [metric.model_dump(mode="json") for metric in metrics], + "findings": [finding.model_dump(mode="json") for finding in findings], + "full_report": {"criteria": {"allowed_errors": 0}, "score_auto": 50.0, + "score_final": 50.0, "failed_metrics": 1, "passed": False}, + } + + +def dds_metric(key: str, passed: bool, card: int = 1) -> Metric: + return Metric(key=key, title=key, fact="факт", norm="норма", passed=passed, + weight=1.0, card=card, service="01") + + +def dds_finding(code: ErrorCode, card: int = 1) -> Finding: + return Finding(code=code, source=FindingSource.DISPATCHER, summary=code.value, + fact="факт", norm="норма", card=card, service="01") + + +def test_dismissed_finding_stops_penalizing_its_metric_but_stays_in_report(): + report = report_with( + [dds_metric("dds_primary", True), dds_metric("dds_progress", False)], + [dds_finding(ErrorCode.D6)], + ) + + updated = review_finding(report, 0, review(FindingDecision.DISMISSED)) + + assert updated["score_reviewed"] == 100.0 + assert updated["score_final"] == 100.0 + assert updated["score_auto"] == 50.0, "автооценка остаётся рядом" + progress = updated["metrics"][1] + assert (progress["passed"], progress["passed_auto"]) == (True, False) + finding = updated["findings"][0] + assert finding["reviews"][-1]["decision"] == "dismissed" + assert finding["reviews"][-1]["reason"] == "доклад пришёл по рации" + archived = updated["full_report"] + assert (archived["score_final"], archived["failed_metrics"], archived["passed"]) == (100.0, 0, True) + + +def test_confirmed_finding_and_other_card_keep_the_metric_failed(): + report = report_with( + [dds_metric("dds_progress", False, card=1), dds_metric("dds_progress", False, card=2)], + [dds_finding(ErrorCode.D6, card=1), dds_finding(ErrorCode.D6, card=2)], + ) + + confirmed = review_finding(report, 0, review(FindingDecision.CONFIRMED)) + assert confirmed["score_reviewed"] == 0.0 + + dismissed = review_finding(report, 0, review(FindingDecision.DISMISSED)) + assert [metric["passed"] for metric in dismissed["metrics"]] == [True, False], ( + "снятие отметки первой карточки не трогает вторую" + ) + + +def test_metric_stays_failed_while_one_of_its_findings_is_active(): + report = report_with( + [dds_metric("dds_reply", False)], + [dds_finding(ErrorCode.D5), dds_finding(ErrorCode.D5)], + ) + + updated = review_finding(report, 0, review(FindingDecision.DISMISSED)) + + assert updated["metrics"][0]["passed"] is False + again = review_finding(updated, 1, review(FindingDecision.DISMISSED)) + assert again["metrics"][0]["passed"] is True + + +def test_instructor_finding_penalizes_linked_metric_and_unlinked_code_adds_one(): + report = report_with([dds_metric("dds_progress", True), dds_metric("dds_primary", True)], []) + manual = Finding(code=ErrorCode.D6, source=FindingSource.INSTRUCTOR, summary="Карточка 1: D6", + fact="«Прибыла» отмечено до доклада", norm="по факту доклада", + card=1, service="01", author="teacher", at=AT) + + updated = add_finding(report, manual) + + assert updated["score_reviewed"] == 50.0 + assert updated["metrics"][0]["passed"] is False + assert updated["metrics"][0]["passed_auto"] is True + assert updated["findings"][0]["source"] == "instructor" + + # У D4 в методике нет метрики — ручная отметка всё равно должна штрафовать. + no_metric = manual.model_copy(update={"code": ErrorCode.D4}) + extra = add_finding(report, no_metric) + assert [metric["key"] for metric in extra["metrics"]][-1] == INSTRUCTOR_METRIC + assert extra["score_reviewed"] == pytest.approx(66.7) + + # Снятая ручная отметка не штрафует, и её метрика исчезает при пересчёте. + lifted = review_finding(extra, 0, review(FindingDecision.DISMISSED)) + assert lifted["score_reviewed"] == 100.0 + assert INSTRUCTOR_METRIC not in {metric["key"] for metric in lifted["metrics"]} + + +def test_score_override_stays_final_after_finding_review(): + report = {**report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)]), + "score_final": 70.0, "overridden_by": "teacher"} + + updated = review_finding(report, 0, review(FindingDecision.DISMISSED)) + + assert updated["score_reviewed"] == 100.0 + assert updated["score_final"] == 70.0 + + +def test_unknown_finding_index_is_refused(): + with pytest.raises(LookupError): + review_finding(report_with([], []), 0, review(FindingDecision.DISMISSED)) + + +# ── HTTP: живое занятие ДДС ── + +@pytest.fixture +def lesson(monkeypatch): + """Завершённое занятие ДДС преподавателя `dev` в памяти узла.""" + + class NoCoaching: + def model_dump(self, **_kwargs): + return {} + + async def no_coach(_metrics): + return NoCoaching() + + async def audit_in_memory(*_args, **_kwargs): + return None + + async def no_database(): + yield None + + # Живое занятие в памяти: чтение отчёта не должно ходить в PostgreSQL. + monkeypatch.setattr(finish_module, "coach", no_coach) + monkeypatch.setattr(sessions, "audit_required", audit_in_memory) + monkeypatch.setitem(app.dependency_overrides, sessions.optional_session, no_database) + store = MemorySessionStore() + monkeypatch.setattr(hub, "store", store) + state = dds_exercise_early_finish() + state.owner_login = "dev" + with TestClient(app) as browser: + browser.post("/api/auth/dev-token") + hub.store = store + hub.register(state) + yield browser, state, store + + +def as_role(monkeypatch, role: Role, login: str = "dev") -> None: + def require(_request, *roles): + if roles and role not in roles: + raise HTTPException(status_code=403, detail="forbidden") + return Principal(login=login, full_name="Проверка", role=role, + trainee_id=uuid4() if role is Role.TRAINEE else None) + + monkeypatch.setattr(sessions, "require", require) + + +def test_owner_dismisses_and_adds_finding_through_http(lesson): + browser, state, store = lesson + session_id = state.session_id + before = browser.get(f"/api/sessions/{session_id}/report").json() + target = next(index for index, finding in enumerate(before["findings"]) + if finding["code"] == "D1") + + dismissed = browser.post( + f"/api/sessions/{session_id}/findings/{target}/review", + json={"decision": "dismissed", "reason": "карточку выдали с опозданием"}, + ) + assert dismissed.status_code == 200, dismissed.text + body = dismissed.json() + assert body["findings"][target]["reviews"][-1]["decision"] == "dismissed" + assert body["findings"][target]["reviews"][-1]["author"] == "dev" + assert body["score_auto"] == before["score_auto"] + assert body["score_reviewed"] >= before["score_auto"] + + added = browser.post( + f"/api/sessions/{session_id}/findings", + json={"code": "D5", "fact": "в комментарии нет адресата", "norm": "назвать адресата", + "card": 1}, + ) + assert added.status_code == 200, added.text + manual = added.json()["findings"][-1] + assert (manual["source"], manual["card"], manual["author"]) == ("instructor", 1, "dev") + assert manual["summary"].startswith("Карточка 1: ") + + records = [record for _, batch in store.commits for record in batch] + assert [type(record) for record in records[-2:]] == [FindingReviewed, FindingAdded] + assert store.scores[session_id]["findings"][target]["reviews"][-1]["decision"] == "dismissed" + + rows = to_csv(sessions.SessionReport.model_validate(added.json())).decode("utf-8-sig") + assert "карточку выдали с опозданием" in rows + assert "в комментарии нет адресата" in rows + + +def test_review_requires_reason_and_existing_card(lesson): + browser, state, _store = lesson + session_id = state.session_id + + blank = browser.post(f"/api/sessions/{session_id}/findings/0/review", + json={"decision": "dismissed", "reason": " "}) + assert blank.status_code == 422 + missing = browser.post(f"/api/sessions/{session_id}/findings/999/review", + json={"decision": "confirmed", "reason": "проверено"}) + assert missing.status_code == 404 + card = browser.post(f"/api/sessions/{session_id}/findings", + json={"code": "D1", "fact": "факт", "norm": "норма", "card": 99}) + assert card.status_code == 422 + + +@pytest.mark.parametrize("role, login, status", [ + (Role.TRAINEE, "trainee", 403), + (Role.INSTRUCTOR, "someone-else", 404), +]) +def test_trainee_and_foreign_instructor_cannot_review(lesson, monkeypatch, role, login, status): + browser, state, store = lesson + commits = len(store.commits) + as_role(monkeypatch, role, login) + + reviewed = browser.post(f"/api/sessions/{state.session_id}/findings/0/review", + json={"decision": "dismissed", "reason": "не согласен"}) + added = browser.post(f"/api/sessions/{state.session_id}/findings", + json={"code": "D1", "fact": "факт", "norm": "норма"}) + + assert (reviewed.status_code, added.status_code) == (status, status) + assert len(store.commits) == commits, "отказ ничего не пишет" + + +def test_finding_review_is_audited_in_postgres(postgres_access): + """Решение и аудит — одна транзакция: строка Score и AuditLog вместе.""" + from sqlalchemy import select + + from app.db.base import get_sessionmaker + from app.db.models import AuditLog, Score + from app.session.pg_store import PostgresSessionStore + from app.session.store import LessonRequest + + store = PostgresSessionStore(get_sessionmaker()) + session_id = uuid4() + report = report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)]) + + async def scenario(): + from app.session.state import SessionState + from app.domain.events import Exercise, SessionMode + + def build(_identity): + return SessionState(session_id=session_id, scenario_id="fire-apartment-l2", + scenario_title="Пожар", level="L2", mode=SessionMode.TRAINING, + exercise=Exercise.DDS) + + await store.open(LessonRequest( + session_id=session_id, scenario_id="fire-apartment-l2", mode="training", + trainee_name=None, trainee_id=None, owner_login="teacher", backend_node_id=None, + ), build) + from app.session.store import ScoreCalculated + await store.commit_archived(session_id, [ScoreCalculated(50.0, report)]) + await store.commit_archived(session_id, [FindingReviewed( + index=0, role="instructor", review=review(FindingDecision.DISMISSED), + )]) + async with get_sessionmaker()() as db: + score = await db.scalar(select(Score).where(Score.session_id == session_id)) + audit = await db.scalar(select(AuditLog).where( + AuditLog.object_id == str(session_id), AuditLog.action == "finding.review", + )) + return score, audit + + score, audit = asyncio.run(scenario()) + assert score.score_auto == 50.0 + assert score.score_final == 100.0 + assert score.report["findings"][0]["reviews"][-1]["decision"] == "dismissed" + assert audit is not None and audit.actor == "teacher" + assert "dismissed" in audit.detail and "50.0 → 100.0" in audit.detail + + +# ── История решений, коды, карточки, веса ── + +def test_repeated_decision_keeps_history_and_last_one_applies(): + report = report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)]) + + dismissed = review_finding(report, 0, review(FindingDecision.DISMISSED)) + confirmed = review_finding(dismissed, 0, FindingReview( + decision=FindingDecision.CONFIRMED, reason="перепроверил запись", author="head", at=AT, + )) + + reviews = confirmed["findings"][0]["reviews"] + assert [(item["decision"], item["author"], item["reason"]) for item in reviews] == [ + ("dismissed", "teacher", "доклад пришёл по рации"), + ("confirmed", "head", "перепроверил запись"), + ] + assert confirmed["score_reviewed"] == 0.0, "действует последнее решение" + + +def test_report_saved_with_single_review_reads_as_history(): + legacy = dds_finding(ErrorCode.D6).model_dump(mode="json") + legacy["review"] = review(FindingDecision.DISMISSED).model_dump(mode="json") + + finding = Finding.model_validate(legacy) + + assert [item.decision for item in finding.reviews] == [FindingDecision.DISMISSED] + assert finding.review.decision is FindingDecision.DISMISSED + + +def test_call_code_in_handoff_penalizes_call_metric_not_instructor_metric(): + """Связка 112 → ДДС: E1 без карточки проваливает метрику 112, а не добавляет свою.""" + call = Metric(key="checklist_completeness", title="Полнота опроса", fact="все", norm="все", + passed=True, weight=1.0) + report = report_with([call, dds_metric("dds_progress", True)], []) + manual = Finding(code=ErrorCode.E1, source=FindingSource.INSTRUCTOR, summary="E1", + fact="не уточнён подъезд", norm="уточнить подъезд", author="teacher", at=AT) + + updated = add_finding(report, manual) + + assert [metric["passed"] for metric in updated["metrics"]] == [False, True] + assert INSTRUCTOR_METRIC not in {metric["key"] for metric in updated["metrics"]} + assert updated["score_reviewed"] == 50.0 + + +def test_codes_and_card_scores_follow_the_review(): + card = {"card_id": str(uuid4()), "scenario_id": "s", "score_auto": 50.0, "reply_text": "", + "metrics": [dds_metric("dds_primary", True).model_dump(mode="json"), + dds_metric("dds_progress", False).model_dump(mode="json")], + "findings": [dds_finding(ErrorCode.D6).model_dump(mode="json")]} + report = {**report_with([dds_metric("dds_primary", True), dds_metric("dds_progress", False)], + [dds_finding(ErrorCode.D6)]), + "summary": {"hints": 2, "codes": {"D6": 1}}, "card_results": [card]} + report["full_report"]["card_results"] = [card] + + dismissed = review_finding(report, 0, review(FindingDecision.DISMISSED)) + + assert dismissed["summary"] == {"hints": 2, "codes": {}}, "снятая отметка не ошибка группы" + for cards in (dismissed["card_results"], dismissed["full_report"]["card_results"]): + assert cards[0]["score_auto"] == 50.0 + assert cards[0]["score_reviewed"] == 100.0 + assert cards[0]["metrics"][1]["passed"] is True + assert cards[0]["findings"][0]["reviews"][-1]["decision"] == "dismissed" + + manual = Finding(code=ErrorCode.D4, source=FindingSource.INSTRUCTOR, summary="D4", + fact="отказ без причины", norm="причина обязательна", card=1, + author="teacher", at=AT) + added = add_finding(dismissed, manual) + assert added["summary"]["codes"] == {"D4": 1} + assert added["card_results"][0]["score_reviewed"] == pytest.approx(66.7) + + +def test_instructor_metric_weight_comes_from_lesson_weights(): + report = report_with([dds_metric("dds_primary", True)], []) + report["full_report"]["criteria"]["score_weights"] = {INSTRUCTOR_METRIC: 3.0} + manual = Finding(code=ErrorCode.D4, source=FindingSource.INSTRUCTOR, summary="D4", + fact="отказ без причины", norm="причина обязательна", card=1, + author="teacher", at=AT) + + updated = add_finding(report, manual) + + assert updated["metrics"][-1]["weight"] == 3.0 + assert updated["score_reviewed"] == 25.0 + + +def test_lesson_criteria_accept_instructor_metric_weight(): + from app.domain.events import LessonCriteria + + assert LessonCriteria(score_weights={INSTRUCTOR_METRIC: 2.0}).score_weights == { + INSTRUCTOR_METRIC: 2.0} + + +def test_repeated_manual_finding_with_same_key_is_added_once(): + report = report_with([dds_metric("dds_primary", True)], []) + manual = Finding(code=ErrorCode.D4, source=FindingSource.INSTRUCTOR, summary="D4", + fact="отказ без причины", norm="причина обязательна", card=1, + author="teacher", at=AT, client_id=uuid4()) + + once = add_finding(report, manual) + twice = add_finding(once, manual) + + assert len(twice["findings"]) == 1 + assert twice["score_reviewed"] == once["score_reviewed"] == 50.0 + + +def test_pdf_shows_score_after_review(monkeypatch): + from reportlab import platypus + + from app.scoring.export import to_pdf + from tests.test_report_export import sample_report + + texts: list[str] = [] + + class Recorded(platypus.Paragraph): + def __init__(self, text, *args, **kwargs): + texts.append(text) + super().__init__(text, *args, **kwargs) + + # Абзацы перехватываются до вёрстки: извлечь текст из PDF нечем без poppler. + monkeypatch.setattr(platypus, "Paragraph", Recorded) + assert to_pdf(sample_report().model_copy(update={"score_reviewed": 91.5})).startswith(b"%PDF-") + + assert "После разбора отметок: 91.5" in texts + + +# ── HTTP: связка 112 → ДДС, идемпотентность, занятие из истории ── + +def test_call_code_with_queue_card_is_refused_in_handoff(lesson): + browser, state, store = lesson + commits = len(store.commits) + state.score["full_report"]["exercise"] = "call" + + refused = browser.post(f"/api/sessions/{state.session_id}/findings", + json={"code": "E1", "fact": "факт", "norm": "норма", "card": 1}) + + assert refused.status_code == 422 + assert refused.json()["detail"] == "call_code_without_card" + assert len(store.commits) == commits + + +def test_repeated_add_request_does_not_add_second_finding(lesson): + browser, state, store = lesson + body = {"code": "D5", "fact": "нет адресата", "norm": "назвать адресата", "card": 1, + "client_id": str(uuid4())} + + first = browser.post(f"/api/sessions/{state.session_id}/findings", json=body) + commits = len(store.commits) + second = browser.post(f"/api/sessions/{state.session_id}/findings", json=body) + + assert first.status_code == second.status_code == 200 + assert len(second.json()["findings"]) == len(first.json()["findings"]) + assert second.json()["score_reviewed"] == first.json()["score_reviewed"] + assert not [record for _, batch in store.commits[commits:] for record in batch], ( + "повтор ничего не пишет") + + +def test_archived_lesson_is_reviewed_through_http(monkeypatch): + """Занятия нет в памяти узла: решение идёт через commit_archived, ответ — по строке после записи.""" + from types import SimpleNamespace + + from app.session.pg_store import PostgresSessionStore + from tests.test_report_export import sample_report + + archived = sample_report().model_dump(mode="json") + archived["findings"] = [dds_finding(ErrorCode.D6).model_dump(mode="json")] + archived["metrics"] = [dds_metric("dds_progress", False).model_dump(mode="json")] + archived.update(score_final=0.0, overridden_by=None, override_comment=None, score_auto=0.0) + session_id = archived["session_id"] + session_row = SimpleNamespace(id=session_id, trainee_id=uuid4(), owner_login="dev") + score_row = SimpleNamespace( + session_id=session_id, score_auto=0.0, score_final=0.0, + report={"score_auto": 0.0, "summary": {"codes": {"D6": 1}}, + "findings": archived["findings"], "metrics": archived["metrics"], + "full_report": archived}, + ) + + class FakeDb: + def __init__(self): + self.added = [] + self.commits = 0 + + async def get(self, _model, _key): + return session_row + + async def scalar(self, _statement): + return score_row + + def add(self, row): + self.added.append(row) + + async def flush(self): + pass + + async def rollback(self): + pass + + async def commit(self): + self.commits += 1 + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + + db = FakeDb() + + async def fake_session(): + yield db + + monkeypatch.setattr(hub, "store", PostgresSessionStore(lambda: db)) + monkeypatch.setattr(hub, "get", lambda _session_id: None) + monkeypatch.setitem(app.dependency_overrides, sessions.optional_session, fake_session) + with TestClient(app) as browser: + browser.post("/api/auth/dev-token") + hub.store = PostgresSessionStore(lambda: db) + response = browser.post(f"/api/sessions/{session_id}/findings/0/review", + json={"decision": "dismissed", "reason": "доклад по рации"}) + + assert response.status_code == 200, response.text + body = response.json() + assert body["score_reviewed"] == 100.0 and body["score_final"] == 100.0 + assert body["findings"][0]["reviews"][-1]["reason"] == "доклад по рации" + assert db.commits == 1, "решение и аудит — один commit" + assert score_row.score_final == 100.0 + assert score_row.report["summary"]["codes"] == {} + audit = db.added[0] + assert audit.action == "finding.review" and audit.actor == "dev" + assert "D6 dismissed" in audit.detail and "доклад по рации" in audit.detail + + +def test_finding_review_is_not_saved_when_audit_fails(postgres_access): + """Fail-closed: строка аудита не записалась — решения и нового балла тоже нет.""" + from sqlalchemy import select + + from app.db.base import get_sessionmaker + from app.db.models import AuditLog, Score + from app.domain.events import Exercise, SessionMode + from app.session.pg_store import PostgresSessionStore + from app.session.state import SessionState + from app.session.store import LessonRequest, ScoreCalculated + + store = PostgresSessionStore(get_sessionmaker()) + session_id = uuid4() + report = report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)]) + # actor в audit_log — String(80): длинный логин роняет INSERT аудита + # на стороне PostgreSQL, а Score меняется в той же транзакции. + broken = FindingReview(decision=FindingDecision.DISMISSED, reason="доклад по рации", + author="x" * 200, at=AT) + + async def scenario(): + def build(_identity): + return SessionState(session_id=session_id, scenario_id="fire-apartment-l2", + scenario_title="Пожар", level="L2", mode=SessionMode.TRAINING, + exercise=Exercise.DDS) + + await store.open(LessonRequest( + session_id=session_id, scenario_id="fire-apartment-l2", mode="training", + trainee_name=None, trainee_id=None, owner_login="teacher", backend_node_id=None, + ), build) + await store.commit_archived(session_id, [ScoreCalculated(50.0, report)]) + with pytest.raises(Exception): + await store.commit_archived(session_id, [FindingReviewed( + index=0, role="instructor", review=broken, + )]) + async with get_sessionmaker()() as db: + score = await db.scalar(select(Score).where(Score.session_id == session_id)) + audit = await db.scalar(select(AuditLog).where( + AuditLog.object_id == str(session_id), AuditLog.action == "finding.review", + )) + return score, audit + + score, audit = asyncio.run(scenario()) + assert audit is None + assert score.score_final == 50.0 + assert score.report["findings"][0].get("reviews", []) == [] + assert "score_reviewed" not in score.report + + +def test_archived_lesson_review_through_http_in_postgres(postgres_access, monkeypatch): + """Тот же маршрут на настоящей PostgreSQL: ответ перечитан после commit под блокировкой.""" + from sqlalchemy import select + + from app.db.base import get_sessionmaker + from app.db.models import AuditLog + from app.domain.events import Exercise, SessionMode + from app.session.pg_store import PostgresSessionStore + from app.session.state import SessionState + from app.session.store import LessonRequest, ScoreCalculated + from tests.test_report_export import sample_report + + session_id = uuid4() + archived = sample_report().model_dump(mode="json") + archived.update(session_id=str(session_id), score_final=0.0, score_auto=0.0, + overridden_by=None, override_comment=None, + findings=[dds_finding(ErrorCode.D6).model_dump(mode="json")], + metrics=[dds_metric("dds_progress", False).model_dump(mode="json")]) + report = {"score_auto": 0.0, "summary": {"codes": {"D6": 1}}, + "findings": archived["findings"], "metrics": archived["metrics"], + "full_report": archived} + store = PostgresSessionStore(get_sessionmaker()) + + async def prepare(): + def build(_identity): + return SessionState(session_id=session_id, scenario_id="fire-apartment-l2", + scenario_title="Пожар", level="L2", mode=SessionMode.TRAINING, + exercise=Exercise.DDS) + + await store.open(LessonRequest( + session_id=session_id, scenario_id="fire-apartment-l2", mode="training", + trainee_name=None, trainee_id=None, owner_login="dev", backend_node_id=None, + ), build) + await store.commit_archived(session_id, [ScoreCalculated(0.0, report)]) + + async def audit(): + async with get_sessionmaker()() as db: + return await db.scalar(select(AuditLog).where( + AuditLog.object_id == str(session_id), AuditLog.action == "finding.review")) + + monkeypatch.setattr(hub, "get", lambda _session_id: None) + # Пул соединений привязан к циклу событий: подготовка и проверка идут + # в цикле того же TestClient, что и запрос. + with TestClient(app) as browser: + browser.post("/api/auth/dev-token") + previous, hub.store = hub.store, store + try: + browser.portal.call(prepare) + response = browser.post(f"/api/sessions/{session_id}/findings/0/review", + json={"decision": "dismissed", "reason": "доклад по рации"}) + row = browser.portal.call(audit) + finally: + hub.store = previous + + assert response.status_code == 200, response.text + assert response.json()["score_final"] == 100.0 + assert response.json()["findings"][0]["reviews"][-1]["reason"] == "доклад по рации" + assert row is not None and "доклад по рации" in row.detail diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 836ce2d..50ac40e 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -21,7 +21,7 @@ from app.session.hub import hub @pytest.fixture(scope="module") -def client(): +def client(template_caller): # Доступность базы проверяется подключением к порту: синхронного драйвера # в проекте нет, и попытка проверить им даёт ложный пропуск теста. import socket diff --git a/backend/tests/test_report_export.py b/backend/tests/test_report_export.py index 9a87484..0ee91f4 100644 --- a/backend/tests/test_report_export.py +++ b/backend/tests/test_report_export.py @@ -68,6 +68,7 @@ def test_csv_contains_sections_and_blocks_formula_injection(): assert any(row[0] == "Занятие" and row[3] == "'=1+1" for row in rows) assert any(row[0] == "Ошибки" and row[3] == "'+HYPERLINK(\"x\")" for row in rows) assert any(row[0] == "Самооценка" and row[3] == "'@SUM(1,2)" for row in rows) + assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "45", ""] in rows assert _cell(" =cmd") == "' =cmd" assert _cell("\tОбычный текст") == "'\tОбычный текст" @@ -89,6 +90,7 @@ def test_pdf_supports_cyrillic_and_spans_pages(tmp_path): assert "Отчёт по учебному занятию" in extracted assert "Адрес должен быть уточнён" in extracted assert "Заявитель сообщает о дыме" in extracted + assert "Лимит реакции на доклад бригады" in extracted def test_certificate_pdf_contains_saved_result(tmp_path): diff --git a/backend/tests/test_route_auth_contract.py b/backend/tests/test_route_auth_contract.py index 5c9e5d0..587e0d2 100644 --- a/backend/tests/test_route_auth_contract.py +++ b/backend/tests/test_route_auth_contract.py @@ -12,13 +12,14 @@ from pathlib import Path API_ROOT = Path(__file__).parents[1] / "app" / "api" # Public by design: credential entry/session bootstrap and the non-sensitive -# incident classification dictionary. dev-token has its own fail-closed flag +# incident classification dictionary and service catalog. dev-token has its own fail-closed flag # and remains hidden in production. PUBLIC_HTTP_ROUTES = { ("auth.py", "post", "/login"), ("auth.py", "post", "/dev-token"), ("http/ekp.py", "get", "/groups"), ("http/ekp.py", "get", "/signs"), + ("http/ekp.py", "get", "/services"), } # Routes that centralize ownership + authentication checks in a shared helper. @@ -83,6 +84,8 @@ HTTP_ROLE_GATE_POLICY = { ("http/sessions.py", "get", "/{session_id}/checklist"): ((),), ("http/sessions.py", "get", "/{session_id}/recording.wav"): (("INSTRUCTOR", "TRAINEE"),), ("http/sessions.py", "patch", "/{session_id}/report"): (("INSTRUCTOR",),), + ("http/sessions.py", "post", "/{session_id}/findings/{index}/review"): (("INSTRUCTOR",),), + ("http/sessions.py", "post", "/{session_id}/findings"): (("INSTRUCTOR",),), ("http/sessions.py", "get", ""): ((),), ("http/trainees.py", "get", "/{trainee_id}/certificate.pdf"): ((),), ("http/trainees.py", "get", ""): (("ADMIN", "INSTRUCTOR"),), diff --git a/backend/tests/test_service_catalog.py b/backend/tests/test_service_catalog.py new file mode 100644 index 0000000..f6c7a0d --- /dev/null +++ b/backend/tests/test_service_catalog.py @@ -0,0 +1,167 @@ +"""Каталог служб из «СЛУЖБЫ 112» (lct-38). + +Каталог только расширяет ручной выбор служб в карточке. Список оповещения +по-прежнему считается по классификатору, эталоны сценариев от каталога не зависят. +""" + +import asyncio +import json +from pathlib import Path +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from pydantic import ValidationError + +from app.api.ws import call as call_ws +from app.domain import ekp +from app.domain.events import ErrorEvent, KioPatchIn, KioPatchOut, PatchSource, SessionMode +from app.domain.kio import EDITABLE_KIO_FIELDS, KIO, PatchRejected, apply_patch +from app.main import app +from app.scenarios.loader import load_file +from app.scoring.card import evaluate_card +from app.session.hub import hub +from app.session.state import SessionState +from app.session.store import MemorySessionStore + + +def test_catalog_keeps_all_209_services_with_attribution_and_source(): + data = ekp.catalog() + + assert len(data.services) == 209 + assert len({item.name for item in data.services}) == 209 + assert sum(item.service_level == "ДДС" for item in data.services) == 157 + assert "ded466988f505082922aacac9fff32b777d1279a" in data.transcription + assert data.license.startswith("MIT") + assert all(item.source_reference.startswith("СЛУЖБЫ 112.docx#word/media/image") + for item in data.services) + + +@pytest.mark.parametrize("name", [ + 'Служба 101 (ГУ МЧС России по г.Москве, ГКУ "Пожарно спасательный центр" ОДС)', + 'Служба 104 (АО "МОСГАЗ" Диспетчерское управление)', + "ЦЭМП", + 'ЦОДД (ГКУ "Центр организации дорожного движения")', + 'ЭВАЖД (ГБУ "Учреждение по эксплуатации высотных административных и жилых домов")', +]) +def test_rows_checked_against_our_screenshots_are_verbatim(name): + assert name in ekp.catalog_names() + + +def test_classifier_services_and_notify_lists_are_untouched(): + """Индексы служб классификатора — основа списков оповещения 96 сценариев.""" + raw = json.loads(ekp.DATA.read_text(encoding="utf-8")) + assert [item.name for item in ekp.services()] == [item["name"] for item in raw["services"]] + assert len(ekp.services()) == 61 + + +def test_operator_adds_catalog_services_without_changing_the_notify_list(): + assert "notify_extra" in EDITABLE_KIO_FIELDS + card = KIO(notify=["Служба 101"]) + + updated = apply_patch(card, {"notify_extra": ["ФСБ", "ЦЭМП", "ФСБ"], "notify": []}) + + assert updated.notify_extra == ["ФСБ", "ЦЭМП"] + assert updated.notify == ["Служба 101"], "автоматический список оператор не правит" + with pytest.raises(PatchRejected): + apply_patch(card, {"notify_extra": ["Служба, которой нет"]}) + with pytest.raises(ValidationError): + apply_patch(card, {"notify_extra": [item.name for item in ekp.catalog().services[:21]]}) + + +def test_service_already_in_notify_is_not_added_twice(): + """ЦЭМП, ФСБ, МОЭК, Метро, Мосгортранс есть и в каталоге, и в классификаторе.""" + card = KIO(notify=["ЦЭМП"]) + + updated = apply_patch(card, {"notify_extra": ["ЦЭМП", "ФСБ"]}) + + assert updated.notify_extra == ["ФСБ"] + + +def test_saved_card_with_renamed_service_still_loads(): + """Каталог проверяется на входе kio.patch: правка названия в каталоге + не должна ломать восстановление снимков и сданных карточек.""" + card = KIO.model_validate({"notify_extra": ["Прежнее название службы"]}) + + assert card.notify_extra == ["Прежнее название службы"] + + +def test_added_services_do_not_change_the_score_against_the_reference(): + library = Path(__file__).resolve().parents[2] / "scenarios" + scenario = load_file(library / "fire-apartment-l2.yaml", library) + card = KIO(address="улица Ленина, 14", description="горит балкон") + + plain = evaluate_card(scenario, card) + extended = evaluate_card(scenario, card.model_copy(update={"notify_extra": ["ФСБ"]})) + + assert [(m.key, m.passed) for m in plain.metrics] == [(m.key, m.passed) for m in extended.metrics] + assert plain.score == extended.score + + +def test_catalog_is_served_like_the_classifier(): + with TestClient(app) as client: + response = client.get("/api/ekp/services") + + assert response.status_code == 200 + body = response.json() + assert len(body["services"]) == 209 + assert {"id", "name", "organization", "service_level", "source_reference"} <= body["services"][0].keys() + + +@pytest.fixture +def lesson(monkeypatch): + sent = [] + monkeypatch.setattr(hub, "store", MemorySessionStore()) + monkeypatch.setattr(hub, "to_trainee", lambda _session_id, event: sent.append(event)) + monkeypatch.setattr(hub, "to_observers", lambda *_args: None) + state = SessionState(session_id=uuid4(), scenario_id="test", scenario_title="Тест", + level="L1", mode=SessionMode.TRAINING) + hub.register(state) + + def patch(fields): + sent.clear() + + async def run(): + async with hub.operation(state.session_id): + await call_ws._handle(state.session_id, state, KioPatchIn(fields=fields)) + + asyncio.run(run()) + return list(sent) + + return state, patch + + +def test_unknown_service_in_kio_patch_is_refused_without_failing_the_lesson(lesson): + state, patch = lesson + + sent = patch({"notify_extra": ["Служба, которой нет"]}) + assert isinstance(sent[0], ErrorEvent) and "не принято" in sent[0].message + assert state.kio.notify_extra == [] + + sent = patch({"notify_extra": ["ФСБ"]}) + assert state.kio.notify_extra == ["ФСБ"] + + +def test_refused_kio_patch_returns_server_values_to_drop_pending(lesson): + """Пакет отклоняется целиком — фронт должен получить серверные значения + всех его полей с `auto`, иначе pending висит до следующего kio.state.""" + state, patch = lesson + patch({"address": "улица Ленина, 14", "notify_extra": ["ФСБ"]}) + + sent = patch({"address": "Тверская, 1", "fire.floors": 5, "notify_extra": ["ФСБ", "Служба, которой нет"]}) + + assert isinstance(sent[0], ErrorEvent) + assert isinstance(sent[1], KioPatchOut) and sent[1].source is PatchSource.AUTO + assert sent[1].fields == {"address": "улица Ленина, 14", "fire.floors": None, "notify_extra": ["ФСБ"]} + assert state.kio.address == "улица Ленина, 14" + + +def test_cleaned_notify_extra_is_sent_back_as_auto(lesson): + state, patch = lesson + state.kio = state.kio.model_copy(update={"notify": ["ЦЭМП"]}) + + sent = patch({"notify_extra": ["ЦЭМП", "ФСБ", "ФСБ"]}) + + auto = [event for event in sent if isinstance(event, KioPatchOut) and event.source is PatchSource.AUTO] + assert auto and auto[-1].fields["notify_extra"] == ["ФСБ"] + assert state.kio.notify_extra == ["ФСБ"] diff --git a/backend/tests/test_session_checkpoint.py b/backend/tests/test_session_checkpoint.py index d5842e8..e572a22 100644 --- a/backend/tests/test_session_checkpoint.py +++ b/backend/tests/test_session_checkpoint.py @@ -12,12 +12,13 @@ from app.domain.events import ( SessionMode, ) from app.domain.kio import KIO -from app.domain.statuses import PhoneCallPending, ServiceStatus +from app.domain.statuses import PhoneCallPending, PhoneReportRecord, ServiceStatus from app.domain.taxonomy import ErrorCode, Finding, FindingSource from app.domain.timers import TimerCode from app.scenarios.loader import load_file from app.session.checkpoint import dump_state, load_state from app.session.dds import prepare_handoff_queue, prepare_queue +from app.session.finish import score_dds_card from app.session.state import DdsCardRecord, SessionState, now_utc LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" @@ -85,6 +86,38 @@ def test_active_dds_session_round_trips_without_losing_work(): assert timer.limit_ms == 45_000 +def test_report_reaction_keeps_times_and_limit_after_restore(): + before = dds_state() + before.criteria.dds_report_reaction_limit_seconds = 20 + card = before.desk.active + service = before.card_services(card)[0] + card.phone_reports.append(PhoneReportRecord( + service=service, crew=card.crew_selected, phase="dispatched", + text="Бригада выехала", at=now_utc(), + )) + card.set_service_status(service, ServiceStatus.RESPONDING, + "Основание: доклад.\nСведения: бригада выехала.") + card.phone_reports[0].at -= timedelta(seconds=70) + + restored = load_state(dump_state(before), now_utc()) + + assert restored.criteria.dds_report_reaction_limit_seconds == 20 + assert restored.desk.active.phone_reports == card.phone_reports + + def reaction(state): + result = score_dds_card(state, state.desk.active) + metric = next(item for item in result.metrics if item.key == "dds_report_reaction") + findings = [item for item in result.findings if item.metric_key == "dds_report_reaction"] + return metric, findings + + metric, findings = reaction(restored) + assert not metric.passed + assert metric.norm == "отметить статус по докладу за 20 с" + assert "70 с (+50 с сверх норматива)" in metric.fact + assert [item.code for item in findings] == [ErrorCode.D6] + assert reaction(restored) == reaction(before), "оценка после восстановления та же" + + def completed_card() -> DdsCardRecord: return DdsCardRecord( card_id=uuid4(), diff --git a/backend/tests/test_session_signals.py b/backend/tests/test_session_signals.py new file mode 100644 index 0000000..cc4ba7a --- /dev/null +++ b/backend/tests/test_session_signals.py @@ -0,0 +1,517 @@ +"""Колонка сигналов реестра: очередь, повторные отказы, курсант не на связи.""" + +import asyncio +import time +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.engine.result import IteratorResult, SimpleResultMetaData +from starlette.websockets import WebSocketDisconnect + +from app.api.auth import Principal +from app.api.http import sessions as sessions_http +from app.api.ws import session as session_ws +from app.config import get_settings +from app.db.models import Session +from app.domain.events import Exercise, SessionMode +from app.domain.roles import Role +from app.main import app +from app.session.checkpoint import dump_state, load_state +from app.session import hub as hub_module +from app.session.hub import SessionHub, hub +from app.session.state import SessionState +from app.session.store import MemorySessionStore + +POOL = ["fire-apartment-l2", "t01-1-fire-container"] + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setenv("DEV_AUTH_BYPASS", "true") + get_settings.cache_clear() + + async def audit_override(*_args, **_kwargs): + return None + + async def optional_session_override(): + yield None + + monkeypatch.setattr(sessions_http, "audit_required", audit_override) + monkeypatch.setitem( + app.dependency_overrides, sessions_http.optional_session, optional_session_override + ) + try: + with TestClient(app) as test_client: + test_client.post("/api/auth/dev-token") + hub.store = MemorySessionStore() + before = set(hub._sessions) + try: + yield test_client + finally: + for session_id in set(hub._sessions) - before: + hub.drop(session_id) + finally: + get_settings.cache_clear() + + +def wait_for(predicate, timeout=3): + end = time.monotonic() + timeout + while time.monotonic() < end: + value = predicate() + if value: + return value + time.sleep(0.02) + raise AssertionError("состояние не обновилось") + + +def read_until(socket, wanted): + received = [] + for _ in range(20): + event = socket.receive_json() + received.append(event["type"]) + if event["type"] == wanted: + return event + raise AssertionError(f"событие {wanted} не пришло; получены: {received}") + + +def start_two_card_dds(client, scenario_ids=POOL): + session_id = uuid4() + context = client.websocket_connect(f"/ws/control/{session_id}") + control = context.__enter__() + control.send_json({ + "type": "scenario.start", + "scenario_id": POOL[0], + "trainee": "Иванов", + "mode": "training", + "exercise": "dds", + "random_scenario_ids": scenario_ids, + }) + wait_for(lambda: hub.get(session_id)) + return session_id, control + + +def start_card_handoff(client): + session_id = uuid4() + context = client.websocket_connect(f"/ws/control/{session_id}") + control = context.__enter__() + control.send_json({ + "type": "scenario.start", "scenario_id": POOL[0], "trainee": "Иванов", + "mode": "training", "exercise": "card", "handoff_to_dds": True, + "scenario_ids": POOL, + }) + wait_for(lambda: hub.get(session_id)) + return session_id, control + + +def start_call(client): + session_id = uuid4() + context = client.websocket_connect(f"/ws/control/{session_id}") + control = context.__enter__() + control.send_json({ + "type": "scenario.start", "scenario_id": POOL[0], "trainee": "Иванов", + "mode": "training", "exercise": "call", + }) + wait_for(lambda: hub.get(session_id)) + return session_id, control + + +def row_for(client, session_id): + rows = client.get("/api/sessions/active").json() + return next(item for item in rows if item["session_id"] == str(session_id)) + + +def signal_kinds(row): + return {signal["kind"] for signal in row["signals"]} + + +def assigned_trainee(state, monkeypatch): + state.trainee_id = uuid4() + who = Principal(login="курсант", full_name="Курсант", role=Role.TRAINEE, + trainee_id=state.trainee_id) + monkeypatch.setattr(session_ws, "principal_of", lambda _ws: who) + + +def registry_time(monkeypatch, since, seconds): + class Clock(datetime): + @classmethod + def now(cls, tz=None): + return since + timedelta(seconds=seconds) + + monkeypatch.setattr(sessions_http, "datetime", Clock) + + +def test_signal_backlog_when_queue_reaches_threshold(client, monkeypatch): + monkeypatch.setenv("SIGNAL_BACKLOG_THRESHOLD", "2") + get_settings.cache_clear() + session_id, control = start_two_card_dds(client) + try: + state = hub.get(session_id) + assert len(state.desk.cards) == 2, "оба билета должны прийти сразу без интервала" + row = row_for(client, session_id) + assert row["dds_open_cards"] == 2 + assert "backlog" in signal_kinds(row) + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) + + +def test_declined_card_leaves_backlog(client, monkeypatch): + monkeypatch.setenv("SIGNAL_BACKLOG_THRESHOLD", "2") + get_settings.cache_clear() + session_id, control = start_two_card_dds(client) + try: + state = hub.get(session_id) + first_card = state.desk.ordered()[0] + with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station: + read_until(station, "station.state") + station.send_json({ + "type": "card.status", "service": state.card_services(first_card)[0], + "status": "declined", "comment": "не наш адрес, передано в УК", + }) + read_until(station, "station.state") + row = row_for(client, session_id) + assert row["dds_open_cards"] == 2, "отклонённая карточка ждёт «Следующей» на пульте" + assert "backlog" not in signal_kinds(row) + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) + + +def test_signal_refusals_after_two_consecutive_declines(client): + session_id, control = start_two_card_dds(client) + try: + state = hub.get(session_id) + first_card, second_card = state.desk.ordered() + with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station: + read_until(station, "station.state") + first_service = state.card_services(first_card)[0] + station.send_json({ + "type": "card.status", "service": first_service, "status": "declined", + "comment": "не наш адрес, передано в УК", + }) + read_until(station, "station.state") + + row = row_for(client, session_id) + assert "refusals" not in signal_kinds(row), "одного отказа недостаточно для сигнала" + + station.send_json({"type": "card.open", "card_id": str(second_card.card_id)}) + read_until(station, "station.state") + second_service = state.card_services(second_card)[0] + station.send_json({ + "type": "card.status", "service": second_service, "status": "declined", + "comment": "не наша территория, передано в ОМВД", + }) + read_until(station, "station.state") + + row = row_for(client, session_id) + assert state.consecutive_refusals == 2 + assert "refusals" in signal_kinds(row) + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) + + +def test_acceptance_clears_refusal_streak(client): + session_id, control = start_two_card_dds( + client, [*POOL, "t20-2-stroke"], + ) + try: + state = hub.get(session_id) + with client.websocket_connect(f"/ws/station/{session_id}") as station: + read_until(station, "station.state") + for card in state.desk.ordered()[:2]: + station.send_json({"type": "card.open", "card_id": str(card.card_id)}) + read_until(station, "station.state") + station.send_json({ + "type": "card.status", "service": state.card_services(card)[0], + "status": "declined", "comment": "Не наша территория, передано дежурному", + }) + read_until(station, "station.state") + assert state.consecutive_refusals == 2 + third = state.desk.ordered()[2] + station.send_json({"type": "card.open", "card_id": str(third.card_id)}) + read_until(station, "station.state") + station.send_json({ + "type": "card.status", "service": state.card_services(third)[0], + "status": "accepted", "comment": "Карточка принята диспетчером", + }) + read_until(station, "station.state") + assert state.consecutive_refusals == 0 + assert "refusals" not in signal_kinds(row_for(client, session_id)) + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) + + +def test_signal_offline_when_station_socket_is_closed_past_the_window(client, monkeypatch): + session_id, control = start_two_card_dds(client) + try: + state = hub.get(session_id) + assigned_trainee(state, monkeypatch) + with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station: + read_until(station, "station.state") + assert hub.station_connected(session_id) + assert not hub.station_connected(session_id) + disconnected_at = state.socket_last_seen_at + assert disconnected_at is not None + window = get_settings().signal_offline_window_seconds + registry_time(monkeypatch, disconnected_at, window - 1) + assert "offline" not in signal_kinds(row_for(client, session_id)) + registry_time(monkeypatch, disconnected_at, window + 1) + row = row_for(client, session_id) + assert "offline" in signal_kinds(row) + assert state.socket_last_seen_at == disconnected_at, "GET реестра не должен менять занятие" + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) + + +def test_signal_offline_without_first_connection(client, monkeypatch): + session_id, control = start_two_card_dds(client) + try: + state = hub.get(session_id) + started = state.socket_last_seen_at + assert started is not None + registry_time(monkeypatch, started, get_settings().signal_offline_window_seconds + 1) + assert "offline" in signal_kinds(row_for(client, session_id)) + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) + + +def test_call_socket_disconnection_triggers_offline(client, monkeypatch): + session_id, control = start_call(client) + try: + state = hub.get(session_id) + assigned_trainee(state, monkeypatch) + with client.websocket_connect(f"/ws/call/{session_id}") as call: + read_until(call, "call.incoming") + assert hub.trainee_connected(session_id) + assert not hub.trainee_connected(session_id) + disconnected_at = state.socket_last_seen_at + registry_time(monkeypatch, disconnected_at, + get_settings().signal_offline_window_seconds + 1) + assert "offline" in signal_kinds(row_for(client, session_id)) + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) + + +def test_teacher_socket_does_not_hide_offline(client, monkeypatch): + session_id, control = start_two_card_dds(client) + try: + state = hub.get(session_id) + with client.websocket_connect(f"/ws/station/{session_id}") as station: + read_until(station, "station.state") + assert not hub.station_connected(session_id) + registry_time(monkeypatch, state.socket_last_seen_at, + get_settings().signal_offline_window_seconds + 1) + assert "offline" in signal_kinds(row_for(client, session_id)) + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) + + +def test_last_of_two_trainee_sockets_starts_offline_window(client, monkeypatch): + session_id, control = start_two_card_dds(client) + try: + state = hub.get(session_id) + assigned_trainee(state, monkeypatch) + with client.websocket_connect(f"/ws/station/{session_id}") as first: + read_until(first, "station.state") + with client.websocket_connect(f"/ws/station/{session_id}") as second: + read_until(second, "station.state") + assert hub.station_connected(session_id) + registry_time(monkeypatch, state.socket_last_seen_at, + get_settings().signal_offline_window_seconds + 1) + assert "offline" not in signal_kinds(row_for(client, session_id)) + disconnected_at = state.socket_last_seen_at + registry_time(monkeypatch, disconnected_at, + get_settings().signal_offline_window_seconds + 1) + assert "offline" in signal_kinds(row_for(client, session_id)) + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) + + +def test_offline_window_survives_checkpoint_restore(client, monkeypatch): + session_id, control = start_two_card_dds(client) + try: + state = hub.get(session_id) + assigned_trainee(state, monkeypatch) + with client.websocket_connect(f"/ws/station/{session_id}") as station: + read_until(station, "station.state") + disconnected_at = state.socket_last_seen_at + restored = load_state(dump_state(state), datetime.now(UTC)) + hub.drop(session_id) + asyncio.run(hub._adopt(restored)) + assert restored.socket_last_seen_at == disconnected_at + registry_time(monkeypatch, disconnected_at, + get_settings().signal_offline_window_seconds + 1) + assert "offline" in signal_kinds(row_for(client, session_id)) + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) + + +def test_takeover_starts_window_when_socket_was_open(client, monkeypatch): + session_id, control = start_two_card_dds(client) + try: + state = hub.get(session_id) + assigned_trainee(state, monkeypatch) + with client.websocket_connect(f"/ws/station/{session_id}") as station: + read_until(station, "station.state") + assert state.socket_connected_at_checkpoint + snapshot = dump_state(state) + old_time = state.socket_last_seen_at + takeover_at = old_time + timedelta(minutes=10) + monkeypatch.setattr(hub_module, "now_utc", lambda: takeover_at) + restored = load_state(snapshot, datetime.now(UTC)) + + class CapturingStore(MemorySessionStore): + def __init__(self): + self.saved = None + + async def commit(self, state, _records=()): + self.saved = dump_state(state) + + store = CapturingStore() + local_hub = SessionHub(store) + try: + assert asyncio.run(local_hub._adopt(restored)) + assert restored.socket_last_seen_at == takeover_at + assert not restored.socket_connected_at_checkpoint + assert datetime.fromisoformat( + store.saved["socket_last_seen_at"].replace("Z", "+00:00") + ) == takeover_at + assert store.saved["socket_connected_at_checkpoint"] is False + finally: + local_hub.stop_ticker(session_id) + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) + + +def test_card_handoff_uses_trainee_station_presence(client, monkeypatch): + session_id, control = start_card_handoff(client) + try: + state = hub.get(session_id) + assigned_trainee(state, monkeypatch) + with monkeypatch.context() as clock: + with client.websocket_connect(f"/ws/call/{session_id}") as call: + read_until(call, "card.briefing") + call.send_json({"type": "card.submit"}) + read_until(call, "call.ended") + handoff_at = state.socket_last_seen_at + clock.setattr(hub_module, "now_utc", lambda: handoff_at + timedelta(minutes=1)) + assert state.socket_last_seen_at == handoff_at, "старый call-сокет не сбрасывает окно ДДС" + assert state.exercise.value == "card" and state.dds_phase + assert state.desk.scenarios + with client.websocket_connect(f"/ws/station/{session_id}") as station: + read_until(station, "station.state") + registry_time(monkeypatch, state.socket_last_seen_at, + get_settings().signal_offline_window_seconds + 1) + row = row_for(client, session_id) + assert row["dds_open_cards"] > 0 + assert "offline" not in signal_kinds(row) + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) + + +def test_checkpoint_on_another_node_marks_presence_unknown(client, monkeypatch): + session_id, control = start_two_card_dds(client) + try: + state = hub.get(session_id) + snapshot = dump_state(state) + checkpoint_at = datetime.now(UTC) + hub.drop(session_id) + + row = Session(id=session_id, owner_login="dev", + live_state=snapshot, checkpoint_at=checkpoint_at) + + class CheckpointDB: + async def scalars(self, _query): + return IteratorResult(SimpleResultMetaData(["Session"]), iter([(row,)])).scalars() + + monkeypatch.setattr(sessions_http, "require", lambda _request, _role: + Principal(login="dev", full_name="Преподаватель", + role=Role.INSTRUCTOR)) + rows = asyncio.run(sessions_http.active(None, db=CheckpointDB())) + row = next(item for item in rows if item.session_id == session_id) + assert row.presence_known is False + assert "offline" not in {item.kind for item in row.signals} + finally: + hub.stop_ticker(session_id) + control.__exit__(None, None, None) + + +def test_failed_presence_commit_does_not_leave_connected_socket(): + class BrokenStore(MemorySessionStore): + async def commit(self, _state, _records=()): + raise OSError("test commit failure") + + local_hub = SessionHub(BrokenStore()) + state = local_hub.register(SessionState( + session_id=uuid4(), scenario_id="test", scenario_title="Тест", level="L1", + mode=SessionMode.TRAINING, exercise=Exercise.DDS, + )) + + async def connect(): + async with local_hub.trainee_socket(state.session_id, station=True, trainee=True): + assert local_hub.is_lease_fenced(state.session_id) + + asyncio.run(connect()) + assert not local_hub.station_connected(state.session_id) + + +def test_failed_presence_commit_closes_station_with_fencing_code(client, monkeypatch): + class BrokenStore(MemorySessionStore): + async def commit(self, _state, _records=()): + raise OSError("test commit failure") + + session_id, control = start_two_card_dds(client) + try: + assigned_trainee(hub.get(session_id), monkeypatch) + hub.store = BrokenStore() + with client.websocket_connect(f"/ws/station/{session_id}") as station: + event = station.receive_json() + assert event["type"] == "error" and event["code"] == "internal" + with pytest.raises(WebSocketDisconnect) as closed: + station.receive_json() + assert closed.value.code == 1012 + assert not hub.station_connected(session_id) + finally: + control.__exit__(None, None, None) + + +def test_takeover_retries_failed_presence_checkpoint(): + class FlakyStore(MemorySessionStore): + def __init__(self): + self.commits = 0 + + async def commit(self, _state, _records=()): + self.commits += 1 + if self.commits == 1: + raise OSError("temporary database outage") + + store = FlakyStore() + local_hub = SessionHub(store) + state = SessionState( + session_id=uuid4(), scenario_id="test", scenario_title="Тест", level="L1", + mode=SessionMode.TRAINING, exercise=Exercise.DDS, + socket_last_seen_at=datetime.now(UTC) - timedelta(minutes=10), + socket_connected_at_checkpoint=True, + ) + + async def recover(): + assert not await local_hub._adopt(state) + assert local_hub.get(state.session_id) is None + await local_hub.maintain_lease() + assert local_hub.get(state.session_id) is state + assert store.commits == 2 + await local_hub.shutdown() + + asyncio.run(recover()) diff --git a/backend/tests/test_ws.py b/backend/tests/test_ws.py index fc1a767..793f207 100644 --- a/backend/tests/test_ws.py +++ b/backend/tests/test_ws.py @@ -22,7 +22,7 @@ from app.session.store import MemorySessionStore @pytest.fixture -def client(monkeypatch): +def client(monkeypatch, template_caller): async def audit_in_memory(*_args, **_kwargs): return None diff --git a/frontend/scripts/test-ws-outbox.mjs b/frontend/scripts/test-ws-outbox.mjs index 6e11fd9..516396e 100644 --- a/frontend/scripts/test-ws-outbox.mjs +++ b/frontend/scripts/test-ws-outbox.mjs @@ -74,6 +74,18 @@ socket.receive({ type: "kio.patch", fields: { }, source: "operator" }); socket = reconnect(socket); assert.deepEqual(socket.sent, [], "matching checkpoint echo clears the KIO patch"); +const refused = { type: "kio.patch", fields: { address: "ул. Новая, 3", notify_extra: ["Нет в каталоге"] } }; +call.send(refused); +socket.receive({ type: "kio.patch", fields: { address: patch.fields.address, notify_extra: [] }, source: "auto" }); +socket.sent = []; +call.send({ type: "kio.patch", fields: { floor: "2" } }); +assert.deepEqual(socket.sent.at(-1).fields, { floor: "2" }, + "server values after a refused patch acknowledge it: refused fields are not merged into the next edit"); +socket.receive({ type: "kio.patch", fields: { floor: "2" }, source: "operator" }); +socket = reconnect(socket); +assert.deepEqual(socket.sent, [], "nothing left to replay after refusal and the next echo"); +const refusedCard = applyPatch(edit(empty, "notify_extra", ["Нет в каталоге"]), { notify_extra: [] }, "auto"); +assert.deepEqual(refusedCard.pending, {}, "server value after refusal drops the pending edit"); const edited = edit(empty, "coords", { latitude: 55.7, longitude: 37.6 }); assert.deepEqual(applyPatch(edited, { coords: { longitude: 37.6, latitude: 55.7 } }, "operator").pending, {}); assert.deepEqual(applyState(edited, { coords: { longitude: 37.6, latitude: 55.7 } }).pending, {}); diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx index 5e639b1..cd2fd92 100644 --- a/frontend/src/features/debrief/Debrief.tsx +++ b/frontend/src/features/debrief/Debrief.tsx @@ -39,6 +39,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
Автоматическая оценка{report.score_auto.toFixed(0)} из 100 {report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"} + {report.score_reviewed != null && после разбора отметок: {report.score_reviewed.toFixed(0)}}
Упражнение{report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"} @@ -80,6 +81,9 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: {` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}} {report.exercise === "card" && <>Норматив заполнения КИО — {` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}} + {/* 112 → ДДС приходит как exercise "card": работу ДДС выдают карточки пульта. */} + {(report.exercise === "dds" || Boolean(report.card_results?.length)) && <>; статус по докладу бригады — + {` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`}} {report.exercise && report.exercise !== "dds" && <> ; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку }. @@ -92,7 +96,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: {Boolean(report.card_results?.length) &&

Карточки занятия ({report.card_results!.length})

{report.card_results!.map((card, index) =>
- Карточка {index + 1}: {card.title || card.scenario_id} · {card.score_auto.toFixed(0)} / 100 + Карточка {index + 1}: {card.title || card.scenario_id} · {(card.score_reviewed ?? card.score_auto).toFixed(0)} / 100 + {card.score_reviewed != null && ` после разбора (автооценка ${card.score_auto.toFixed(0)})`} {card.address ? ` · ${card.address}` : ""} {card.managed_service &&

Ответственная служба: {card.managed_service}

}

Ответ диспетчера: {card.reply_text || "не внесён"}

@@ -147,6 +152,10 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: {finding.summary}
{finding.fact}{finding.norm && ` · ${finding.norm}`}
+ {finding.source === "instructor" &&
отметка преподавателя {finding.author ?? ""}
} + {(finding.reviews ?? []).map((review, reviewIndex) =>
+ {review.decision === "dismissed" ? "снята" : "подтверждена"} преподавателем {review.author}: {review.reason} +
)} ))} diff --git a/frontend/src/features/debrief/FindingReview.tsx b/frontend/src/features/debrief/FindingReview.tsx new file mode 100644 index 0000000..ef47f23 --- /dev/null +++ b/frontend/src/features/debrief/FindingReview.tsx @@ -0,0 +1,162 @@ +// Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку. +// +// Балл пересчитывает сервер теми же метриками и весами (lct-35): здесь только +// решение и его причина. Причина обязательна — она остаётся в отчёте и в CSV/PDF. + +import { useRef, useState } from "react"; + +import type { ErrorCode, FindingDecision, SessionReport } from "@/shared/types/generated"; + +const DDS_CODES: ErrorCode[] = ["D1", "D2", "D3", "D4", "D5", "D6"]; +const CALL_CODES: ErrorCode[] = ["E1", "E2", "E3", "E4", "E5", "E6"]; + +async function send(url: string, body: unknown): Promise { + const response = await fetch(url, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await response.json().catch(() => null); + if (!response.ok) { + const detail = data && typeof data === "object" && "detail" in data ? data.detail : response.statusText; + throw new Error(typeof detail === "string" ? detail : "Не удалось сохранить решение."); + } + return data as SessionReport; +} + +export function FindingReview({ report, onChange }: { + report: SessionReport; + onChange: (report: SessionReport) => void; +}) { + const cards = report.card_results ?? []; + const codes = report.exercise === "dds" ? DDS_CODES + : cards.length ? [...CALL_CODES, ...DDS_CODES] : CALL_CODES; + const [reasons, setReasons] = useState>({}); + const [code, setCode] = useState(codes[0]); + // В ДДС все метрики принадлежат карточкам очереди. В связке 112 → ДДС + // E-коды относятся к занятию, а D-код без карточки — к живой карточке звонка. + const [card, setCard] = useState(report.exercise === "dds" && cards.length ? "1" : ""); + const cardChoice = cards.length > 0 && (report.exercise === "dds" || code.startsWith("D")); + // Один ключ на отметку, пока она не сохранена: повтор после сбоя сети + // не добавит её второй раз. + const pendingId = useRef(null); + const [fact, setFact] = useState(""); + const [norm, setNorm] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const base = `/api/sessions/${report.session_id}/findings`; + + async function run(action: () => Promise) { + setBusy(true); + setError(""); + try { + onChange(await action()); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Не удалось сохранить решение."); + } finally { + setBusy(false); + } + } + + function decide(index: number, decision: FindingDecision) { + const reason = (reasons[index] ?? "").trim(); + if (!reason) { + setError("Укажите причину решения по отметке."); + return; + } + void run(async () => { + const updated = await send(`${base}/${index}/review`, { decision, reason }); + setReasons((current) => ({ ...current, [index]: "" })); + return updated; + }); + } + + function addFinding() { + if (!fact.trim() || !norm.trim()) { + setError("Для отметки нужны факт и норма."); + return; + } + pendingId.current ??= crypto.randomUUID(); + const clientId = pendingId.current; + void run(async () => { + const updated = await send(base, { + code, fact: fact.trim(), norm: norm.trim(), card: cardChoice && card ? Number(card) : null, + client_id: clientId, + }); + pendingId.current = null; + setFact(""); + setNorm(""); + return updated; + }); + } + + return ( +
+

Разбор по отметкам

+

+ Снятая отметка не штрафует свою метрику, добавленная — штрафует. Балл пересчитывается + по тем же критериям и весам; автоматическая оценка ({report.score_auto.toFixed(1)}) сохраняется рядом. + {report.score_reviewed != null && ` После разбора: ${report.score_reviewed.toFixed(1)}.`} +

+ + + {report.findings.map((finding, index) => ( + + + + + ))} + {report.findings.length === 0 && } + +
{finding.code} + {finding.summary} +
{finding.fact}{finding.norm && ` · ${finding.norm}`}
+ {(finding.reviews ?? []).map((review, reviewIndex, all) =>
+ {review.decision === "dismissed" ? "Снята" : "Подтверждена"} ({review.author},{" "} + {new Date(review.at).toLocaleString("ru-RU")}): {review.reason} +
)} +
+ setReasons((current) => ({ ...current, [index]: event.target.value }))} /> + + +
+
Автоматических отметок нет.
+ +

Добавить отметку

+
+ + {cardChoice && } + + + +
+ {error &&

{error}

} +
+ ); +} diff --git a/frontend/src/features/instructor/ScoreWeightsEditor.tsx b/frontend/src/features/instructor/ScoreWeightsEditor.tsx index 9f81065..94102ab 100644 --- a/frontend/src/features/instructor/ScoreWeightsEditor.tsx +++ b/frontend/src/features/instructor/ScoreWeightsEditor.tsx @@ -32,6 +32,7 @@ const DDS_METRICS: MetricOption[] = [ { key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." }, { key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." }, { key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." }, + { key: "dds_report_reaction", label: "Реакция на доклад бригады", hint: "Статус по докладу бригады не позже лимита занятия." }, ]; const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [ @@ -39,6 +40,10 @@ const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [ "required_fields", "description_grammar", "card_fill_time", ].includes(key)); const CALL_METRICS = OPERATOR_METRICS.filter(({ key }) => key !== "card_fill_time"); +// Метрика появляется, только когда ручной отметке преподавателя нечего провалить. +const REVIEW_METRICS: MetricOption[] = [ + { key: "instructor_finding", label: "Отметка преподавателя", hint: "Ручная отметка на разборе без связанной метрики (например, D4)." }, +]; export function ScoreWeightsEditor({ exercise, @@ -57,6 +62,7 @@ export function ScoreWeightsEditor({ : exercise === "card_dds" ? [["Карточка КИО", CARD_METRICS], ["Реагирование ДДС", DDS_METRICS]] : [["Опрос 112", CALL_METRICS], ["Реагирование ДДС", DDS_METRICS]]; + groups.push(["Разбор", REVIEW_METRICS]); function setWeight(key: string, raw: string) { const next = { ...values }; diff --git a/frontend/src/features/kio-card/KioCard.tsx b/frontend/src/features/kio-card/KioCard.tsx index 4f70658..6ca9c4b 100644 --- a/frontend/src/features/kio-card/KioCard.tsx +++ b/frontend/src/features/kio-card/KioCard.tsx @@ -1,7 +1,9 @@ // Карточка КИО. Один и тот же компонент рисуется у курсанта, на пульте, // на внешнем мониторе и на АРМ ДДС — там в режиме `readOnly`. -import { useIncidentGroups, useSigns } from "@/shared/api/http"; +import { useState } from "react"; + +import { useIncidentGroups, useServiceCatalog, useSigns } from "@/shared/api/http"; import { type CardState, display } from "./merge"; import { type Field, type FieldGroup, visibleGroups } from "./fields"; @@ -17,6 +19,7 @@ interface Props { export function KioCard({ state, required = [], readOnly = false, referenceArm = false, onChange }: Props) { const dds = display(state, "dds") as string | null; const incidentGroup = display(state, "incident_group") as number | null; + const notify = (display(state, "notify") as string[] | null) ?? []; const groups = visibleGroups(dds); const section = (group: FieldGroup) => (
@@ -36,6 +39,7 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm = readOnly={readOnly || field.readOnly} referenceArm={referenceArm} incidentGroup={incidentGroup} + notify={notify} onChange={onChange} /> ))} @@ -58,7 +62,7 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm = } function Row({ - field, value, pending, required, readOnly, referenceArm, incidentGroup, onChange, + field, value, pending, required, readOnly, referenceArm, incidentGroup, notify, onChange, }: { field: Field; value: unknown; @@ -67,6 +71,7 @@ function Row({ readOnly?: boolean; referenceArm: boolean; incidentGroup: number | null; + notify: string[]; onChange?: (path: string, value: unknown) => void; }) { const empty = value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0); @@ -85,7 +90,7 @@ function Row({ {readOnly ? ( {formatValue(value, field)} ) : ( - + )} @@ -93,12 +98,13 @@ function Row({ } function Input({ - field, value, referenceArm, incidentGroup, onChange, + field, value, referenceArm, incidentGroup, notify, onChange, }: { field: Field; value: unknown; referenceArm: boolean; incidentGroup: number | null; + notify: string[]; onChange?: (path: string, value: unknown) => void; }) { const emit = (next: unknown) => onChange?.(field.path, next); @@ -141,6 +147,9 @@ function Input({ ); } + if (field.kind === "services") { + return emit(next)} />; + } if (field.kind === "signs") { return emit(next)} />; } @@ -170,6 +179,46 @@ function Input({ ); } +/** Как `max_length` у `KIO.notify_extra`. */ +const SERVICES_LIMIT = 20; + +/** Ручное добавление служб из каталога — окно «Добавьте службы» боевого АРМ. + * Список оповещения по признакам остаётся как есть: здесь только добавки. */ +function ServicesPicker({ value, notify, onChange }: { + value: string[]; notify: string[]; onChange: (next: string[]) => void; +}) { + const catalog = useServiceCatalog(); + const [query, setQuery] = useState(""); + const needle = query.trim().toLowerCase(); + // Лимит и службы из списка оповещения — те же правила, что на сервере: + // лишняя служба отклонила бы весь пакет правок. + const full = value.length >= SERVICES_LIMIT; + const found = needle && !full + ? (catalog.data?.services ?? []) + .filter((item) => !value.includes(item.name) && !notify.includes(item.name) + && item.name.toLowerCase().includes(needle)) + .slice(0, 8) + : []; + return ( +
+ {value.length > 0 &&
+ {value.map((name) => + {name} + )} +
} + setQuery(event.target.value)} /> + {found.length > 0 &&
    + {found.map((item) =>
  • + +
  • )} +
} +
+ ); +} + function IncidentGroupPicker({ value, onChange }: { value: number | null; onChange: (next: number | null) => void }) { const page = useIncidentGroups(); return