lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
"""Реестр живых сессий и подписки наблюдателей.
|
|
|
|
|
|
|
|
|
|
|
|
Дельты курсанту, полное состояние наблюдателям — сознательно: у курсанта одно
|
|
|
|
|
|
соединение и важна задержка, наблюдателей несколько и подключаются они
|
|
|
|
|
|
в произвольный момент (docs/arch/CONTRACT.md).
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import contextlib
|
|
|
|
|
|
from collections.abc import AsyncIterator, Iterator
|
|
|
|
|
|
from typing import Protocol
|
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
|
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
|
|
|
|
|
|
from app.domain.events import TimerTick
|
|
|
|
|
|
from app.session.state import SessionState
|
|
|
|
|
|
|
|
|
|
|
|
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
|
|
|
|
|
|
#: очередь ограничена, переполнение роняет соединение, а не сессию.
|
|
|
|
|
|
QUEUE_SIZE = 256
|
|
|
|
|
|
|
|
|
|
|
|
TICK_SECONDS = 1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Journal(Protocol):
|
|
|
|
|
|
"""Запись в БД. Вынесена за хаб: без базы занятие должно идти,
|
|
|
|
|
|
но молчать об ошибке записи нельзя."""
|
|
|
|
|
|
|
|
|
|
|
|
async def start_lesson(
|
|
|
|
|
|
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None
|
|
|
|
|
|
) -> int: ...
|
|
|
|
|
|
async def utterance(self, session_id: UUID, entry) -> None: ...
|
|
|
|
|
|
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
|
|
|
|
|
|
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...
|
|
|
|
|
|
async def self_assessment(self, session_id: UUID, missed: list[str], comment: str, at) -> None: ...
|
2026-09-17 21:24:34 +03:00
|
|
|
|
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None: ...
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
async def session_started(self, session_id: UUID, at) -> None: ...
|
|
|
|
|
|
async def session_ended(self, session_id: UUID, at, reason: str) -> None: ...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SessionHub:
|
|
|
|
|
|
def __init__(self, journal: Journal | None = None) -> None:
|
|
|
|
|
|
self.journal = journal
|
|
|
|
|
|
self._sessions: dict[UUID, SessionState] = {}
|
|
|
|
|
|
self._observers: dict[UUID, set[asyncio.Queue]] = {}
|
|
|
|
|
|
self._trainees: dict[UUID, set[asyncio.Queue]] = {}
|
|
|
|
|
|
self._tickers: dict[UUID, asyncio.Task] = {}
|
|
|
|
|
|
|
|
|
|
|
|
# ── реестр ──
|
|
|
|
|
|
|
|
|
|
|
|
def register(self, state: SessionState) -> SessionState:
|
|
|
|
|
|
self._sessions[state.session_id] = state
|
|
|
|
|
|
return state
|
|
|
|
|
|
|
|
|
|
|
|
def get(self, session_id: UUID) -> SessionState | None:
|
|
|
|
|
|
return self._sessions.get(session_id)
|
|
|
|
|
|
|
|
|
|
|
|
def drop(self, session_id: UUID) -> None:
|
|
|
|
|
|
self._sessions.pop(session_id, None)
|
|
|
|
|
|
self.stop_ticker(session_id)
|
|
|
|
|
|
|
|
|
|
|
|
# ── подписки ──
|
|
|
|
|
|
|
|
|
|
|
|
@contextlib.contextmanager
|
|
|
|
|
|
def _subscribe(self, registry: dict[UUID, set[asyncio.Queue]], session_id: UUID) -> Iterator[asyncio.Queue]:
|
|
|
|
|
|
queue: asyncio.Queue = asyncio.Queue(maxsize=QUEUE_SIZE)
|
|
|
|
|
|
registry.setdefault(session_id, set()).add(queue)
|
|
|
|
|
|
try:
|
|
|
|
|
|
yield queue
|
|
|
|
|
|
finally:
|
|
|
|
|
|
registry.get(session_id, set()).discard(queue)
|
|
|
|
|
|
|
|
|
|
|
|
def observer(self, session_id: UUID):
|
|
|
|
|
|
return self._subscribe(self._observers, session_id)
|
|
|
|
|
|
|
|
|
|
|
|
def trainee(self, session_id: UUID):
|
|
|
|
|
|
return self._subscribe(self._trainees, session_id)
|
|
|
|
|
|
|
|
|
|
|
|
# ── вещание ──
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
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
|
|
|
|
def _put(queues: set[asyncio.Queue], event: BaseModel | bytes) -> None:
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
for queue in list(queues):
|
|
|
|
|
|
try:
|
|
|
|
|
|
queue.put_nowait(event)
|
|
|
|
|
|
except asyncio.QueueFull:
|
|
|
|
|
|
queues.discard(queue)
|
|
|
|
|
|
|
|
|
|
|
|
def to_observers(self, session_id: UUID, event: BaseModel) -> None:
|
|
|
|
|
|
self._put(self._observers.get(session_id, set()), event)
|
|
|
|
|
|
|
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
|
|
|
|
def to_trainee(self, session_id: UUID, event: BaseModel | bytes) -> None:
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
self._put(self._trainees.get(session_id, set()), event)
|
|
|
|
|
|
|
|
|
|
|
|
def broadcast(self, session_id: UUID, event: BaseModel) -> None:
|
|
|
|
|
|
self.to_trainee(session_id, event)
|
|
|
|
|
|
self.to_observers(session_id, event)
|
|
|
|
|
|
|
|
|
|
|
|
def observer_count(self, session_id: UUID) -> int:
|
|
|
|
|
|
return len(self._observers.get(session_id, set()))
|
|
|
|
|
|
|
|
|
|
|
|
# ── такт таймеров ──
|
|
|
|
|
|
|
|
|
|
|
|
def start_ticker(self, session_id: UUID) -> None:
|
|
|
|
|
|
"""`timer.tick` раз в секунду, а не на каждое изменение: таймеров дюжина,
|
|
|
|
|
|
а UI всё равно рисует секунды."""
|
|
|
|
|
|
if session_id in self._tickers:
|
|
|
|
|
|
return
|
|
|
|
|
|
self._tickers[session_id] = asyncio.create_task(self._tick(session_id))
|
|
|
|
|
|
|
|
|
|
|
|
def stop_ticker(self, session_id: UUID) -> None:
|
|
|
|
|
|
task = self._tickers.pop(session_id, None)
|
|
|
|
|
|
if task is not None:
|
|
|
|
|
|
task.cancel()
|
|
|
|
|
|
|
|
|
|
|
|
async def shutdown(self) -> None:
|
|
|
|
|
|
"""Погасить все такты при остановке приложения.
|
|
|
|
|
|
|
|
|
|
|
|
Без этого задачи тикеров переживают выключение и держат событийный цикл:
|
|
|
|
|
|
первым это ловит не продакшен, а тест, который не может закрыть клиент.
|
|
|
|
|
|
"""
|
|
|
|
|
|
for session_id in list(self._tickers):
|
|
|
|
|
|
self.stop_ticker(session_id)
|
|
|
|
|
|
|
|
|
|
|
|
async def _tick(self, session_id: UUID) -> None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
while True:
|
|
|
|
|
|
await asyncio.sleep(TICK_SECONDS)
|
|
|
|
|
|
state = self.get(session_id)
|
|
|
|
|
|
if state is None or state.ended:
|
|
|
|
|
|
return
|
|
|
|
|
|
self.broadcast(session_id, TimerTick(timers=state.timers.snapshot()))
|
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hub = SessionHub()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def drain(queue: asyncio.Queue) -> AsyncIterator[BaseModel]:
|
|
|
|
|
|
while True:
|
|
|
|
|
|
yield await queue.get()
|