lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
"""Состояние живой сессии: карточка, транскрипт, таймеры, режим, попытка.
|
|
|
|
|
|
|
|
|
|
|
|
Живёт в памяти процесса — поэтому воркер uvicorn ровно один: с двумя
|
|
|
|
|
|
преподаватель подключился бы к другому процессу, чем курсант, и увидел
|
|
|
|
|
|
пустой экран (docs/arch/STACK.md).
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
import time
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
from dataclasses import dataclass, field
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from datetime import UTC, datetime
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
from typing import Any
|
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.dialog.caller import TemplateCaller
|
|
|
|
|
|
from app.dialog.persona import PersonaState
|
|
|
|
|
|
from app.dialog.slots import SlotMachine
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
from app.domain.events import (
|
|
|
|
|
|
CallEndReason,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
Exercise,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
LessonCriteria,
|
|
|
|
|
|
Metric,
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
Mood,
|
|
|
|
|
|
SessionMode,
|
|
|
|
|
|
SessionSnapshot,
|
|
|
|
|
|
Speaker,
|
|
|
|
|
|
TranscriptEntry,
|
|
|
|
|
|
)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.domain.kio import KIO, ResponseStatus, apply_patch
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
from app.domain.statuses import (
|
|
|
|
|
|
NEXT,
|
2026-09-26 18:12:27 +03:00
|
|
|
|
CardStatus,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
DdsCardSummary,
|
|
|
|
|
|
DdsQueueCard,
|
|
|
|
|
|
PhoneCallPending,
|
|
|
|
|
|
PhoneLineRecord,
|
|
|
|
|
|
PhoneReportRecord,
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
ServiceStatus,
|
|
|
|
|
|
StationSnapshot,
|
|
|
|
|
|
StatusEntry,
|
|
|
|
|
|
card_status,
|
|
|
|
|
|
check,
|
|
|
|
|
|
current,
|
|
|
|
|
|
)
|
2026-09-21 19:56:13 +03:00
|
|
|
|
from app.domain.taxonomy import Finding
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.domain.timers import TimerCode
|
|
|
|
|
|
from app.scenarios.schema import Scenario
|
|
|
|
|
|
from app.scoring.grammar import GrammarAssessment
|
|
|
|
|
|
from app.session.timers import SessionTimers
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def now_utc() -> datetime:
|
|
|
|
|
|
"""Часы серверные. Метрика, посчитанная по часам браузера, недоказуема."""
|
2026-09-24 01:10:49 +03:00
|
|
|
|
return datetime.now(UTC)
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 19:56:13 +03:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class DdsCardRecord:
|
|
|
|
|
|
card_id: UUID
|
|
|
|
|
|
scenario_id: str
|
|
|
|
|
|
reply_text: str
|
|
|
|
|
|
metrics: list[Metric]
|
|
|
|
|
|
findings: list[Finding]
|
|
|
|
|
|
actions: list[dict[str, Any]]
|
|
|
|
|
|
duration_ms: int
|
2026-09-26 18:12:27 +03:00
|
|
|
|
title: str | None = None
|
|
|
|
|
|
address: str | None = None
|
|
|
|
|
|
description: str | None = None
|
|
|
|
|
|
incident_type: str | None = None
|
|
|
|
|
|
victims_count: int | None = None
|
|
|
|
|
|
received_at: datetime | None = None
|
|
|
|
|
|
managed_service: str | None = None
|
|
|
|
|
|
recipient_services: list[str] = field(default_factory=list)
|
2026-09-21 19:56:13 +03:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def score_auto(self) -> float:
|
|
|
|
|
|
total = sum(metric.weight for metric in self.metrics)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
passed = sum(metric.weight * (metric.credit if metric.credit is not None
|
|
|
|
|
|
else float(metric.passed)) for metric in self.metrics)
|
2026-09-21 19:56:13 +03:00
|
|
|
|
return round(100 * passed / total, 1) if total else 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class DdsLiveCard:
|
|
|
|
|
|
"""Изолированное живое состояние одной одновременно выданной карточки."""
|
|
|
|
|
|
|
|
|
|
|
|
original_index: int
|
|
|
|
|
|
scenario: Scenario
|
|
|
|
|
|
kio: KIO
|
|
|
|
|
|
dispatched_card: KIO
|
|
|
|
|
|
dispatched_at: datetime
|
|
|
|
|
|
timers: SessionTimers
|
|
|
|
|
|
bounced_fields: list[str] = field(default_factory=list)
|
|
|
|
|
|
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
|
|
|
|
|
|
status_log: list[StatusEntry] = field(default_factory=list)
|
|
|
|
|
|
crew_selected: str | None = None
|
|
|
|
|
|
crew_assignments: dict[str, str] = field(default_factory=dict)
|
|
|
|
|
|
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
|
|
|
|
|
|
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
|
|
|
|
|
|
phone_pending: PhoneCallPending | None = None
|
|
|
|
|
|
reply_text: str = ""
|
|
|
|
|
|
reply_grammar: GrammarAssessment | None = None
|
|
|
|
|
|
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def card_id(self) -> UUID:
|
|
|
|
|
|
return self.dispatched_card.card_id
|
|
|
|
|
|
|
|
|
|
|
|
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class SessionState:
|
|
|
|
|
|
session_id: UUID
|
|
|
|
|
|
scenario_id: str
|
|
|
|
|
|
scenario_title: str
|
|
|
|
|
|
level: str
|
|
|
|
|
|
mode: SessionMode
|
2026-09-24 01:10:49 +03:00
|
|
|
|
#: Преподаватель, создавший занятие; чужой пульт не может им управлять.
|
|
|
|
|
|
owner_login: str | None = None
|
2026-09-26 18:12:27 +03:00
|
|
|
|
#: Monotonic DB ownership generation; stale processes may not persist writes.
|
|
|
|
|
|
backend_fencing_epoch: int = 0
|
|
|
|
|
|
#: Runtime-only: set when this process loses or cannot confirm DB ownership.
|
|
|
|
|
|
lease_fenced: bool = False
|
2026-09-21 17:40:54 +03:00
|
|
|
|
exercise: Exercise = Exercise.CALL
|
2026-09-21 20:10:57 +03:00
|
|
|
|
#: После заполнения КИО занятие продолжится на АРМ ДДС, а не завершится.
|
|
|
|
|
|
handoff_to_dds: bool = False
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
required_fields: list[str] = field(default_factory=list)
|
|
|
|
|
|
trainee_name: str | None = None
|
feat: вход, роли и аудит действий (lct-23)
Самое крупное расхождение с ТЗ: входа не было вовсе, экраны открывались
ссылкой с номером занятия, и пускало знание адреса.
- Таблицы users и audit_log, миграция. Пароль argon2, сессия — подписанная
cookie; роль на сокетах читается из той же cookie в момент рукопожатия,
отдельного протокола авторизации в канале нет.
- Разграничение: control — преподавателю, observe — преподавателю и админу,
call и station — обучающемуся и преподавателю. Отказ приходит событием
error с кодом forbidden.
- Обучающийся не видит чужого: история подменяет фильтр на его собственный
идентификатор, разбор и профиль сверяют trainee_id. ТЗ запрещает доступ
к чужим результатам, а не только к чужим экранам.
- Администратору закрыта правка оценок — ТЗ запрещает это прямо.
- make users заводит по записи на роль и печатает случайные пароли один раз:
зашитый в репозиторий admin/admin пережил бы сдачу.
- Экран входа и проверка роли на каждом маршруте фронта.
Наши инструменты не сломались: make lesson и тесты входят через dev-token
за флагом dev_auth_bypass, на стенде точка отвечает 404 — выключенной
функции не должно быть видно вовсе. У тестов появился conftest.py.
Role уехала в домен и в generated.ts через EventCatalog.principal: иначе
фронт переписывал бы список ролей руками.
181 тест зелёный (14 новых), make typecheck чистый.
2026-09-20 09:01:05 +03:00
|
|
|
|
#: Чьё это занятие. Проставляется при запуске, когда курсант известен
|
|
|
|
|
|
#: по учётной записи: по нему разбор закрывается от чужих (lct-23).
|
|
|
|
|
|
trainee_id: UUID | None = None
|
2026-09-24 01:10:49 +03:00
|
|
|
|
#: Конкретная ДДС обучающегося. Она меняет статусы только своей службы;
|
|
|
|
|
|
#: остальные адресаты карточки показываются информационно.
|
|
|
|
|
|
dds_service: str | None = None
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
attempt: int = 1
|
2026-09-24 01:10:49 +03:00
|
|
|
|
criteria: LessonCriteria = field(default_factory=LessonCriteria)
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
|
|
|
|
|
|
kio: KIO = field(default_factory=KIO)
|
|
|
|
|
|
transcript: list[TranscriptEntry] = field(default_factory=list)
|
|
|
|
|
|
timers: SessionTimers = field(default_factory=SessionTimers)
|
|
|
|
|
|
hints_shown: list[str] = field(default_factory=list)
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
#: Когда именно подсказывали — в разборе видно, какой пункт и на какой минуте.
|
|
|
|
|
|
hints_log: list[tuple[str, datetime]] = field(default_factory=list)
|
|
|
|
|
|
notes: list[dict] = field(default_factory=list)
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
directives: list[str] = field(default_factory=list)
|
|
|
|
|
|
|
lct-07 (без LLM): слот-автомат, персона, звонящий на заготовках, make repl
Звонящий не выдаёт данные сам: автомат держит раскрытые факты явно и
отдаёт звонящему только их. Тест прогоняет длинную серию реплик и
проверяет, что ни один нераскрытый факт не прозвучал.
Главная находка — матчинг по порогу близости не работает. На
multilingual-e5-small настоящие вопросы дают 0.79–0.95, а «Оставайтесь
на линии» — до 0.89: диапазоны перекрываются, и любой порог либо выдаёт
адрес на «успокойтесь», либо не слышит «где вы находитесь?».
Решение — сравнение с ближайшим соседом: у пункта чек-листа несколько
формулировок (examples), рядом общий список не-вопросов
(checklists/common.yaml), и реплика засчитывается пункту, только если она
ближе к нему, чем к любому не-вопросу. На отложенных фразах: 19 из 20
вопросов, 0 из 8 ложных срабатываний.
Реплика режется только по границам предложений, со знаком: «?» для e5 —
сильный признак вопроса, без него «Куда ехать» уходит к не-вопросам.
Подсказка берёт неотработанный пункт из автомата. В живой сессии автомат
начнёт слышать оператора, когда голосовой контур передаст ему stt.final.
Ждёт ключа LLM: условие approach у скрытых фактов, звонящий своими
словами, dialog/llm.py с кэшем.
2026-09-17 13:57:43 +03:00
|
|
|
|
# Звонящий. Автомата нет, если не скачана модель эмбеддингов:
|
|
|
|
|
|
# занятие идёт, подсказки откатываются на порядок чек-листа.
|
2026-09-17 21:38:43 +03:00
|
|
|
|
# Своя копия сценария на занятие: директивы преподавателя правят факты
|
|
|
|
|
|
# и эталон, и правка в одной группе не должна протекать в остальные.
|
|
|
|
|
|
scenario: Scenario | None = None
|
lct-07 (без LLM): слот-автомат, персона, звонящий на заготовках, make repl
Звонящий не выдаёт данные сам: автомат держит раскрытые факты явно и
отдаёт звонящему только их. Тест прогоняет длинную серию реплик и
проверяет, что ни один нераскрытый факт не прозвучал.
Главная находка — матчинг по порогу близости не работает. На
multilingual-e5-small настоящие вопросы дают 0.79–0.95, а «Оставайтесь
на линии» — до 0.89: диапазоны перекрываются, и любой порог либо выдаёт
адрес на «успокойтесь», либо не слышит «где вы находитесь?».
Решение — сравнение с ближайшим соседом: у пункта чек-листа несколько
формулировок (examples), рядом общий список не-вопросов
(checklists/common.yaml), и реплика засчитывается пункту, только если она
ближе к нему, чем к любому не-вопросу. На отложенных фразах: 19 из 20
вопросов, 0 из 8 ложных срабатываний.
Реплика режется только по границам предложений, со знаком: «?» для e5 —
сильный признак вопроса, без него «Куда ехать» уходит к не-вопросам.
Подсказка берёт неотработанный пункт из автомата. В живой сессии автомат
начнёт слышать оператора, когда голосовой контур передаст ему stt.final.
Ждёт ключа LLM: условие approach у скрытых фактов, звонящий своими
словами, dialog/llm.py с кэшем.
2026-09-17 13:57:43 +03:00
|
|
|
|
slots: SlotMachine | None = None
|
|
|
|
|
|
persona: PersonaState | None = None
|
lct-06: голосовой контур VAD → STT → звонящий → TTS, с перебиванием
Контур замкнут без LLM: звонящий отвечает заготовками через слот-автомат.
Живой диалог через WebSocket прошёл 5 раз подряд без сбоев: первая
реплика после снятия трубки — 87–154 мс, ход — 1.2–1.4 с, перебивание —
88–90 мс. На LLM до цели 1.5 с остаётся 100–300 мс.
Задача ответа живёт, пока у курсанта доигрывает звук: перебивание —
это отмена одной задачи, и tts.end приходит, когда звонящий
действительно замолчал.
Что нашлось при сборке:
- Silero молча выбрасывает цифры: «улица Ленина, дом… квартира…» без
номеров. Числа и сокращения разворачиваются в слова до синтеза.
- onnxruntime брал все 16 ядер и вытеснял синтез, первый вызов был
холодным: распознавание 576 мс вместо ~210. Прогрев на старте и по
4 потока на модель.
- Весь адрес одним предложением — ~455 мс синтеза до первого звука.
Длинное предложение режется по запятым, номер от улицы не отрывается:
~250 мс.
- Первая реплика «Алло! Помогите!» запускалась мимо задачи ответа и не
перебивалась вовсе.
Отдельно: сломал make dev — num2words попал в pyproject, а Dockerfile
ставил зависимости ручным списком, и бэкенд в контейнере упал на
импорте. Образ теперь ставит зависимости из pyproject.toml.
Голоса в контейнере нет: под WSL модели работают нативно, make back.
2026-09-17 15:04:17 +03:00
|
|
|
|
caller: TemplateCaller | None = None
|
|
|
|
|
|
# Голосовой контур звонка. Нет — если голос выключен или моделей нет:
|
|
|
|
|
|
# тогда кадры микрофона только считаются.
|
|
|
|
|
|
voice: object | None = None
|
2026-09-24 01:10:49 +03:00
|
|
|
|
recorder: object | None = None
|
|
|
|
|
|
recording_path: str | None = None
|
lct-07 (без LLM): слот-автомат, персона, звонящий на заготовках, make repl
Звонящий не выдаёт данные сам: автомат держит раскрытые факты явно и
отдаёт звонящему только их. Тест прогоняет длинную серию реплик и
проверяет, что ни один нераскрытый факт не прозвучал.
Главная находка — матчинг по порогу близости не работает. На
multilingual-e5-small настоящие вопросы дают 0.79–0.95, а «Оставайтесь
на линии» — до 0.89: диапазоны перекрываются, и любой порог либо выдаёт
адрес на «успокойтесь», либо не слышит «где вы находитесь?».
Решение — сравнение с ближайшим соседом: у пункта чек-листа несколько
формулировок (examples), рядом общий список не-вопросов
(checklists/common.yaml), и реплика засчитывается пункту, только если она
ближе к нему, чем к любому не-вопросу. На отложенных фразах: 19 из 20
вопросов, 0 из 8 ложных срабатываний.
Реплика режется только по границам предложений, со знаком: «?» для e5 —
сильный признак вопроса, без него «Куда ехать» уходит к не-вопросам.
Подсказка берёт неотработанный пункт из автомата. В живой сессии автомат
начнёт слышать оператора, когда голосовой контур передаст ему stt.final.
Ждёт ключа LLM: условие approach у скрытых фактов, звонящий своими
словами, dialog/llm.py с кэшем.
2026-09-17 13:57:43 +03:00
|
|
|
|
|
lct-08: каркас фронта, типизированный сокет, захват микрофона
Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.
Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.
Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.
Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.
make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.
Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
2026-09-17 14:19:38 +03:00
|
|
|
|
# Аудио курсанта. До голосового контура (lct-06) кадры только считаются —
|
|
|
|
|
|
# этого достаточно, чтобы доказать, что звук доходит от микрофона до сервера.
|
|
|
|
|
|
audio_frames: int = 0
|
|
|
|
|
|
bad_frames: int = 0
|
|
|
|
|
|
|
2026-09-17 21:24:34 +03:00
|
|
|
|
# Самооценка сдана — до неё курсант не видит автооценку.
|
|
|
|
|
|
self_assessed: bool = False
|
|
|
|
|
|
self_assessment: dict | None = None
|
|
|
|
|
|
score: dict | None = None
|
|
|
|
|
|
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
started_at: datetime | None = None
|
|
|
|
|
|
ended_at: datetime | None = None
|
|
|
|
|
|
end_reason: CallEndReason | None = None
|
|
|
|
|
|
dispatched_card: KIO | None = None
|
2026-09-17 21:45:47 +03:00
|
|
|
|
dispatched_at: datetime | None = None
|
|
|
|
|
|
#: Поля, из-за которых диспетчер вернул карточку, — основание E6.
|
|
|
|
|
|
bounced_fields: list[str] = field(default_factory=list)
|
|
|
|
|
|
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
#: Отметки служб по списку оповещения — предмет второго режима занятия.
|
|
|
|
|
|
status_log: list[StatusEntry] = field(default_factory=list)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
crew_selected: str | None = None
|
|
|
|
|
|
crew_assignments: dict[str, str] = field(default_factory=dict)
|
|
|
|
|
|
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
|
|
|
|
|
|
phone_pending: PhoneCallPending | None = None
|
2026-09-21 19:56:13 +03:00
|
|
|
|
dds_scenarios: list[Scenario] = field(default_factory=list)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
pending_dds_scenarios: list[Scenario] = field(default_factory=list)
|
|
|
|
|
|
#: Исходная часть упражнения 112→ДДС сохраняется отдельно от активной
|
|
|
|
|
|
#: карточки ДДС, которая может переключаться по очереди.
|
|
|
|
|
|
operator_kio: KIO | None = None
|
|
|
|
|
|
operator_scenario: Scenario | None = None
|
2026-09-24 01:10:49 +03:00
|
|
|
|
dds_live_cards: list[DdsLiveCard] = field(default_factory=list)
|
|
|
|
|
|
dds_active_card_id: UUID | None = None
|
2026-09-21 19:56:13 +03:00
|
|
|
|
dds_card_index: int = 0
|
|
|
|
|
|
dds_completed: list[DdsCardRecord] = field(default_factory=list)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
dds_arrival_interval_seconds: int = 0
|
|
|
|
|
|
dds_max_waiting: int = 3
|
|
|
|
|
|
dds_next_scenario_index: int = 0
|
|
|
|
|
|
dds_next_arrival_at: datetime | None = None
|
2026-09-21 19:56:13 +03:00
|
|
|
|
reply_text: str = ""
|
2026-09-24 01:10:49 +03:00
|
|
|
|
reply_grammar: GrammarAssessment | None = None
|
2026-09-21 19:56:13 +03:00
|
|
|
|
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
|
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.
- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.
Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.
Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.
161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
|
|
|
|
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
|
|
|
|
|
|
resolved_outcome: str | None = None
|
|
|
|
|
|
resolve_comment: str = ""
|
2026-09-26 18:12:27 +03:00
|
|
|
|
#: Recently committed DDS command IDs; included in the durable checkpoint so
|
|
|
|
|
|
#: 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)
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
|
|
|
|
|
|
def on_event(self, event_type: str) -> None:
|
|
|
|
|
|
"""Единственная точка, где событие двигает таймеры."""
|
|
|
|
|
|
self.timers.on_event(event_type)
|
|
|
|
|
|
|
|
|
|
|
|
def append(self, speaker: Speaker, text: str, mood: Mood | None = None) -> TranscriptEntry:
|
|
|
|
|
|
entry = TranscriptEntry(
|
|
|
|
|
|
ref=f"u{len(self.transcript) + 1}",
|
|
|
|
|
|
speaker=speaker,
|
|
|
|
|
|
text=text,
|
|
|
|
|
|
at=now_utc(),
|
|
|
|
|
|
mood=mood,
|
|
|
|
|
|
)
|
|
|
|
|
|
self.transcript.append(entry)
|
|
|
|
|
|
return entry
|
|
|
|
|
|
|
|
|
|
|
|
def patch_kio(self, fields: dict[str, Any]) -> KIO:
|
|
|
|
|
|
self.kio = apply_patch(self.kio, fields)
|
|
|
|
|
|
return self.kio
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
def dispatch(self, service: str | None = None) -> KIO:
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
"""Карточка замораживается снимком: оператор не должен иметь
|
|
|
|
|
|
возможности дописать задним числом поле, которое забыл."""
|
2026-09-21 17:40:54 +03:00
|
|
|
|
if service is not None:
|
|
|
|
|
|
self.kio = apply_patch(self.kio, {"dds": service})
|
|
|
|
|
|
self.kio.response_status = ResponseStatus.TRANSFERRED
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
self.dispatched_card = self.kio.model_copy(deep=True)
|
2026-09-17 21:45:47 +03:00
|
|
|
|
self.dispatched_at = now_utc()
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
return self.dispatched_card
|
|
|
|
|
|
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
def notified_services(self) -> list[str]:
|
|
|
|
|
|
"""Список оповещения переданной карточки. До передачи — пусто:
|
|
|
|
|
|
отмечаться диспетчеру пока не по чему."""
|
|
|
|
|
|
card = self.dispatched_card or self.kio
|
|
|
|
|
|
return list(card.notify)
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
def _managed_services_for(self, recipients: list[str]) -> list[str]:
|
|
|
|
|
|
"""Служба, от имени которой работает этот обучающийся.
|
|
|
|
|
|
|
|
|
|
|
|
Старые занятия и demo без профиля безопасно закрепляются за первым
|
|
|
|
|
|
адресатом. Обобщённая учебная служба «ДДС района» соответствует
|
|
|
|
|
|
территориальному органу, а не всем получателям карточки сразу.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not recipients:
|
|
|
|
|
|
return []
|
|
|
|
|
|
wanted = (self.dds_service or "").strip().casefold()
|
|
|
|
|
|
if wanted:
|
|
|
|
|
|
exact = next((item for item in recipients if item.casefold() == wanted), None)
|
|
|
|
|
|
if exact:
|
|
|
|
|
|
return [exact]
|
|
|
|
|
|
contained = next(
|
|
|
|
|
|
(item for item in recipients
|
|
|
|
|
|
if wanted in item.casefold() or item.casefold() in wanted),
|
|
|
|
|
|
None,
|
|
|
|
|
|
)
|
|
|
|
|
|
if contained:
|
|
|
|
|
|
return [contained]
|
|
|
|
|
|
if "район" in wanted:
|
|
|
|
|
|
district = next(
|
|
|
|
|
|
(item for item in recipients if "территориальн" in item.casefold()), None
|
|
|
|
|
|
)
|
|
|
|
|
|
if district:
|
|
|
|
|
|
return [district]
|
|
|
|
|
|
return []
|
|
|
|
|
|
return [recipients[0]]
|
|
|
|
|
|
|
|
|
|
|
|
def managed_services(self) -> list[str]:
|
|
|
|
|
|
return self._managed_services_for(self.notified_services())
|
|
|
|
|
|
|
|
|
|
|
|
def capture_active_dds(self) -> None:
|
|
|
|
|
|
"""Сохранить изменяемые поля активной карточки перед переключением."""
|
|
|
|
|
|
if not self.dispatched_card:
|
|
|
|
|
|
return
|
|
|
|
|
|
card = next(
|
|
|
|
|
|
(item for item in self.dds_live_cards
|
|
|
|
|
|
if item.card_id == self.dispatched_card.card_id),
|
|
|
|
|
|
None,
|
|
|
|
|
|
)
|
|
|
|
|
|
if card is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
card.scenario = self.scenario
|
|
|
|
|
|
card.kio = self.kio
|
|
|
|
|
|
card.dispatched_card = self.dispatched_card
|
|
|
|
|
|
card.dispatched_at = self.dispatched_at or card.dispatched_at
|
|
|
|
|
|
card.timers = self.timers
|
|
|
|
|
|
card.bounced_fields = self.bounced_fields
|
|
|
|
|
|
card.dds_log = self.dds_log
|
|
|
|
|
|
card.status_log = self.status_log
|
|
|
|
|
|
card.crew_selected = self.crew_selected
|
|
|
|
|
|
card.crew_assignments = self.crew_assignments
|
|
|
|
|
|
card.phone_reports = self.phone_reports
|
|
|
|
|
|
card.phone_lines = self.phone_lines
|
|
|
|
|
|
card.phone_pending = self.phone_pending
|
|
|
|
|
|
card.reply_text = self.reply_text
|
|
|
|
|
|
card.reply_grammar = self.reply_grammar
|
|
|
|
|
|
card.reply_log = self.reply_log
|
|
|
|
|
|
self.dds_active_card_id = card.card_id
|
|
|
|
|
|
|
|
|
|
|
|
def activate_dds_card(self, card_id: UUID, *, capture: bool = True) -> bool:
|
|
|
|
|
|
"""Переключить рабочее место на карточку, не останавливая остальные."""
|
|
|
|
|
|
if capture:
|
|
|
|
|
|
self.capture_active_dds()
|
|
|
|
|
|
card = next((item for item in self.dds_live_cards if item.card_id == card_id), None)
|
|
|
|
|
|
if card is None:
|
|
|
|
|
|
return False
|
|
|
|
|
|
scenario = card.scenario
|
|
|
|
|
|
self.scenario = scenario
|
|
|
|
|
|
self.scenario_id = scenario.id
|
|
|
|
|
|
self.scenario_title = scenario.title
|
|
|
|
|
|
self.level = scenario.level.value
|
|
|
|
|
|
self.required_fields = list(scenario.required_fields)
|
|
|
|
|
|
self.kio = card.kio
|
|
|
|
|
|
self.dispatched_card = card.dispatched_card
|
|
|
|
|
|
self.dispatched_at = card.dispatched_at
|
|
|
|
|
|
self.timers = card.timers
|
|
|
|
|
|
self.bounced_fields = card.bounced_fields
|
|
|
|
|
|
self.dds_log = card.dds_log
|
|
|
|
|
|
self.status_log = card.status_log
|
|
|
|
|
|
self.crew_selected = card.crew_selected
|
|
|
|
|
|
self.crew_assignments = card.crew_assignments
|
|
|
|
|
|
self.phone_reports = card.phone_reports
|
|
|
|
|
|
self.phone_lines = card.phone_lines
|
|
|
|
|
|
self.phone_pending = card.phone_pending
|
|
|
|
|
|
self.reply_text = card.reply_text
|
|
|
|
|
|
self.reply_grammar = card.reply_grammar
|
|
|
|
|
|
self.reply_log = card.reply_log
|
|
|
|
|
|
self.dds_card_index = card.original_index
|
|
|
|
|
|
self.dds_active_card_id = card.card_id
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
def crew_options(self) -> list[str]:
|
|
|
|
|
|
return [f"{service} — бригада №{number}"
|
2026-09-24 01:10:49 +03:00
|
|
|
|
for service in self.managed_services() for number in (1, 2)]
|
2026-09-21 17:40:54 +03:00
|
|
|
|
|
|
|
|
|
|
def crew_service(self, crew: str) -> str | None:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
return next((service for service in self.managed_services()
|
2026-09-21 17:40:54 +03:00
|
|
|
|
if crew in (f"{service} — бригада №1", f"{service} — бригада №2")), None)
|
|
|
|
|
|
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
def set_service_status(
|
|
|
|
|
|
self, service: str, status: ServiceStatus, comment: str = "", author: str = ""
|
|
|
|
|
|
) -> StatusEntry:
|
|
|
|
|
|
"""Отметка службы. Проверка перехода — до записи: недопустимый статус
|
|
|
|
|
|
не должен попасть в журнал даже отвергнутым."""
|
|
|
|
|
|
check(self.status_log, service, status, comment)
|
|
|
|
|
|
entry = StatusEntry(
|
|
|
|
|
|
service=service, status=status, at=now_utc(), comment=comment, author=author
|
|
|
|
|
|
)
|
|
|
|
|
|
self.status_log.append(entry)
|
|
|
|
|
|
return entry
|
|
|
|
|
|
|
|
|
|
|
|
def station_snapshot(self) -> StationSnapshot:
|
|
|
|
|
|
"""Что видит диспетчер: службы, их статусы и доступные переходы."""
|
2026-09-24 01:10:49 +03:00
|
|
|
|
self.capture_active_dds()
|
|
|
|
|
|
has_active_dds_card = any(
|
|
|
|
|
|
item.card_id == self.dds_active_card_id for item in self.dds_live_cards
|
|
|
|
|
|
)
|
|
|
|
|
|
recipients = self.notified_services() if has_active_dds_card or not self.dds_scenarios else []
|
|
|
|
|
|
services = self._managed_services_for(recipients)
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
statuses = {service: current(self.status_log, service) for service in services}
|
|
|
|
|
|
elapsed = self.timers.measured_ms(TimerCode.DDS_ACK)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
limit = self.timers.limits[TimerCode.DDS_ACK]
|
|
|
|
|
|
overdue = has_active_dds_card and self.dispatched_at is not None and (
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
elapsed is None
|
|
|
|
|
|
and (now_utc() - self.dispatched_at).total_seconds() * 1000 > limit
|
|
|
|
|
|
)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
now_mono = time.monotonic()
|
|
|
|
|
|
queue_cards: list[DdsQueueCard] = []
|
|
|
|
|
|
for item in sorted(self.dds_live_cards, key=lambda queued: queued.original_index):
|
|
|
|
|
|
recipients_for_card = list(item.dispatched_card.notify)
|
|
|
|
|
|
managed = self._managed_services_for(recipients_for_card)
|
|
|
|
|
|
service = managed[0] if managed else None
|
|
|
|
|
|
timer = item.timers.timers.get(TimerCode.DDS_ACK)
|
|
|
|
|
|
elapsed_ms = timer.current_ms(now_mono) if timer else 0
|
|
|
|
|
|
stopped = timer.stopped if timer else False
|
|
|
|
|
|
limit_ms = item.timers.limits[TimerCode.DDS_ACK]
|
|
|
|
|
|
queue_cards.append(DdsQueueCard(
|
|
|
|
|
|
card_id=item.card_id,
|
|
|
|
|
|
scenario_id=item.scenario.id,
|
|
|
|
|
|
title=item.scenario.title,
|
|
|
|
|
|
address=item.dispatched_card.address,
|
|
|
|
|
|
description=item.dispatched_card.description,
|
|
|
|
|
|
incident_type=(item.dispatched_card.incident_type.value
|
|
|
|
|
|
if item.dispatched_card.incident_type else None),
|
|
|
|
|
|
victims_count=item.dispatched_card.victims_count,
|
|
|
|
|
|
received_at=item.dispatched_at,
|
|
|
|
|
|
managed_service=service,
|
|
|
|
|
|
service_status=(current(item.status_log, service)
|
|
|
|
|
|
if service else ServiceStatus.ADDED),
|
|
|
|
|
|
card_status=card_status(
|
|
|
|
|
|
item.status_log, managed,
|
|
|
|
|
|
notify_deadline_passed=not stopped and elapsed_ms > limit_ms,
|
|
|
|
|
|
),
|
|
|
|
|
|
elapsed_ms=elapsed_ms,
|
|
|
|
|
|
limit_ms=limit_ms,
|
|
|
|
|
|
timer_stopped=stopped,
|
|
|
|
|
|
active=item.card_id == self.dds_active_card_id,
|
|
|
|
|
|
))
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
return StationSnapshot(
|
|
|
|
|
|
services=services,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
recipient_services=recipients,
|
|
|
|
|
|
managed_service=services[0] if services else None,
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
statuses=statuses,
|
|
|
|
|
|
available={service: list(NEXT[status]) for service, status in statuses.items()},
|
2026-09-24 01:10:49 +03:00
|
|
|
|
card=(card_status(self.status_log, services, notify_deadline_passed=overdue)
|
|
|
|
|
|
if has_active_dds_card or not self.dds_scenarios else CardStatus.REGISTERED),
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
log=list(self.status_log),
|
2026-09-24 01:10:49 +03:00
|
|
|
|
crew_options=(self.crew_options() if has_active_dds_card or not self.dds_scenarios else []),
|
|
|
|
|
|
crew_selected=self.crew_selected if has_active_dds_card else None,
|
2026-09-26 18:12:27 +03:00
|
|
|
|
zone_decision=(
|
|
|
|
|
|
next((detail == "в зоне" for action, _at, detail in reversed(self.dds_log)
|
|
|
|
|
|
if action == "zone.decision"), None)
|
|
|
|
|
|
if has_active_dds_card else None
|
|
|
|
|
|
),
|
2026-09-24 01:10:49 +03:00
|
|
|
|
phone_reports=list(self.phone_reports) if has_active_dds_card else [],
|
|
|
|
|
|
phone_lines=list(self.phone_lines) if has_active_dds_card else [],
|
|
|
|
|
|
phone_pending=self.phone_pending if has_active_dds_card else None,
|
|
|
|
|
|
card_id=(self.dispatched_card.card_id if self.dispatched_card and has_active_dds_card else None),
|
|
|
|
|
|
card_index=(self.dds_card_index + 1 if has_active_dds_card
|
|
|
|
|
|
else min(self.dds_next_scenario_index + 1, len(self.dds_scenarios))),
|
2026-09-21 19:56:13 +03:00
|
|
|
|
card_total=len(self.dds_scenarios) or 1,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
reply_text=self.reply_text if has_active_dds_card else "",
|
2026-09-21 19:56:13 +03:00
|
|
|
|
completed_cards=[DdsCardSummary(card_id=item.card_id,
|
|
|
|
|
|
scenario_id=item.scenario_id,
|
|
|
|
|
|
score_auto=item.score_auto)
|
|
|
|
|
|
for item in self.dds_completed],
|
2026-09-24 01:10:49 +03:00
|
|
|
|
queue_cards=queue_cards,
|
|
|
|
|
|
pending_cards_count=max(
|
|
|
|
|
|
0, len(self.dds_scenarios) - len(self.dds_completed)
|
|
|
|
|
|
- (1 if has_active_dds_card else 0),
|
|
|
|
|
|
),
|
|
|
|
|
|
next_arrival_in_seconds=(
|
|
|
|
|
|
max(0, int((self.dds_next_arrival_at - now_utc()).total_seconds() + 0.999))
|
|
|
|
|
|
if self.dds_next_arrival_at is not None else None
|
|
|
|
|
|
),
|
|
|
|
|
|
max_waiting_cards=self.dds_max_waiting,
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-17 21:45:47 +03:00
|
|
|
|
def card_received_event(self):
|
|
|
|
|
|
"""Снимок карточки для станции ДДС."""
|
|
|
|
|
|
from app.domain.events import CardReceived
|
|
|
|
|
|
|
|
|
|
|
|
return CardReceived(
|
|
|
|
|
|
card=self.dispatched_card,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from_operator=("учебный сценарий" if self.exercise is Exercise.DDS
|
2026-09-26 18:12:27 +03:00
|
|
|
|
or (self.handoff_to_dds and self.dds_card_index > 0)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
else self.trainee_name or "оператор 112"),
|
2026-09-17 21:45:47 +03:00
|
|
|
|
at=self.dispatched_at or now_utc(),
|
2026-09-21 19:56:13 +03:00
|
|
|
|
card_index=self.dds_card_index + 1,
|
|
|
|
|
|
card_total=len(self.dds_scenarios) or 1,
|
2026-09-17 21:45:47 +03:00
|
|
|
|
)
|
|
|
|
|
|
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
@property
|
|
|
|
|
|
def ended(self) -> bool:
|
|
|
|
|
|
return self.ended_at is not None
|
|
|
|
|
|
|
|
|
|
|
|
def snapshot(self) -> SessionSnapshot:
|
|
|
|
|
|
"""Полное состояние. Монитор в классе включают посреди занятия —
|
|
|
|
|
|
он обязан показать текущее, а не ждать следующего события."""
|
|
|
|
|
|
return SessionSnapshot(
|
|
|
|
|
|
session_id=self.session_id,
|
|
|
|
|
|
scenario_id=self.scenario_id,
|
|
|
|
|
|
scenario_title=self.scenario_title,
|
|
|
|
|
|
level=self.level,
|
|
|
|
|
|
mode=self.mode,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
exercise=self.exercise,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
criteria=self.criteria,
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
trainee_name=self.trainee_name,
|
|
|
|
|
|
started_at=self.started_at,
|
|
|
|
|
|
kio=self.kio,
|
|
|
|
|
|
required_fields=self.required_fields,
|
|
|
|
|
|
transcript=list(self.transcript),
|
|
|
|
|
|
timers=self.timers.snapshot(),
|
|
|
|
|
|
hints_used=len(self.hints_shown),
|
|
|
|
|
|
ended=self.ended,
|
|
|
|
|
|
)
|