lct-hack/backend/app/session/state.py

124 lines
5.2 KiB
Python
Raw Normal View History

lct-05: состояние сессии, таймеры по событиям, четыре канала Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны по обработчикам: так видно целиком, что чем запускается, и норматив нельзя потерять по дороге. Опрос стартует на ответе курсанта и останавливается передачей в ДДС — событием, а не таймаутом. Канал наблюдателя без единого обработчика входящих: кадры читаются и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без чтения задача сокета висела бы на очереди до первой отправки, а закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что наблюдатель не может ни изменить карточку, ни завершить занятие. Пульт теперь заводит сессию в журнале: запущенное с него занятие жило только в памяти, и реплики с подсказками уходили в нарушение внешнего ключа — журнал об этом честно сообщал в лог. Тикер таймеров гасится при остановке приложения, иначе задачи переживают выключение и держат событийный цикл. Проверено вживую через uvicorn: монитор, подключённый посреди занятия, получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
"""Состояние живой сессии: карточка, транскрипт, таймеры, режим, попытка.
Живёт в памяти процесса — поэтому воркер uvicorn ровно один: с двумя
преподаватель подключился бы к другому процессу, чем курсант, и увидел
пустой экран (docs/arch/STACK.md).
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from app.domain.events import (
CallEndReason,
Mood,
SessionMode,
SessionSnapshot,
Speaker,
TranscriptEntry,
)
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
from app.dialog.caller import TemplateCaller
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
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.kio import KIO, apply_patch
from app.session.timers import SessionTimers
def now_utc() -> datetime:
"""Часы серверные. Метрика, посчитанная по часам браузера, недоказуема."""
return datetime.now(timezone.utc)
@dataclass
class SessionState:
session_id: UUID
scenario_id: str
scenario_title: str
level: str
mode: SessionMode
required_fields: list[str] = field(default_factory=list)
trainee_name: str | None = None
attempt: int = 1
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
# Звонящий. Автомата нет, если не скачана модель эмбеддингов:
# занятие идёт, подсказки откатываются на порядок чек-листа.
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
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
# Самооценка сдана — до неё курсант не видит автооценку.
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
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
def dispatch(self, service: str) -> KIO:
"""Карточка замораживается снимком: оператор не должен иметь
возможности дописать задним числом поле, которое забыл."""
self.kio = apply_patch(self.kio, {"dds": service, "response_status": "transferred"})
self.dispatched_card = self.kio.model_copy(deep=True)
return self.dispatched_card
@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,
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,
)