lct-hack/backend/app/api/ws/control.py
Ivan Gerasimov 456f3cd34d 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

165 lines
6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Канал преподавателя: только передача.
**Ни одной команды, меняющей карточку курсанта.** Преподаватель управляет
ситуацией, а не работой обучаемого, иначе оценка перестаёт быть оценкой
курсанта (docs/arch/CONTRACT.md).
Ответы сюда не идут — канал односторонний. Всё, что сервер хочет сказать
преподавателю, уходит на его же сокет `observe`.
"""
import logging
from uuid import UUID, uuid4
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.domain.events import (
CallEndReason,
CallIncoming,
ErrorEvent,
ErrorKind,
InstructorToServer,
InstructorNoteShown,
ModeSet,
ReferenceStarted,
SessionEnded,
)
import asyncio
from app.dialog.caller import TemplateCaller
from app.dialog.persona import PersonaState
from app.dialog.runtime import get_embedder
from app.dialog.slots import SlotMachine
from app.scenarios import store
from app.session.hub import hub
from app.session.state import SessionState, now_utc
from app.voice.models import get_voice_models
from app.voice.pipeline import FILLERS, prefetch
log = logging.getLogger(__name__)
router = APIRouter()
_adapter = TypeAdapter(InstructorToServer)
async def _start(session_id: UUID, event) -> None:
scenario = store.get(event.scenario_id)
if scenario is None:
hub.to_observers(
session_id,
ErrorEvent(code=ErrorKind.SCENARIO_INVALID, message=f"Нет сценария {event.scenario_id}"),
)
return
attempt = 1
if hub.journal:
attempt = await hub.journal.start_lesson(
session_id, scenario.id, event.mode.value, event.trainee
)
state = hub.register(
SessionState(
session_id=session_id,
scenario_id=scenario.id,
scenario_title=scenario.title,
level=scenario.level.value,
mode=event.mode,
required_fields=scenario.required_fields,
trainee_name=event.trainee,
attempt=attempt,
)
)
embedder = get_embedder()
if embedder is not None:
state.slots = SlotMachine(scenario, embedder)
state.persona = PersonaState(scenario.persona)
state.caller = TemplateCaller()
# Первая реплика и филлеры синтезируются, пока курсант не снял трубку:
# «Алло! Помогите!» должно прозвучать мгновенно (docs/arch/BACKEND.md).
models = get_voice_models()
if models is not None:
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
state.on_event("call.incoming")
hub.start_ticker(session_id)
hub.to_trainee(
session_id,
CallIncoming(
scenario_id=scenario.id,
caller_number="+7 (495) 000-00-00",
level=scenario.level,
mode=event.mode,
required_fields=scenario.required_fields,
),
)
hub.to_observers(session_id, ModeSet(mode=event.mode))
hub.to_observers(session_id, state.snapshot())
async def _stop(session_id: UUID) -> None:
state = hub.get(session_id)
if state is None:
return
state.ended_at = now_utc()
state.end_reason = CallEndReason.INSTRUCTOR
hub.stop_ticker(session_id)
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
if hub.journal:
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value)
@router.websocket("/ws/control/{session_id}")
async def control(ws: WebSocket, session_id: UUID) -> None:
await ws.accept()
try:
while True:
payload = await ws.receive_json()
try:
event = _adapter.validate_python(payload)
except ValidationError:
hub.to_observers(
session_id,
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=str(payload)[:200]),
)
continue
match event.type:
case "scenario.start":
await _start(session_id, event)
case "session.stop":
await _stop(session_id)
case "instructor_note.add":
hub.to_observers(
session_id,
InstructorNoteShown(
transcript_ref=event.transcript_ref,
text=event.text,
author="преподаватель",
),
)
if hub.journal:
await hub.journal.note(
session_id, event.transcript_ref, event.text, "преподаватель"
)
case "reference.play":
state = hub.get(session_id)
if state is not None:
hub.to_observers(session_id, ReferenceStarted(scenario_id=state.scenario_id))
case "director.inject":
# Поведение звонящего — карточка lct-07, пульт — lct-22.
# До них директива копится в состоянии и видна в разборе.
state = hub.get(session_id)
if state is not None:
state.directives.append(event.directive)
case _:
hub.to_observers(
session_id,
ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message=f"{event.type} ещё не реализовано",
),
)
except WebSocketDisconnect:
return