Merge remote-tracking branch 'origin/main' into feat/cluster-auth-handshake

# Conflicts:
#	docs/arch/SCALE-OUT.md
#	scripts/test_cluster_failover.py
This commit is contained in:
gglamer 2026-09-27 20:48:17 +00:00
commit 240848a68d
58 changed files with 5789 additions and 152 deletions

View file

@ -48,3 +48,14 @@ async def signs(prefix: list[str] = Query(default=[]), group: int | None = None)
else None
),
}
@router.get("/services")
async def services() -> dict:
"""Каталог служб для ручного добавления в карточку — как окно «Добавьте службы».
Каталог — справочник боевого АРМ, а не содержимое вызова: отдавать его
курсанту так же безопасно, как группы происшествий.
"""
data = ekp.catalog()
return {"source": data.source, "services": [item.model_dump() for item in data.services]}

View file

@ -8,6 +8,7 @@ import logging
import time
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
@ -22,16 +23,29 @@ from app.db import repo
from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee
from app.domain.events import Exercise, SessionMode, SessionReport
from app.domain.taxonomy import (
ERRORS,
ErrorCode,
Finding,
FindingDecision,
FindingReview,
FindingSource,
)
from app.domain.roles import Role
from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
from app.domain.timers import TimerCode
from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current
from app.domain.timers import TimerCode, TimerState
from app.scoring.export import to_csv, to_pdf
from app.scoring.report import build as build_report
from app.scoring.review import has_finding
from app.scoring.taxonomy import METRIC_MAP
from app.session.access import can_access
from app.session.checkpoint import load_state
from app.session.finish import override_score
from app.session.finish import change_findings, override_score
from app.session.store import FindingAdded, FindingReviewed
from app.session.timers import now_utc
from app.session.score import scoring_scenario
from app.session.hub import hub
from app.session.state import SessionState
from app.session.store import ScoreOverridden, apply_score_override
from app.voice.recording import recording_path
@ -86,6 +100,15 @@ class DdsHistoryOut(BaseModel):
recipient_services: list[str] = []
class Signal(BaseModel):
"""Одна строка колонки сигналов реестра. Цвет несёт смысл только через
`severity`; текст обязателен и не заменяется цветом."""
kind: Literal["backlog", "refusals", "offline"]
severity: TimerState
text: str
class ActiveSessionOut(BaseModel):
session_id: UUID
trainee_name: str | None
@ -100,7 +123,10 @@ class ActiveSessionOut(BaseModel):
dds_overdue_cards: int
dds_work_overdue_cards: int
dds_statuses: dict[str, str]
paused: bool = False
dds_snapshot: StationSnapshot | None = None
signals: list[Signal] = []
presence_known: bool = True
def _out(session) -> SessionOut:
@ -184,6 +210,52 @@ async def dds_history(
return result
def _signals(
state: SessionState, queue: list[DdsQueueCard], now: datetime, *, live: bool
) -> list[Signal]:
"""Сигналы реестра: очередь, повторные отказы, курсант не на связи.
Не завязаны на паузу занятия (`session.pause`, если появится) — очередь,
отказы и присутствие сокета не читают таймеры. Присутствие — по наличию
подписчика в хабе этого узла; окно нужно, чтобы короткий разрыв
соединения не сразу считался потерей связи курсанта.
`live=False` — снимок чужого узла: его сокеты здесь не видны, поэтому
присутствие неизвестно. Чтение реестра не меняет состояние занятия.
"""
settings = get_settings()
signals: list[Signal] = []
# Необработанная — без первичного статуса: он останавливает норматив
# DDS_ACK. Принятая или отклонённая карточка висит в очереди до
# «Следующей», но реакции курсанта уже не ждёт.
pending = sum(not card.timer_stopped for card in queue)
if pending >= settings.signal_backlog_threshold:
signals.append(Signal(
kind="backlog", severity=TimerState.WARN,
text=f"Очередь: {pending} необработанных карточек",
))
if state.consecutive_refusals >= settings.signal_refusals_threshold:
signals.append(Signal(
kind="refusals", severity=TimerState.WARN,
text=f"Подряд отказов: {state.consecutive_refusals}",
))
if live:
connected = (
hub.station_connected(state.session_id) if state.dds_phase
else hub.trainee_connected(state.session_id)
)
offline_seconds = (
(now - (state.socket_last_seen_at or state.started_at)).total_seconds()
if (state.socket_last_seen_at or state.started_at) is not None else 0
)
if not connected and offline_seconds >= settings.signal_offline_window_seconds:
signals.append(Signal(
kind="offline", severity=TimerState.VIOLATED,
text=f"Курсант не на связи {int(offline_seconds)} с",
))
return sorted(signals, key=lambda signal: signal.severity is not TimerState.VIOLATED)
@router.get("/active", response_model=list[ActiveSessionOut])
async def active(
request: Request,
@ -197,6 +269,9 @@ async def active(
state.session_id: state
for state in hub.active_sessions(who.login)
}
#: Снимки без живой записи в хабе этого узла — чужой узел или сессия,
#: ожидающая восстановления. Её сокеты нельзя проверить этим процессом.
checkpoint_only: set[UUID] = set()
if db is not None:
rows = (
await db.scalars(
@ -225,11 +300,15 @@ async def active(
state.owner_login = row.owner_login
if not state.ended:
states[state.session_id] = state
checkpoint_only.add(state.session_id)
for state in states.values():
elapsed = (max(0, int((now - state.started_at).total_seconds()))
paused_ms = state.total_paused_ms
if state.paused and state.paused_at is not None:
paused_ms += max(0, int((now - state.paused_at).total_seconds() * 1000))
elapsed = (max(0, int((now - state.started_at).total_seconds() - paused_ms / 1000))
if state.started_at else 0)
station = state.station_snapshot() if state.exercise is Exercise.DDS else None
station = state.station_snapshot() if state.dds_phase else None
queue = station.queue_cards if station else []
card = state.desk.active
status_log = card.status_log if card is not None else []
@ -255,13 +334,18 @@ async def active(
),
dds_work_overdue_cards=sum(
(timer := card.timers.timers.get(TimerCode.DDS_WORK)) is not None
and timer.started_at is not None
# Пауза держит `started_at=None`, хотя таймер уже шёл: судить
# по нему одному спрятало бы уже случившееся нарушение (lct-39).
and (timer.started_at is not None or timer.paused)
and not timer.stopped
and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK]
for card in state.desk.cards.values()
),
dds_statuses=latest_statuses,
paused=state.paused,
dds_snapshot=station,
signals=_signals(state, queue, now, live=state.session_id not in checkpoint_only),
presence_known=state.session_id not in checkpoint_only,
))
return result
@ -584,6 +668,157 @@ async def override(
return SessionReport.model_validate(report["full_report"])
def _required_text(value: str) -> str:
cleaned = value.strip()
if not cleaned:
raise ValueError("поле обязательно")
return cleaned
class FindingReviewIn(BaseModel):
"""Решение по отметке: причина обязательна и остаётся в разборе."""
decision: FindingDecision
reason: str = Field(min_length=1, max_length=1000)
_reason = field_validator("reason")(_required_text)
class FindingIn(BaseModel):
"""Отметка преподавателя: код, факт и норма — то же обоснование, что у автоматической."""
code: ErrorCode
fact: str = Field(min_length=1, max_length=1000)
norm: str = Field(min_length=1, max_length=1000)
#: Номер карточки очереди ДДС (с 1); без него отметка относится к занятию.
card: int | None = Field(default=None, ge=1)
#: Ключ идемпотентности с клиента: повтор запроса не добавит отметку второй раз.
client_id: UUID | None = None
_fact = field_validator("fact")(_required_text)
_norm = field_validator("norm")(_required_text)
async def _change_findings(
session_id: UUID, who, db: AsyncSession | None, make,
) -> SessionReport:
"""Общий путь решений по отметкам — тот же, что у правки итога.
`make(who, score)` строит запись изменения по сохранённой оценке или
отвечает HTTP-ошибкой. Роль проверяет маршрут: администратору сюда нельзя,
как и к правке балла.
"""
state = hub.get(session_id)
if state is not None:
_require_access(who, state)
if state.score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
scenario = scoring_scenario(state)
if scenario is None:
raise HTTPException(status_code=409, detail="scenario_not_found")
change = make(who, state.score)
async with hub.operation(session_id):
if not _repeated(state.score, change):
change_findings(state, change)
return build_report(session_id, state, scenario)
if db is None:
raise HTTPException(status_code=404, detail="session_not_found")
session = await repo.get_session(db, session_id)
if session is None:
raise HTTPException(status_code=404, detail="session_not_found")
_require_access(who, session)
score = await db.scalar(select(Score).where(Score.session_id == session_id))
if score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
if (score.report or {}).get("full_report") is None:
raise HTTPException(status_code=409, detail="report_not_archived")
current = dict(score.report)
change = make(who, current)
await db.rollback() # чтение закончено; запись — одной транзакцией хранилища
if not _repeated(current, change):
await hub.store.commit_archived(session_id, [change])
# Ответ — по строке после commit: пересчёт шёл под блокировкой и мог учесть
# решение из соседней вкладки, которого не было в прочитанном выше отчёте.
score = await db.scalar(
select(Score).where(Score.session_id == session_id)
.execution_options(populate_existing=True)
)
report = SessionReport.model_validate(score.report["full_report"])
await db.rollback()
return report
def _repeated(report: dict, change: FindingReviewed | FindingAdded) -> bool:
return isinstance(change, FindingAdded) and has_finding(report, change.finding.client_id)
@router.post("/{session_id}/findings/{index}/review", response_model=SessionReport)
async def review_finding(
session_id: UUID,
index: int,
body: FindingReviewIn,
request: Request,
db: AsyncSession | None = Depends(optional_session),
) -> SessionReport:
"""Подтвердить или снять отметку; снятая не штрафует свою метрику."""
who = require(request, Role.INSTRUCTOR)
def make(who, score: dict) -> FindingReviewed:
if not 0 <= index < len(score.get("findings", [])):
raise HTTPException(status_code=404, detail="finding_not_found")
return FindingReviewed(index=index, role=who.role.value, review=FindingReview(
decision=body.decision, reason=body.reason, author=who.login, at=now_utc(),
))
return await _change_findings(session_id, who, db, make)
@router.post("/{session_id}/findings", response_model=SessionReport)
async def add_finding(
session_id: UUID,
body: FindingIn,
request: Request,
db: AsyncSession | None = Depends(optional_session),
) -> SessionReport:
"""Отметка преподавателя штрафует связанную метрику по той же методике."""
who = require(request, Role.INSTRUCTOR)
def make(who, score: dict) -> FindingAdded:
service = None
prefix = ""
exercise = (score.get("full_report") or {}).get("exercise")
# E1–E6 в связке 112 → ДДС — ошибки приёма вызова: их метрики у занятия,
# а не у карточки очереди. С номером карточки отметка не нашла бы свою
# метрику 112 и штрафовала бы отдельной метрикой преподавателя.
if (body.card is not None and body.code.value.startswith("E")
and exercise != Exercise.DDS.value):
raise HTTPException(status_code=422, detail="call_code_without_card")
if body.card is not None:
cards = score.get("card_results") or []
if body.card > len(cards):
raise HTTPException(status_code=422, detail="card_not_found")
service = cards[body.card - 1].get("managed_service")
prefix = f"Карточка {body.card}: " + (f"{service}: " if service else "")
competency = next((item for code, item in METRIC_MAP.values() if code is body.code), None)
return FindingAdded(role=who.role.value, finding=Finding(
code=body.code,
source=FindingSource.INSTRUCTOR,
summary=f"{prefix}{ERRORS[body.code].title}",
fact=body.fact,
norm=body.norm,
ref="отметка преподавателя на разборе",
competency=competency,
at=now_utc(),
card=body.card,
service=service,
author=who.login,
client_id=body.client_id,
))
return await _change_findings(session_id, who, db, make)
@router.get("", response_model=list[SessionOut])
async def listing(
request: Request,

View file

@ -14,7 +14,7 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.api.ws.session import pump, run_command, session_socket
from app.api.ws.session import close_fenced, pump, run_command, session_socket
from app.domain.events import (
BgStart,
CallEnded,
@ -31,6 +31,7 @@ from app.domain.events import (
PatchSource,
ScoreReady,
SessionMode,
SessionPaused,
StationState,
TimerTick,
TextTurnAccepted,
@ -39,6 +40,7 @@ from app.domain.events import (
TraineeToServer,
)
from app.dialog.slots import TurnResult
from app.domain.kio import PatchRejected, get_field
from app.domain.roles import Role
from app.scenarios import store
from app.session.dds import prepare_handoff_queue
@ -191,6 +193,14 @@ async def _handle(session_id: UUID, state, event) -> None:
code=ErrorKind.UNSUPPORTED_EVENT, message="Занятие уже завершено",
))
return
if state.paused:
# Отказ временный, в отличие от отклонённого значения: `kio.patch`
# без `auto`-отката. useCall держит правку в pending и повторяет её
# после `session.paused: false` — откат стёр бы ввод курсанта.
hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT, message="Пауза, ждите преподавателя",
))
return
if (event.type == "text.turn" and state.exercise is not Exercise.CARD) or state.exercise is Exercise.DDS or (
state.exercise is Exercise.CARD and event.type not in {"kio.patch", "card.submit", "text.turn"}
) or (state.exercise is Exercise.CALL and event.type == "card.submit"):
@ -229,6 +239,9 @@ async def _handle(session_id: UUID, state, event) -> None:
code=ErrorKind.INTERNAL, message="Не удалось получить ответ заявителя. Попробуйте ещё раз.",
))
return
if state.paused:
# Ответ модели мог закончиться уже после команды преподавателя.
return
caller_entry = state.append(Speaker.CALLER, line.text, line.mood)
hub.to_trainee(session_id, CallerUtterance(
utterance_id=uuid4(), text=line.text,
@ -270,16 +283,37 @@ async def _handle(session_id: UUID, state, event) -> None:
case "kio.patch":
old_code, old_notify = state.kio.incident_code, list(state.kio.notify)
state.patch_kio(event.fields)
old_extra = list(state.kio.notify_extra)
try:
state.patch_kio(event.fields)
except (ValidationError, PatchRejected) as exc:
reason = exc.errors()[0].get("msg", "") if isinstance(exc, ValidationError) else str(exc)
# Недопустимое значение — отказ курсанту, а не сбой операции:
# сбой закрыл бы занятие на этом узле.
hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message=f"Поле карточки не принято: {reason}"[:200],
))
# Пакет отклонён целиком, а фронт держит его в pending до эха.
# Серверное значение с `auto` снимает pending — иначе на экране
# останутся правки, которых на сервере нет.
hub.to_trainee(session_id, KioPatchOut(
fields={path: get_field(state.kio, path) for path in event.fields},
source=PatchSource.AUTO,
))
return
hub.to_trainee(session_id, KioPatchOut(
fields=event.fields, source=PatchSource.OPERATOR,
))
auto: dict = {}
if (state.kio.incident_code, state.kio.notify) != (old_code, old_notify):
hub.to_trainee(session_id, KioPatchOut(
fields={"incident_code": state.kio.incident_code,
"notify": list(state.kio.notify)},
source=PatchSource.AUTO,
))
auto |= {"incident_code": state.kio.incident_code, "notify": list(state.kio.notify)}
# Добавки сервер чистит сам (повторы, службы из notify) — фронту
# нужно итоговое значение, а не эхо присланного.
if state.kio.notify_extra != event.fields.get("notify_extra", old_extra):
auto["notify_extra"] = list(state.kio.notify_extra)
if auto:
hub.to_trainee(session_id, KioPatchOut(fields=auto, source=PatchSource.AUTO))
# Наблюдателю уходит карточка целиком: рассинхрон на внешнем мониторе
# посреди занятия дороже лишних килобайт.
hub.to_observers(session_id, KioState(kio=state.kio))
@ -390,9 +424,16 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
)
if entered is None:
return
_who, state = entered
who, state = entered
with hub.trainee(session_id) as queue:
async with hub.trainee_socket(
session_id, station=False, trainee=who.role is Role.TRAINEE,
) as queue:
if hub.is_lease_fenced(session_id):
await close_fenced(ws)
return
# Переподключившийся клиент сначала узнаёт, разрешён ли ввод.
queue.put_nowait(SessionPaused(paused=state.paused))
if state.exercise is Exercise.CARD:
from app.api.ws.control import card_briefing
@ -435,6 +476,10 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
# Бинарные кадры — аудио, текстовые — события. Направление определяется
# каналом, обёртки JSON вокруг звука нет (docs/arch/CONTRACT.md).
if message.get("bytes") is not None:
if state.paused:
# JSON-команды отказывает `_handle`; бинарные кадры сюда
# не заходят, поэтому пауза глушит звук здесь же (lct-39).
continue
if state.exercise is Exercise.CALL:
_on_audio(session_id, state, message["bytes"])
else:

View file

@ -41,6 +41,7 @@ from app.domain.events import (
ModeSet,
ReferenceStarted,
SessionEnded,
SessionPaused,
StationState,
)
from app.domain.roles import Role
@ -51,7 +52,14 @@ from app.session.access import can_access
from app.session.hub import hub
from app.session.finish import end_session, override_score
from app.session.state import SessionState, now_utc
from app.session.store import LessonIdentity, LessonRequest, NoteAdded, ScoreOverridden
from app.session.store import (
LessonIdentity,
LessonPaused,
LessonRequest,
LessonResumed,
NoteAdded,
ScoreOverridden,
)
from app.voice.models import get_voice_models
from app.voice.pipeline import FILLERS, prefetch
@ -104,6 +112,7 @@ def _build_state(session_id: UUID, event, who, scenario, scenarios,
dds_service=identity.service or event.dds_service,
attempt=identity.attempt,
criteria=event.criteria,
socket_last_seen_at=now_utc(),
)
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
state.timers.limits[TimerCode.CARD_FILL] = event.criteria.card_fill_time_limit_seconds * 1000
@ -344,6 +353,26 @@ async def _command(session_id: UUID, event, who) -> None:
await _start(session_id, event, who)
case "session.stop":
await _stop(session_id)
case "session.pause":
if state is None or state.ended or state.paused:
return
state.pause()
hub.record(session_id, LessonPaused(
at=state.paused_at, author=who.login, role=who.role.value,
))
hub.broadcast(session_id, SessionPaused(paused=True))
hub.to_station(session_id, SessionPaused(paused=True))
case "session.resume":
if state is None or state.ended or not state.paused:
return
paused_ms_before = state.total_paused_ms
state.resume()
hub.record(session_id, LessonResumed(
at=now_utc(), author=who.login, role=who.role.value,
paused_ms=state.total_paused_ms - paused_ms_before,
))
hub.broadcast(session_id, SessionPaused(paused=False))
hub.to_station(session_id, SessionPaused(paused=False))
case "instructor_note.add":
if state is not None:
state.notes.append({
@ -390,6 +419,14 @@ async def _command(session_id: UUID, event, who) -> None:
case "director.inject":
if state is None:
return
if state.paused:
# Обрыв на паузе запустил бы норматив обратного дозвона, и простой
# ушёл бы в него; курсант же за баннером паузы не может ответить.
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
message="Занятие на паузе",
))
return
if state.exercise is not Exercise.CALL:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,

View file

@ -17,7 +17,7 @@ from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.api.ws.session import pump, run_command, session_socket
from app.api.ws.session import close_fenced, pump, run_command, session_socket
from app.domain.events import (
CallEndReason,
ErrorEvent,
@ -39,6 +39,9 @@ def _error(session_id: UUID, message: str) -> None:
async def _handle(session_id: UUID, state, event) -> None:
if state.paused:
_error(session_id, "Пауза, ждите преподавателя")
return
outcome = state.desk.apply(event, state)
if outcome.error is not None:
_error(session_id, outcome.error)
@ -59,9 +62,14 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
entered = await session_socket(ws, session_id, (Role.INSTRUCTOR, Role.TRAINEE))
if entered is None:
return
_who, state = entered
who, state = entered
with hub.station(session_id) as queue:
async with hub.trainee_socket(
session_id, station=True, trainee=who.role is Role.TRAINEE,
) as queue:
if hub.is_lease_fenced(session_id):
await close_fenced(ws)
return
sender = asyncio.create_task(pump(ws, queue))
try:
# Карточка, переданная до подключения станции, не теряется:

View file

@ -120,6 +120,11 @@ class Settings(BaseSettings):
def limit_ms(self, code: TimerCode) -> int:
return self.timer_limits_ms.get(code, NORMATIVES[code].limit_ms)
# Пороги сигналов реестра живых занятий (пульт преподавателя).
signal_backlog_threshold: int = Field(default=4, ge=1)
signal_refusals_threshold: int = Field(default=2, ge=1)
signal_offline_window_seconds: int = Field(default=45, ge=1)
def validate_deployment_security(self) -> None:
"""Reject known development authentication defaults on a production app."""
if self.app_env != "production":

View file

@ -15,6 +15,9 @@ from pathlib import Path
from pydantic import BaseModel, Field
DATA = Path(__file__).parent / "ekp.json"
#: Каталог окна «Добавьте службы» из «СЛУЖБЫ 112.docx»: 209 служб, в том числе
#: районные ДДС. Источник и лицензия — в шапке файла и в docs/delivery/THIRD-PARTY.md.
CATALOG = Path(__file__).parent / "services_catalog.json"
#: Часть строк книги существует ради интеграции с чужими системами, а оператору
#: не показывается — книга помечает их прямо в первом признаке.
@ -89,6 +92,37 @@ def reference() -> Reference:
return Reference.model_validate_json(DATA.read_text(encoding="utf-8"))
class CatalogService(BaseModel):
"""Служба каталога боевого АРМ — то, что оператор добавляет в карточку вручную."""
id: str
name: str
organization: str | None = None
#: «ДДС», если это явно указано в названии в каталоге.
service_level: str | None = None
#: Скриншот и строка списка в «СЛУЖБЫ 112.docx».
source_reference: str
class Catalog(BaseModel):
source: str
transcription: str
license: str
services: list[CatalogService]
@lru_cache(maxsize=1)
def catalog() -> Catalog:
"""Каталог только расширяет выбор служб. Список оповещения по-прежнему
считается из признаков по классификатору, эталоны сценариев от него не зависят."""
return Catalog.model_validate_json(CATALOG.read_text(encoding="utf-8"))
@lru_cache(maxsize=1)
def catalog_names() -> frozenset[str]:
return frozenset(service.name for service in catalog().services)
@lru_cache(maxsize=1)
def _by_code() -> dict[str, Incident]:
return {incident.code: incident for incident in reference().incidents}

View file

@ -23,7 +23,7 @@ from app.domain.roles import Role
from app.domain.statuses import ServiceStatus, StationSnapshot
from app.domain.taxonomy import Finding
from app.domain.timers import TimerSnapshot
from app.scoring.taxonomy import METRIC_MAP
from app.scoring.taxonomy import INSTRUCTOR_METRIC, METRIC_MAP
class SessionMode(StrEnum):
@ -53,6 +53,9 @@ class LessonCriteria(BaseModel):
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
card_fill_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
dds_card_work_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
#: Сколько секунд после доклада бригады даётся на соответствующий статус.
dds_report_reaction_enabled: bool = False
dds_report_reaction_limit_seconds: int = Field(default=45, ge=5, le=600)
allowed_errors: int = Field(default=0, ge=0, le=50)
require_correct_grammar: bool = True
score_weights: dict[str, float] = Field(default_factory=dict)
@ -60,7 +63,7 @@ class LessonCriteria(BaseModel):
@field_validator("score_weights")
@classmethod
def validate_score_weights(cls, weights: dict[str, float]) -> dict[str, float]:
unknown = weights.keys() - METRIC_MAP.keys()
unknown = weights.keys() - METRIC_MAP.keys() - {INSTRUCTOR_METRIC}
if unknown:
raise ValueError(f"неизвестные метрики весов: {', '.join(sorted(unknown))}")
if any(not 0 <= weight <= 10 for weight in weights.values()):
@ -274,6 +277,13 @@ class ErrorEvent(BaseModel):
message: str
class SessionPaused(BaseModel):
"""Пульт остановил или возобновил время занятия (lct-39)."""
type: Literal["session.paused"] = "session.paused"
paused: bool
ServerToTrainee = Annotated[
CallIncoming
| CardBriefing
@ -293,6 +303,7 @@ ServerToTrainee = Annotated[
| TimerTick
| CallEnded
| ScoreReady
| SessionPaused
| ErrorEvent,
Field(discriminator="type"),
]
@ -404,6 +415,8 @@ class SessionSnapshot(BaseModel):
timers: list[TimerSnapshot]
hints_used: int = 0
ended: bool = False
#: Пульт остановил время занятия (lct-39).
paused: bool = False
class TranscriptAppend(BaseModel):
@ -458,6 +471,7 @@ ServerToObserver = Annotated[
| ScoreReady
| InstructorNoteShown
| ReferenceStarted
| SessionPaused
| ErrorEvent,
Field(discriminator="type"),
]
@ -518,13 +532,25 @@ class SessionStop(BaseModel):
type: Literal["session.stop"] = "session.stop"
class SessionPause(BaseModel):
"""Остановить время занятия; карточку курсанта команда не меняет (lct-39)."""
type: Literal["session.pause"] = "session.pause"
class SessionResume(BaseModel):
type: Literal["session.resume"] = "session.resume"
InstructorToServer = Annotated[
ScenarioStart
| DirectorInject
| ReferencePlay
| InstructorNoteAdd
| ScoreOverride
| SessionStop,
| SessionStop
| SessionPause
| SessionResume,
Field(discriminator="type"),
]
@ -665,7 +691,7 @@ class CrewArrived(BaseModel):
ServerToStation = Annotated[
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded
| ScoreReady | CommandAck | ErrorEvent,
| ScoreReady | CommandAck | SessionPaused | ErrorEvent,
Field(discriminator="type"),
]
@ -692,6 +718,12 @@ class Metric(BaseModel):
passed: bool
weight: float = 1.0
credit: float | None = Field(default=None, ge=0, le=1)
#: Номер карточки очереди ДДС (с 1) и служба — область метрики для разбора.
card: int | None = None
service: str | None = None
#: Автоматический вердикт, если его изменило решение преподавателя по отметке.
passed_auto: bool | None = None
credit_auto: float | None = Field(default=None, ge=0, le=1)
class CompetencyScore(BaseModel):
@ -741,6 +773,8 @@ class DdsCardReport(BaseModel):
card_id: UUID
scenario_id: str
score_auto: float
#: Балл карточки после решений преподавателя по отметкам; None — решений не было.
score_reviewed: float | None = None
reply_text: str
metrics: list[Metric]
findings: list[Finding]
@ -780,9 +814,13 @@ class SessionReport(BaseModel):
self_assessment_diff: SelfAssessmentDiff | None = None
notes: list[InstructorNoteShown] = []
score_auto: float
#: Балл после решений преподавателя по отметкам; None — решений не было.
score_reviewed: float | None = None
score_final: float
overridden_by: str | None = None
override_comment: str | None = None
#: Суммарная длительность пауз преподавателя — время объяснимо (lct-39).
total_paused_ms: int = 0
class Principal(BaseModel):

View file

@ -112,12 +112,20 @@ class KIO(BaseModel):
description: str | None = None
victims_count: int | None = None
is_emergency: bool = False
offence_signs: bool | None = None
life_threat: bool = False
evacuation_needed: bool = False
# ДДС. `notify` считается из признаков и правится только системой:
# в боевом АРМ оператор может добавить службу вручную, но не удалить.
notify: list[str] = Field(default_factory=list, description="Список оповещения")
#: Службы, добавленные оператором вручную из каталога. Автоматический список
#: не заменяют и в оценку по эталону не входят. Каталог проверяется на входе
#: (apply_patch), а не здесь: иначе правка названия в каталоге сломала бы
#: загрузку сохранённых снимков занятий и сданных карточек.
notify_extra: list[str] = Field(
default_factory=list, max_length=20, description="Дополнительно оповестить"
)
dds: DDSCode | None = Field(
default=None, description="Устаревшее: одна служба. Заменено списком оповещения"
)
@ -146,8 +154,8 @@ EDITABLE_KIO_FIELDS: frozenset[str] = frozenset({
"caller_name", "caller_contact", "phone_on_scene", "language",
"okato", "address", "street", "building", "entrance", "floor",
"intercom_code", "description", "incident_group", "signs",
"incident_type", "victims_count", "is_emergency", "life_threat",
"evacuation_needed", "dds",
"incident_type", "victims_count", "is_emergency", "offence_signs", "life_threat",
"evacuation_needed", "dds", "notify_extra",
"fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified",
"fire.people_inside", "fire.smoke_spread",
"police.offence_kind", "police.suspects", "police.suspect_fled",
@ -186,7 +194,10 @@ def missing_fields(card: KIO, required: list[str]) -> list[str]:
#: те же «признаки», только не про тип происшествия, а про обстоятельства:
#: пострадавшие поднимают скорую, газификация — МОСГАЗ (domain/ekp.py).
MODIFIER_FIELDS: frozenset[str] = frozenset(
{"incident_group", "signs", "victims_count", "life_threat", "evacuation_needed", "fire.gasified"}
{
"incident_group", "signs", "victims_count", "life_threat",
"evacuation_needed", "fire.gasified", "offence_signs", "police.offence_kind",
}
)
@ -201,6 +212,12 @@ def modifiers(card: KIO) -> list[str]:
keys.append("evacuation")
if card.fire is not None and card.fire.gasified:
keys.append("gas")
if card.offence_signs is True or (
card.police is not None
and card.police.offence_kind is not None
and bool(card.police.offence_kind.strip())
):
keys.append("offence")
return keys
@ -223,11 +240,40 @@ def derive_incident(card: KIO) -> KIO:
return KIO.model_validate(data)
class PatchRejected(ValueError):
"""Дельта `kio.patch` не принята целиком: карточка не изменилась."""
def _check_catalog(names: Any) -> None:
"""Только службы каталога: свободный текст в адресатах карточки
на АРМ ДДС выглядел бы как настоящая служба."""
from app.domain import ekp
if not isinstance(names, list):
return # тип и лимит проверит модель
known = ekp.catalog_names()
unknown = [name for name in names if name not in known]
if unknown:
raise PatchRejected(f"нет в каталоге служб: {', '.join(map(str, unknown[:3]))}")
def _without_auto_services(card: KIO) -> KIO:
"""Добавки без повторов и без служб, которые уже есть в списке оповещения:
иначе на АРМ ДДС одна служба окажется в двух строках."""
extra = [name for name in dict.fromkeys(card.notify_extra) if name not in card.notify]
if extra == card.notify_extra:
return card
return card.model_copy(update={"notify_extra": extra})
def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
"""Применить дельту `kio.patch`. Служебные поля игнорируются.
Вложенные поля приходят плоским путём: {"fire.floors": 5}.
Недопустимое значение — `PatchRejected` или `ValidationError`.
"""
if "notify_extra" in fields:
_check_catalog(fields["notify_extra"])
data = card.model_dump()
for path, value in fields.items():
if path in READ_ONLY_FIELDS:
@ -243,4 +289,4 @@ def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
# Пересчитываем не только на смену признаков: пострадавшие поднимают скорую,
# газификация — МОСГАЗ, и список оповещения обязан это отразить сразу.
touched = MODIFIER_FIELDS & set(fields)
return derive_incident(updated) if touched else updated
return _without_auto_services(derive_incident(updated) if touched else updated)

File diff suppressed because it is too large Load diff

View file

@ -276,3 +276,5 @@ class StationSnapshot(BaseModel):
pending_cards_count: int = 0
next_arrival_in_seconds: int | None = None
max_waiting_cards: int = 3
#: Пульт на паузе: время не идёт, новая карточка не поступит (lct-39).
paused: bool = False

View file

@ -7,8 +7,10 @@
from datetime import datetime
from enum import StrEnum
from typing import Any
from uuid import UUID
from pydantic import BaseModel
from pydantic import BaseModel, model_validator
class ErrorCode(StrEnum):
@ -152,6 +154,22 @@ COMPETENCY_LABELS: dict[Competency, str] = {
}
class FindingDecision(StrEnum):
"""Решение преподавателя по отметке на разборе."""
CONFIRMED = "confirmed"
DISMISSED = "dismissed"
class FindingReview(BaseModel):
"""Кто, когда и почему подтвердил или снял отметку. Сама отметка остаётся."""
decision: FindingDecision
reason: str
author: str
at: datetime
class Finding(BaseModel):
"""Отметка в разборе. `fact` и `norm` — то самое обоснование."""
@ -164,3 +182,32 @@ class Finding(BaseModel):
competency: Competency | None = None
transcript_ref: str | None = None
at: datetime | None = None
#: Метрика, чей провал объясняет отметка. Без неё связь с метриками идёт
#: по методике METRIC_MAP в пределах той же карточки и службы.
metric_key: str | None = None
#: Номер карточки очереди ДДС (с 1) и служба, к которым относится отметка.
card: int | None = None
service: str | None = None
#: Автор ручной отметки преподавателя (`source=instructor`).
author: str | None = None
#: Ключ идемпотентности ручной отметки: повтор запроса не добавляет её снова.
client_id: UUID | None = None
#: Все решения по отметке по порядку; действует последнее. Прежние не
#: затираются: «кто, когда, почему» нужно по каждой правке, а не по итогу.
reviews: list[FindingReview] = []
@model_validator(mode="before")
@classmethod
def _single_review(cls, data: Any) -> Any:
# Разборы, сохранённые до истории решений, хранили одно поле `review`.
if isinstance(data, dict) and "review" in data:
data = dict(data)
single = data.pop("review")
if single and not data.get("reviews"):
data["reviews"] = [single]
return data
@property
def review(self) -> FindingReview | None:
"""Действующее решение — последнее."""
return self.reviews[-1] if self.reviews else None

View file

@ -30,6 +30,7 @@ def evaluate_card(scenario: Scenario, kio: KIO) -> GostResult:
source=FindingSource.KIO if code.value == "E5" else FindingSource.GROUND_TRUTH,
summary=f"{title}: {fact}; требуется {norm}",
fact=fact, norm=norm, ref=ref, competency=competency,
metric_key=key,
))
if truth.incident_code:

View file

@ -11,31 +11,138 @@
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
"""
import math
from collections.abc import Sequence
from datetime import datetime
from app.domain.statuses import (
COMMENT_REQUIRED,
PRIMARY,
REFUSAL_COMMENT_REQUIRED,
SERVICE_STATUS_LABELS,
PhoneReportRecord,
ServiceStatus,
StatusEntry,
current,
)
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.events import Metric
from app.domain.timers import GOST_REF
from app.domain.timers import GOST_REF, TimerCode
from app.scoring.timing import TimeNorm, time_metric
MEMO_REF = "памятка «Работа на АРМ-112», раздел «Статусы реагирования»"
#: Статусы, означающие, что реагирование действительно шло.
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
#: Статус, который диспетчер отмечает по докладу бригады этой фазы.
REPORT_STATUS = {
"dispatched": ServiceStatus.RESPONDING,
"arrived": ServiceStatus.ARRIVED,
"working": ServiceStatus.WORKING,
"completed": ServiceStatus.COMPLETED,
}
REPORT_LABELS = {
"dispatched": "о выезде",
"arrived": "о прибытии",
"working": "о начале работ",
"completed": "о завершении работ",
}
#: Лимит реакции на доклад по умолчанию; занятие задаёт свой в критериях.
REPORT_REACTION_MS = 45_000
#: Реакция на доклад — норматив времени, как отработка карточки, но отметка
#: D6 из памятки: опоздавший статус — тот же неполный ход работ.
DDS_REPORT_REACTION = TimeNorm(
key="dds_report_reaction",
title="реакция на доклад бригады",
summary="статус по докладу бригады отмечен с нарушением норматива",
action="отметить статус по докладу",
unmeasured="не отмечен",
incomplete="не отмечен",
code=ErrorCode.D6,
source=FindingSource.DISPATCHER,
ref=MEMO_REF,
)
#: Терминальный отказ: работ не было, поэтому неотмеченный по докладу статус
#: не штрафуется ни отметкой, ни метрикой.
TERMINAL_REFUSAL = {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
def _finding(code: ErrorCode, summary: str, fact: str, norm: str | None = None) -> Finding:
def report_reactions(
marks: list[StatusEntry], reports: list[PhoneReportRecord],
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[tuple[PhoneReportRecord, int | None]]:
"""Задержка статуса после каждого доклада бригады, мс; None — статус не отмечен.
Отсчёт — от серверного времени доклада. Статус, отмеченный раньше доклада,
задержки не имеет: диспетчер узнал о ходе работ по другому каналу.
"""
result: list[tuple[PhoneReportRecord, int | None]] = []
for report in reports:
status = REPORT_STATUS.get(report.phase)
if status is None:
continue
times = [mark.at for mark in marks if mark.status is status]
if any(at <= report.at for at in times):
result.append((report, 0))
continue
after = min(times, default=None)
if after is None:
result.append((report, None))
continue
elapsed_ms = int((after - report.at).total_seconds() * 1000)
for start, end in pause_intervals or ():
overlap_start = max(report.at, start)
overlap_end = min(after, end)
if overlap_end > overlap_start:
elapsed_ms -= int((overlap_end - overlap_start).total_seconds() * 1000)
result.append((report, max(0, elapsed_ms)))
return result
def report_reaction(
service: str, marks: list[StatusEntry], reports: list[PhoneReportRecord], limit_ms: int,
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> tuple[Metric, Finding | None] | None:
"""Метрика и отметка D6 по самой поздней реакции службы на доклады бригады.
Одна функция для отметок и для балла: иначе метрика провалится там,
где отметки нет. None — реагировать было не на что.
"""
refused = current(marks, service) in TERMINAL_REFUSAL
reactions = [(report, delay) for report, delay in report_reactions(marks, reports, pause_intervals)
if delay is not None or not refused]
if not reactions:
return None
report, delay = max(reactions, key=lambda item: math.inf if item[1] is None else item[1])
metric, finding = time_metric(DDS_REPORT_REACTION, delay, limit_ms, completed=True)
label = SERVICE_STATUS_LABELS[REPORT_STATUS[report.phase]]
detail = "отмечен не позже доклада" if delay == 0 else metric.fact
fact = (f"статус «{label}» по докладу бригады «{report.crew}» "
f"{REPORT_LABELS[report.phase]}: {detail}")
if len(reactions) > 1:
late = sum(item is None or item > limit_ms for _, item in reactions)
fact += f"; с нарушением {late} из {len(reactions)} докладов"
metric = metric.model_copy(update={
"title": f"{service}: {metric.title}", "fact": fact, "service": service,
})
if finding is not None:
finding = finding.model_copy(update={
"summary": f"{service}: {finding.summary}", "fact": fact, "service": service,
})
return metric, finding
def _finding(service: str, code: ErrorCode, summary: str, fact: str,
norm: str | None = None) -> Finding:
return Finding(
service=service,
code=code,
source=FindingSource.DISPATCHER,
summary=summary,
fact=fact,
norm=norm,
ref="памятка «Работа на АРМ-112», раздел «Статусы реагирования»",
ref=MEMO_REF,
competency=(
Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD
),
@ -91,6 +198,10 @@ def evaluate_dispatcher(
reply_text: str = "",
expected_decision: str = "accept",
expected_decision_reason: str | None = None,
phone_reports: list[PhoneReportRecord] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
report_reaction_enabled: bool = False,
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[Finding]:
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
@ -117,7 +228,7 @@ def evaluate_dispatcher(
]
if missing_comment_statuses:
findings.append(_finding(
ErrorCode.D5,
service, ErrorCode.D5,
f"{service}: к статусу не добавлены основание и сведения",
fact="не заполнены комментарии: " + ", ".join(missing_comment_statuses),
norm="к каждой ручной отметке добавить основание и содержание полученных сведений",
@ -132,7 +243,7 @@ def evaluate_dispatcher(
]
if incomplete_comment_statuses:
findings.append(_finding(
ErrorCode.D5,
service, ErrorCode.D5,
f"{service}: комментарии к статусам не разделяют основание и сведения",
fact="неполные комментарии: " + ", ".join(incomplete_comment_statuses),
norm="в каждом комментарии заполнить отдельные поля «Основание» и «Сведения»",
@ -152,7 +263,7 @@ def evaluate_dispatcher(
]
if missing_recipients:
findings.append(_finding(
ErrorCode.D5,
service, ErrorCode.D5,
f"{service}: отдельный комментарий не указывает получателя сведений",
fact="; ".join(missing_recipients)[:500],
norm=(
@ -166,7 +277,7 @@ def evaluate_dispatcher(
if primary is None:
findings.append(
_finding(
ErrorCode.D1,
service, ErrorCode.D1,
f"{service}: статус реагирования не проставлен",
fact=(
f"прошло {elapsed_ms // 1000} с"
@ -181,7 +292,7 @@ def evaluate_dispatcher(
if elapsed_ms is None or elapsed_ms > deadline_ms:
findings.append(
_finding(
ErrorCode.D1,
service, ErrorCode.D1,
f"{service}: первичный статус проставлен с нарушением срока",
fact=(
f"прошло {elapsed_ms // 1000} с"
@ -197,7 +308,7 @@ def evaluate_dispatcher(
if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
findings.append(
_finding(
ErrorCode.D4,
service, ErrorCode.D4,
f"{service}: «{SERVICE_STATUS_LABELS[mark.status]}» без комментария",
fact="причина отказа не указана",
norm="комментарий к отказу обязателен",
@ -209,7 +320,7 @@ def evaluate_dispatcher(
if latest is ServiceStatus.DECLINED and expected_decision == "accept":
findings.append(
_finding(
ErrorCode.D3,
service, ErrorCode.D3,
f"{service}: отказ от происшествия, которое в её компетенции",
fact="служба есть в списке оповещения по ЕКП",
norm="принять согласно эталону сценария",
@ -218,7 +329,7 @@ def evaluate_dispatcher(
elif latest is ServiceStatus.ACCEPTED and expected_decision == "decline":
findings.append(
_finding(
ErrorCode.D2,
service, ErrorCode.D2,
f"{service}: принято вопреки эталону сценария",
fact="карточка принята службой",
norm=expected_decision_reason or "отказать с указанной причиной",
@ -232,7 +343,7 @@ def evaluate_dispatcher(
if service not in crew_assignments:
findings.append(
_finding(
ErrorCode.D2,
service, ErrorCode.D2,
f"{service}: «Принята» без назначения бригады и хода реагирования",
fact="бригада не выбрана, после приёма статусов не было",
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
@ -241,7 +352,7 @@ def evaluate_dispatcher(
else:
findings.append(
_finding(
ErrorCode.D2,
service, ErrorCode.D2,
f"{service}: «Принята», но о реагировании ничего не отмечено",
fact="после приёма статусов не было",
norm="статус должен соответствовать фактическому состоянию заявки",
@ -252,7 +363,7 @@ def evaluate_dispatcher(
) and service not in crew_assignments:
findings.append(
_finding(
ErrorCode.D2,
service, ErrorCode.D2,
f"{service}: ход реагирования без назначения бригады",
fact="статусы хода работ проставлены, бригада не выбрана",
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
@ -262,15 +373,18 @@ def evaluate_dispatcher(
# D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке
# это приводит к повторным звонкам и скрывает от других служб факт реакции.
# REFUSED — отдельный допустимый терминальный статус с обязательной причиной.
reports = [report for report in phone_reports or () if report.service == service]
missing_progress = [status for status in PROGRESS if status not in {m.status for m in marks}]
if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
and (missing_progress or latest is not ServiceStatus.COMPLETED)):
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
reported_done = any(report.phase == "completed" for report in reports)
findings.append(
_finding(
ErrorCode.D6,
service, ErrorCode.D6,
f"{service}: ход реагирования не доведён до конца",
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"),
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта")
+ ("; бригада доложила о завершении работ" if reported_done else ""),
norm=(
"отметить по факту начало реагирования, прибытие, проведение работ "
"и завершение; если работы не проводились — оформить отказ с причиной"
@ -278,16 +392,25 @@ def evaluate_dispatcher(
)
)
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
if report_reaction_enabled:
reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals)
if reaction is not None and reaction[1] is not None:
findings.append(reaction[1])
return findings
def dispatcher_metrics(
state,
card,
deadline_ms: int,
expected_decision: str = "accept",
expected_decision_reason: str | None = None,
*,
services: list[str] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
report_reaction_enabled: bool = False,
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
@ -295,20 +418,21 @@ def dispatcher_metrics(
источник информации, но не обязательный шлюз статуса.
"""
metrics: list[Metric] = []
for service in (services if services is not None else state.managed_services()):
marks = [mark for mark in state.status_log if mark.service == service]
# Через таймер, а не `primary.at - dispatched_at`: календарная разница
# включила бы паузу преподавателя в норматив (lct-39).
elapsed = card.timers.measured_ms(TimerCode.DDS_ACK)
for service in (services if services is not None else card.managed_services()):
marks = [mark for mark in card.status_log if mark.service == service]
statuses = {mark.status for mark in marks}
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
metrics.append(Metric(
key=key, title=f"{service}: {title}", fact=fact, norm=norm,
key=key, title=f"{service}: {title}", fact=fact, norm=norm, service=service,
ref=GOST_REF if key == "dds_ack" else "памятка «Работа на АРМ-112», статусы реагирования",
passed=passed, weight=weight,
))
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
elapsed = (int((primary.at - state.dispatched_at).total_seconds() * 1000)
if primary and state.dispatched_at else None)
add("dds_primary", "решение по карточке", primary is not None,
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с",
@ -317,6 +441,13 @@ def dispatcher_metrics(
f"≤ {deadline_ms // 1000} с")
if primary is None:
continue
# Метрика есть только там, где бригада докладывала: без докладов
# реагировать не на что, и отсутствие звонков не штраф.
reports = [report for report in card.phone_reports if report.service == service]
if report_reaction_enabled:
reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals)
if reaction is not None:
metrics.append(reaction[0])
expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"
else ServiceStatus.DECLINED)
expected_label = "Принята" if expected_decision == "accept" else "Не принята"
@ -326,7 +457,7 @@ def dispatcher_metrics(
expected_decision_reason or f"по эталону ожидается «{expected_label}»", 2.0)
if primary.status is ServiceStatus.DECLINED or expected_decision == "decline":
continue
crew = state.crew_assignments.get(service)
crew = card.crew_assignments.get(service)
add("dds_crew", "назначение бригады", bool(crew),
crew or "бригада не выбрана",
"необходимую бригаду выбирает ДДС вручную", 2.0)

View file

@ -11,6 +11,7 @@ from html import escape
from pathlib import Path
from app.domain.events import SessionReport
from app.domain.taxonomy import Finding, FindingDecision, FindingSource
def _cell(value: object) -> str:
@ -18,12 +19,27 @@ def _cell(value: object) -> str:
if value is None:
return ""
result = str(value)
probe = result.lstrip(" \t\r\n\ufeff\u200b")
probe = result.lstrip(" \t\r\n\N{ZERO WIDTH NO-BREAK SPACE}\N{ZERO WIDTH SPACE}")
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
return "'" + result
return result
def _finding_origin(item: Finding) -> str:
if item.source is FindingSource.INSTRUCTOR:
return f"; добавлена преподавателем {item.author or ''}".rstrip()
return ""
def _finding_decision(item: Finding) -> str:
"""Решения по отметке рядом с ней, все по порядку: действует последнее."""
return "".join(
f"; {'снята' if review.decision is FindingDecision.DISMISSED else 'подтверждена'} "
f"преподавателем {review.author}: {review.reason}"
for review in item.reviews
)
def to_csv(report: SessionReport) -> bytes:
"""UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки."""
output = io.StringIO(newline="")
@ -38,12 +54,16 @@ def to_csv(report: SessionReport) -> bytes:
row("Занятие", "", "Режим", report.mode.value)
row("Занятие", "", "Попытка", report.attempt)
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
row("Критерии", "", "Лимит реакции на доклад бригады, с",
report.criteria.dds_report_reaction_limit_seconds
if report.criteria.dds_report_reaction_enabled else "не применяется")
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
row("Критерии", "", "Нарушенных метрик", report.failed_metrics)
row("Критерии", "", "Зачёт", "да" if report.passed else "нет")
row("Оценка", "", "Автоматическая", report.score_auto)
row("Оценка", "", "После разбора отметок", report.score_reviewed)
row("Оценка", "", "Итоговая", report.score_final)
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
row("Оценка", "", "Причина изменения", report.override_comment)
@ -54,7 +74,9 @@ def to_csv(report: SessionReport) -> bytes:
f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; "
f"вес: {item.weight:g}{credit}; источник: {item.ref or ''}")
for number, item in enumerate(report.findings, 1):
row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}")
row("Ошибки", number, item.code.value, item.summary,
f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}"
f"{_finding_origin(item)}{_finding_decision(item)}")
for number, item in enumerate(report.competencies, 1):
row("Компетенции", number, item.competency, item.value)
for number, item in enumerate(report.transcript, 1):
@ -77,7 +99,7 @@ def to_csv(report: SessionReport) -> bytes:
row("Сверка самооценки", "", label, ", ".join(values))
for number, item in enumerate(report.notes, 1):
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
return ("\ufeff" + output.getvalue()).encode("utf-8")
return ("\N{ZERO WIDTH NO-BREAK SPACE}" + output.getvalue()).encode("utf-8")
def _font_paths() -> tuple[Path, Path | None]:
@ -151,6 +173,9 @@ def to_pdf(report: SessionReport) -> bytes:
("Режим", report.mode.value),
("Попытка", report.attempt),
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
("Лимит реакции на доклад бригады",
f"{report.criteria.dds_report_reaction_limit_seconds} с"
if report.criteria.dds_report_reaction_enabled else "не применяется"),
("Допустимо ошибок", report.criteria.allowed_errors),
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
("Переопределения весов", report.criteria.score_weights or "стандартные"),
@ -160,6 +185,9 @@ def to_pdf(report: SessionReport) -> bytes:
("Итоговая оценка", report.score_final),
):
story.append(Paragraph(f"<b>{escape(label)}:</b> {escape(str(value))}", base))
# Когда итог правили вручную, без этой строки не понять, какой балл дал разбор.
if report.score_reviewed is not None:
story.append(Paragraph(f"<b>После разбора отметок:</b> {report.score_reviewed}", base))
if report.overridden_by:
story.append(Paragraph(f"<b>Оценка изменена:</b> {escape(report.overridden_by)}", base))
if report.override_comment:
@ -183,7 +211,9 @@ def to_pdf(report: SessionReport) -> bytes:
story.append(p("Ошибок не выявлено", muted))
for item in report.findings:
story.append(p(f"{item.code.value} - {item.summary}"))
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. Источник: {item.ref or 'не указан'}", muted))
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. "
f"Источник: {item.ref or 'не указан'}"
f"{_finding_origin(item)}{_finding_decision(item)}", muted))
section("Компетенции")
for item in report.competencies:

View file

@ -73,6 +73,7 @@ class _Builder:
norm=norm,
ref=ref,
competency=competency,
metric_key=key,
)
)
@ -174,6 +175,7 @@ def evaluate(
norm=f"эталонный вопрос: «{question}»" if question else "обязательный факт сценария",
ref="чек-лист сценария",
competency=Competency.INTERVIEW,
metric_key="checklist_completeness",
)
)

View file

@ -106,9 +106,11 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
self_assessment_diff=difference,
notes=[InstructorNoteShown.model_validate(note) for note in state.notes],
score_auto=score.get("score_auto", 0.0),
score_reviewed=score.get("score_reviewed"),
score_final=score.get("score_final", score.get("score_auto", 0.0)),
overridden_by=score.get("overridden_by"),
override_comment=score.get("override_comment"),
ai_coaching=(AICoaching.model_validate(score["ai_coaching"])
if score.get("ai_coaching") else None),
total_paused_ms=state.total_paused_ms,
)

View file

@ -0,0 +1,193 @@
"""Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку.
Баллы по-прежнему считает код. Преподаватель меняет входы — какие отметки
действуют, — а не формулу: балл пересчитывается теми же метриками и весами.
Связь «отметка → метрика» — методика `METRIC_MAP` в пределах карточки и службы;
если генератор знает метрику точно, она записана в `Finding.metric_key`.
Функции чистые и работают со словарём сохранённой оценки: живое занятие,
строка `Score` и архивный разбор меняются одним и тем же путём, как правка
итога в `apply_score_override`.
"""
from app.domain.events import Metric
from app.domain.taxonomy import Finding, FindingDecision, FindingReview, FindingSource
from app.scoring.competency import radar
from app.scoring.gost import GostResult
from app.scoring.taxonomy import INSTRUCTOR_METRIC, METRIC_MAP
__all__ = ["INSTRUCTOR_METRIC", "add_finding", "has_finding", "linked", "review_finding",
"reviewed_metrics"]
def _in_scope(finding: Finding, metric: Metric) -> bool:
if finding.card != metric.card:
return False
return finding.service is None or metric.service is None or finding.service == metric.service
def linked(finding: Finding, metric: Metric) -> bool:
"""Отметка объясняет провал этой метрики."""
if not _in_scope(finding, metric):
return False
if finding.metric_key is not None:
return finding.metric_key == metric.key
mapping = METRIC_MAP.get(metric.key)
return mapping is not None and mapping[0] is finding.code
def _dismissed(finding: Finding) -> bool:
return finding.review is not None and finding.review.decision is FindingDecision.DISMISSED
def _credit(metric: Metric) -> float:
return metric.credit if metric.credit is not None else float(metric.passed)
def reviewed_metrics(metrics: list[Metric], findings: list[Finding],
instructor_weight: float = 1.0) -> list[Metric]:
"""Метрики с учётом решений преподавателя.
Проваленная метрика засчитывается, только если все её автоматические
отметки сняты. Ручная отметка проваливает связанные метрики; если их нет,
появляется отдельная метрика преподавателя. Исходный вердикт остаётся в
`passed_auto`/`credit_auto`, повторный пересчёт начинается с него.
"""
base = [
metric.model_copy(update={
"passed": metric.passed_auto if metric.passed_auto is not None else metric.passed,
"credit": metric.credit_auto if metric.passed_auto is not None else metric.credit,
"passed_auto": None,
"credit_auto": None,
})
for metric in metrics
if metric.key != INSTRUCTOR_METRIC
]
result: list[Metric] = []
for metric in base:
links = [finding for finding in findings if linked(finding, metric)]
manual = [finding for finding in links
if finding.source is FindingSource.INSTRUCTOR and not _dismissed(finding)]
automatic = [finding for finding in links if finding.source is not FindingSource.INSTRUCTOR]
if manual and _credit(metric) > 0:
metric = metric.model_copy(update={
"passed": False, "credit": 0.0,
"passed_auto": metric.passed, "credit_auto": metric.credit,
})
elif (not manual and _credit(metric) < 1 and automatic
and all(_dismissed(finding) for finding in automatic)):
metric = metric.model_copy(update={
"passed": True, "credit": 1.0,
"passed_auto": metric.passed, "credit_auto": metric.credit,
})
result.append(metric)
for finding in findings:
if (finding.source is FindingSource.INSTRUCTOR and not _dismissed(finding)
and not any(linked(finding, metric) for metric in base)):
result.append(Metric(
key=INSTRUCTOR_METRIC,
title=f"Отметка преподавателя: {finding.summary}",
fact=finding.fact,
norm=finding.norm or "по решению преподавателя",
ref=finding.ref,
passed=False,
weight=instructor_weight,
card=finding.card,
service=finding.service,
))
return result
def _instructor_weight(report: dict) -> float:
"""Вес метрики преподавателя из весов занятия: веса настраивает преподаватель."""
criteria = (report.get("full_report") or {}).get("criteria") or {}
return float((criteria.get("score_weights") or {}).get(INSTRUCTOR_METRIC, 1.0))
def _codes(findings: list[Finding]) -> dict[str, int]:
"""Коды действующих отметок: по ним строятся частые ошибки группы и профиль."""
codes: dict[str, int] = {}
for finding in findings:
if not _dismissed(finding):
codes[finding.code.value] = codes.get(finding.code.value, 0) + 1
return codes
def _cards(cards: list[dict], metrics: list[Metric], findings: list[Finding]) -> list[dict]:
"""Карточки очереди ДДС с тем же разбором: их метрики — часть метрик занятия."""
updated = []
for number, card in enumerate(cards, 1):
own = [metric for metric in metrics if metric.card == number]
updated.append({
**card,
"metrics": [metric.model_dump(mode="json") for metric in own],
"findings": [finding.model_dump(mode="json")
for finding in findings if finding.card == number],
"score_reviewed": GostResult(metrics=own).score,
})
return updated
def _rescore(report: dict, findings: list[Finding]) -> dict:
metrics = reviewed_metrics(
[Metric.model_validate(item) for item in report.get("metrics", [])], findings,
_instructor_weight(report),
)
score = GostResult(metrics=metrics).score
fields = {
"findings": [finding.model_dump(mode="json") for finding in findings],
"metrics": [metric.model_dump(mode="json") for metric in metrics],
"competencies": [item.model_dump() for item in radar(metrics)],
"score_reviewed": score,
}
# Правка итога — последнее слово преподавателя; решение по отметке её не отменяет.
if not report.get("overridden_by"):
fields["score_final"] = score
updated = {**report, **fields}
if "summary" in report:
updated["summary"] = {**(report.get("summary") or {}), "codes": _codes(findings)}
if report.get("card_results"):
updated["card_results"] = _cards(report["card_results"], metrics, findings)
archived = report.get("full_report")
if isinstance(archived, dict):
failed = sum(not metric.passed and metric.weight > 0 for metric in metrics)
allowed = (archived.get("criteria") or {}).get("allowed_errors", 0)
updated["full_report"] = {
**archived, **fields,
"card_results": _cards(archived.get("card_results") or [], metrics, findings),
"failed_metrics": failed,
"passed": failed <= allowed,
"score_final": updated.get("score_final", archived.get("score_final")),
}
return updated
def _findings(report: dict) -> list[Finding]:
return [Finding.model_validate(item) for item in report.get("findings", [])]
def review_finding(report: dict, index: int, review: FindingReview) -> dict:
"""Подтвердить или снять отметку. Решение дописывается, действует последнее."""
findings = _findings(report)
if not 0 <= index < len(findings):
raise LookupError(f"нет отметки {index}")
findings[index] = findings[index].model_copy(
update={"reviews": [*findings[index].reviews, review]})
return _rescore(report, findings)
def has_finding(report: dict, client_id) -> bool:
"""Ручная отметка с этим ключом уже в разборе — повтор запроса."""
return client_id is not None and any(
finding.client_id == client_id for finding in _findings(report))
def add_finding(report: dict, finding: Finding) -> dict:
"""Добавить отметку преподавателя; она штрафует связанную метрику."""
if finding.source is not FindingSource.INSTRUCTOR:
raise ValueError("вручную добавляется только отметка преподавателя")
# Повтор (ретрай сети, вторая вкладка) не штрафует второй раз.
if has_finding(report, finding.client_id):
return report
return _rescore(report, [*_findings(report), finding])

View file

@ -31,10 +31,16 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
"dds_progress": (ErrorCode.D6, Competency.CARD),
"dds_completion": (ErrorCode.D6, Competency.CARD),
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
"dds_report_reaction": (ErrorCode.D6, Competency.NORMS),
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
}
#: Метрика ручной отметки, которой нечего провалить (у кода нет метрики в
#: METRIC_MAP или в карточке она не считалась). Кода у неё нет: код несёт сама
#: отметка. Вес задаёт преподаватель в весах занятия, по умолчанию 1.
INSTRUCTOR_METRIC = "instructor_finding"
#: Вес метрики в детерминированной оценке.
#:
#: **Предварительные значения, требуют утверждения методистом.** Без весов все
@ -59,4 +65,5 @@ METRIC_WEIGHTS: dict[str, float] = {
"description_grammar": 1.0,
"card_fill_time": 1.5,
"dds_work_time": 1.5,
"dds_report_reaction": 1.5,
}

View file

@ -23,8 +23,8 @@ def time_credit(elapsed_ms: int | None, limit_ms: int) -> float:
@dataclass(frozen=True)
class TimeNorm:
"""Формулировки норматива времени: карточка 112 и карточка ДДС различаются
только словами, расчёт и отметка E3 у них общие."""
"""Формулировки норматива времени: нормативы различаются словами и кодом
отметки, расчёт вклада и формат факта у них общие."""
key: str
title: str
@ -32,6 +32,10 @@ class TimeNorm:
action: str
unmeasured: str
incomplete: str
code: ErrorCode = ErrorCode.E3
source: FindingSource = FindingSource.TIMERS
competency: Competency = Competency.NORMS
ref: str = TIME_REF
CARD_FILL_TIME = TimeNorm(
@ -55,7 +59,7 @@ DDS_WORK_TIME = TimeNorm(
def time_metric(
norm: TimeNorm, elapsed_ms: int | None, limit_ms: int, *, completed: bool
) -> tuple[Metric, Finding | None]:
"""Метрика норматива и отметка E3, если он не выполнен.
"""Метрика норматива и отметка (по умолчанию E3), если он не выполнен.
Незавершённая работа не получает временного вклада: быстрое, но брошенное
заполнение не должно выглядеть скоростью.
@ -64,13 +68,20 @@ def time_metric(
fact = norm.unmeasured
else:
delta_ms = elapsed_ms - limit_ms
# Превышение до полусекунды при округлении до целых читалось бы
# как «45 с (+0 с)» при нормативе 45 с — тогда нужна десятая доля.
precise = 0 < delta_ms <= 500
def seconds(ms: int) -> str:
return f"{ms / 1000:.1f}" if precise else f"{round(ms / 1000)}"
if delta_ms > 0:
deviation = f"+{round(delta_ms / 1000)} с сверх норматива"
deviation = f"+{seconds(delta_ms)} с сверх норматива"
elif delta_ms < 0:
deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива"
else:
deviation = "точно в норматив"
fact = f"{round(elapsed_ms / 1000)} с ({deviation})"
fact = f"{seconds(elapsed_ms)} с ({deviation})"
if not completed:
fact = f"{norm.incomplete}; {fact}"
norm_text = f"{norm.action} за {round(limit_ms / 1000)} с"
@ -80,7 +91,7 @@ def time_metric(
title=norm.title,
fact=fact,
norm=norm_text,
ref=TIME_REF,
ref=norm.ref,
passed=passed,
weight=METRIC_WEIGHTS[norm.key],
credit=time_credit(elapsed_ms, limit_ms) if completed else 0.0,
@ -88,11 +99,12 @@ def time_metric(
if passed:
return metric, None
return metric, Finding(
code=ErrorCode.E3,
source=FindingSource.TIMERS,
code=norm.code,
source=norm.source,
summary=norm.summary,
fact=fact,
norm=norm_text,
ref=TIME_REF,
competency=Competency.NORMS,
ref=norm.ref,
competency=norm.competency,
metric_key=norm.key,
)

View file

@ -446,6 +446,7 @@ class DdsDesk(BaseModel):
services[0], ServiceStatus.ACCEPTED, command.comment,
author="диспетчер",
)
session.consecutive_refusals = 0
except StatusError:
pass # статус уже стоит: повторное нажатие ничего не меняет
case "card.status":
@ -462,6 +463,13 @@ class DdsDesk(BaseModel):
# Первичный статус останавливает норматив 30 секунд.
if command.status in PRIMARY:
card.on_event("card.ack")
# Сигнал реестра "повторные отказы" считает подряд идущие
# отказы, а не сумму за занятие.
session.consecutive_refusals = (
session.consecutive_refusals + 1
if command.status is ServiceStatus.DECLINED
else 0
)
if command.status in {
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
}:

View file

@ -12,7 +12,7 @@ import logging
import time
from uuid import UUID
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded, SessionPaused
from app.domain.statuses import ServiceStatus, current
from app.domain.timers import TimerCode
from app.scoring.ai_coach import coach
@ -26,10 +26,14 @@ from app.session.hub import hub
from app.session.score import ScoringSnapshot, score_session, scoring_scenario
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
from app.session.store import (
FindingAdded,
FindingReviewed,
LessonEnded,
LessonResumed,
ScoreArchived,
ScoreCalculated,
ScoreOverridden,
apply_finding_change,
apply_score_override,
)
@ -45,6 +49,7 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
services = state.card_services(card)
decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK]
dds_decision = card.scenario.dds_decision
reaction_ms = state.criteria.dds_report_reaction_limit_seconds * 1000
findings = evaluate_dispatcher(
entries=card.status_log,
services=services,
@ -54,10 +59,16 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
reply_text=card.reply_text,
expected_decision=dds_decision.expected,
expected_decision_reason=dds_decision.reason,
phone_reports=card.phone_reports,
report_reaction_ms=reaction_ms,
report_reaction_enabled=state.criteria.dds_report_reaction_enabled,
pause_intervals=state.pause_intervals,
)
metrics = dispatcher_metrics(
card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
services=services,
services=services, report_reaction_ms=reaction_ms,
report_reaction_enabled=state.criteria.dds_report_reaction_enabled,
pause_intervals=state.pause_intervals,
)
weighted = GostResult(metrics=metrics, findings=findings)
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]
@ -100,9 +111,11 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
card_id=card.card_id,
scenario_id=card.scenario.id,
reply_text=card.reply_text,
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}"})
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}",
"card": number})
for metric in weighted.metrics],
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"})
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}",
"card": number})
for finding in weighted.findings],
actions=actions,
duration_ms=max(0, int((now_utc() - card.dispatched_at).total_seconds() * 1000)),
@ -181,7 +194,14 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
"""
if state.ended:
return
was_paused = state.paused
paused_ms_before = state.total_paused_ms
ended_at = state.end(reason)
if was_paused:
hub.record(session_id, LessonResumed(
at=ended_at, author="система", role="system",
paused_ms=state.total_paused_ms - paused_ms_before,
))
if state.exercise is Exercise.CARD and state.dispatched_card is None:
state.on_event("card.end")
# Норматив отработки фиксируется событием, а не текущим значением часов.
@ -200,6 +220,11 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
hub.to_station(session_id, SessionEnded(reason=reason))
else:
hub.to_trainee(session_id, CallEnded(reason=reason))
if was_paused:
# Снятие паузы — после сообщения о конце: иначе клиент дошлёт отложенные
# на паузе правки карточки в уже закрытое занятие.
hub.broadcast(session_id, SessionPaused(paused=False))
hub.to_station(session_id, SessionPaused(paused=False))
if state.score is None:
return
hub.to_observers(session_id, ScoreReady(session_id=session_id))
@ -234,3 +259,14 @@ def override_score(state, override: ScoreOverridden) -> None:
hub.record(state.session_id, override)
state.score = apply_score_override(state.score, override)
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))
def change_findings(state, change: FindingReviewed | FindingAdded) -> None:
"""Решение преподавателя по отметке — тем же путём, что правка итога.
Вызывается внутри `hub.operation`: отметки, пересчитанный балл, архивный
разбор и строка аудита уходят одним commit.
"""
state.score = apply_finding_change(state.score, change)
hub.record(state.session_id, change)
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))

View file

@ -24,6 +24,7 @@ from app.domain.events import (
)
from app.session.state import SessionState
from app.session.store import MemorySessionStore, Record, SessionLeaseLost, SessionStore
from app.session.timers import now_utc
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
#: очередь ограничена, переполнение роняет соединение, а не сессию.
@ -53,6 +54,9 @@ class SessionHub:
self._observers: dict[UUID, set[asyncio.Queue]] = {}
self._trainees: dict[UUID, set[asyncio.Queue]] = {}
self._stations: dict[UUID, set[asyncio.Queue]] = {}
self._trainee_calls: dict[UUID, set[asyncio.Queue]] = {}
self._trainee_stations: dict[UUID, set[asyncio.Queue]] = {}
self._pending_adoptions: dict[UUID, SessionState] = {}
self._tickers: dict[UUID, asyncio.Task] = {}
# Задачи, порождённые внутри операции, наследуют контекст; после
# закрытия операции их события идут напрямую (`_Operation.open`).
@ -116,6 +120,7 @@ class SessionHub:
def drop(self, session_id: UUID) -> None:
self._sessions.pop(session_id, None)
self._pending_adoptions.pop(session_id, None)
self.stop_ticker(session_id)
def live_count(self) -> int:
@ -135,9 +140,10 @@ class SessionHub:
if not self.store.persistent:
return 0
restored = await self.store.restore_active()
adopted = 0
for state in restored:
self._adopt(state)
return len(restored)
adopted += await self._adopt(state)
return adopted
async def maintain_lease(self) -> None:
"""Один оборот супервизора: продлить свои lease, подхватить просроченные чужие.
@ -154,6 +160,18 @@ class SessionHub:
log.warning("продление lease занятия %s не удалось", state.session_id,
exc_info=True)
await self.fence(state)
for state in list(self._pending_adoptions.values()):
try:
await self.store.commit(state)
except SessionLeaseLost:
self._pending_adoptions.pop(state.session_id, None)
except Exception:
log.warning("повторное сохранение занятия %s после takeover не удалось",
state.session_id, exc_info=True)
else:
self._pending_adoptions.pop(state.session_id, None)
self.register(state)
self.start_ticker(state.session_id)
try:
claimed = await self.store.claim_expired()
except Exception: # следующий оборот попробует снова
@ -163,17 +181,32 @@ class SessionHub:
current = self._sessions.get(state.session_id)
if current is not None and not current.lease_fenced:
continue
self._adopt(state)
await self._adopt(state)
async def supervise_lease(self, interval: float) -> None:
while True:
await asyncio.sleep(interval)
await self.maintain_lease()
def _adopt(self, state: SessionState) -> None:
async def _adopt(self, state: SessionState) -> bool:
self.stop_ticker(state.session_id)
if state.socket_connected_at_checkpoint:
# Разрыв произошёл при потере узла; старое время подключения не
# доказывает, что курсант отсутствовал всё это время.
state.socket_last_seen_at = now_utc()
state.socket_connected_at_checkpoint = False
try:
await self.store.commit(state)
except SessionLeaseLost:
return False
except Exception:
log.exception("не удалось сохранить присутствие после takeover %s", state.session_id)
self._pending_adoptions[state.session_id] = state
return False
self._pending_adoptions.pop(state.session_id, None)
self.register(state)
self.start_ticker(state.session_id)
return True
async def save_all(self) -> None:
"""Снимок живых занятий при остановке узла: следующий владелец продолжит с него."""
@ -285,6 +318,44 @@ class SessionHub:
def station(self, session_id: UUID):
return self._subscribe(self._stations, session_id)
async def _record_presence(self, session_id: UUID, *, station: bool) -> None:
state = self.get(session_id)
if state is None or state.ended or state.dds_phase != station:
return
async with self.operation(session_id):
state.socket_last_seen_at = now_utc()
present = self._trainee_stations if station else self._trainee_calls
state.socket_connected_at_checkpoint = bool(present.get(session_id))
@contextlib.asynccontextmanager
async def trainee_socket(
self, session_id: UUID, *, station: bool, trainee: bool,
) -> AsyncIterator[asyncio.Queue]:
"""Учесть только сокет курсанта; вещание преподавателю остаётся общим."""
registry = self._stations if station else self._trainees
presence = self._trainee_stations if station else self._trainee_calls
with self._subscribe(registry, session_id) as queue:
if trainee:
presence.setdefault(session_id, set()).add(queue)
try:
if trainee:
try:
await self._record_presence(session_id, station=station)
except Exception:
if not self.is_lease_fenced(session_id):
raise
yield queue
finally:
if trainee:
presence[session_id].discard(queue)
if not presence[session_id]:
presence.pop(session_id)
try:
await self._record_presence(session_id, station=station)
except Exception:
if not self.is_lease_fenced(session_id):
raise
# ── вещание ──
@staticmethod
@ -314,6 +385,12 @@ class SessionHub:
def observer_count(self, session_id: UUID) -> int:
return len(self._observers.get(session_id, set()))
def station_connected(self, session_id: UUID) -> bool:
return bool(self._trainee_stations.get(session_id))
def trainee_connected(self, session_id: UUID) -> bool:
return bool(self._trainee_calls.get(session_id))
# ── такт таймеров ──
def start_ticker(self, session_id: UUID) -> None:
@ -347,7 +424,7 @@ class SessionHub:
state = self.get(session_id)
if state is None or state.ended:
return
if state.dds_phase:
if state.dds_phase and not state.paused:
async with self.operation(session_id) as op:
active_before = state.desk.active_id
op.persist = bool(state.desk.deliver_due())

View file

@ -29,10 +29,14 @@ from app.domain.events import Mood, Speaker, TranscriptEntry
from app.session.checkpoint import dump_state, load_state
from app.session.state import SessionState, now_utc
from app.session.store import (
FindingAdded,
FindingReviewed,
HintRecorded,
LessonEnded,
LessonIdentity,
LessonPaused,
LessonRequest,
LessonResumed,
LessonStarted,
NoteAdded,
Record,
@ -42,8 +46,10 @@ from app.session.store import (
SelfAssessed,
SessionLeaseLost,
UtteranceAppended,
apply_finding_change,
apply_score_override,
)
from app.scoring.review import has_finding
log = logging.getLogger(__name__)
LEASE_SECONDS = 15
@ -196,6 +202,16 @@ class PostgresSessionStore:
object_id=str(session_id),
detail=f"missed_count={len(record.missed)}; comment_chars={len(record.comment)}",
))
case LessonPaused(at=at, author=author, role=role):
db.add(AuditLog(
actor=author, role=role, action="session.pause",
object_id=str(session_id), detail=f"at={at.isoformat()}",
))
case LessonResumed(at=at, author=author, role=role, paused_ms=paused_ms):
db.add(AuditLog(
actor=author, role=role, action="session.resume",
object_id=str(session_id), detail=f"at={at.isoformat()}; paused_ms={paused_ms}",
))
case LessonStarted(at=at):
await db.execute(
update(Session).where(Session.id == session_id).values(started_at=at)
@ -239,6 +255,40 @@ class PostgresSessionStore:
detail=(f"{score.score_auto} → {record.score_final}; "
f"comment_chars={len(record.comment)}"),
))
case FindingReviewed() | FindingAdded():
await db.flush()
score = await db.scalar(
select(Score).where(Score.session_id == session_id).with_for_update()
)
if score is None:
raise LookupError(f"нет оценки занятия {session_id}")
# Повтор ручной отметки под блокировкой: ни изменения, ни второй строки аудита.
if (isinstance(record, FindingAdded)
and has_finding(score.report or {}, record.finding.client_id)):
return
before = score.score_final
findings = (score.report or {}).get("findings", [])
score.report = apply_finding_change(dict(score.report or {}), record)
score.score_final = score.report.get("score_final", score.score_final)
if isinstance(record, FindingReviewed):
code = (findings[record.index].get("code", "?")
if 0 <= record.index < len(findings) else "?")
actor, action = record.review.author, "finding.review"
what = (f"#{record.index} {code} {record.review.decision.value}; "
f"причина: {record.review.reason}")
else:
finding = record.finding
actor, action = finding.author or "instructor", "finding.add"
where = f", карточка {finding.card}" if finding.card is not None else ""
what = (f"{finding.code.value}{where}; факт: {finding.fact}; "
f"норма: {finding.norm or ''}")
# Решение по отметке меняет чужой балл: аудит сам по себе должен
# ответить «кто, когда, что и почему», даже если разбор потом правили.
db.add(AuditLog(
actor=actor, role=record.role, action=action,
object_id=str(session_id),
detail=f"{what}; {before} → {score.score_final}",
))
await db.flush()
@staticmethod

View file

@ -8,6 +8,7 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from app.domain.events import CallEndReason, Exercise, LessonCriteria, Metric
@ -68,6 +69,7 @@ class ScoringSnapshot:
#: Карточка на пульте в упражнении по звонку: отметки D1–D6 рядом с E1–E6.
call_card: DdsLiveCard | None
call_card_services: tuple[str, ...]
pause_intervals: tuple[tuple[datetime, datetime], ...]
@property
def reports_cards(self) -> bool:
@ -112,6 +114,7 @@ class ScoringSnapshot:
call_card=live_card.model_copy(deep=True) if live_card is not None else None,
call_card_services=(tuple(state.card_services(live_card))
if live_card is not None else ()),
pause_intervals=tuple(state.pause_intervals),
)
@ -210,6 +213,7 @@ async def score_session(
norm="грамматически корректное описание",
ref="критерий занятия; правила русского языка",
competency=Competency.COMMUNICATION,
metric_key="description_grammar",
))
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
@ -218,6 +222,7 @@ async def score_session(
if live_card is not None:
decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK]
services = list(snapshot.call_card_services)
reaction_ms = snapshot.criteria.dds_report_reaction_limit_seconds * 1000
result.findings.extend(evaluate_dispatcher(
entries=live_card.status_log,
services=services,
@ -227,10 +232,16 @@ async def score_session(
reply_text=live_card.reply_text,
expected_decision=scenario.dds_decision.expected,
expected_decision_reason=scenario.dds_decision.reason,
phone_reports=live_card.phone_reports,
report_reaction_ms=reaction_ms,
report_reaction_enabled=snapshot.criteria.dds_report_reaction_enabled,
pause_intervals=snapshot.pause_intervals,
))
result.metrics.extend(dispatcher_metrics(
live_card, decision_limit_ms, scenario.dds_decision.expected,
scenario.dds_decision.reason, services=services,
scenario.dds_decision.reason, services=services, report_reaction_ms=reaction_ms,
report_reaction_enabled=snapshot.criteria.dds_report_reaction_enabled,
pause_intervals=snapshot.pause_intervals,
))
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
# занятия. Веса первого сценария поверх них испортили бы остальные.

View file

@ -105,6 +105,14 @@ class PersistedSession(BaseModel):
started_at: datetime | None = None
ended_at: datetime | None = None
end_reason: CallEndReason | None = None
#: Пульт остановил время занятия; таймеры и очередь ДДС заморожены (lct-39).
paused: bool = False
#: Момент постановки на паузу — им же считается длительность простоя при снятии.
paused_at: datetime | None = None
#: Суммарная длительность пауз — для разбора, чтобы время было объяснимо.
total_paused_ms: int = 0
#: Закрытые интервалы пауз нужны для расчёта активного времени между докладом и статусом.
pause_intervals: list[tuple[datetime, datetime]] = Field(default_factory=list)
#: Карточка оператора 112, замороженная при передаче в ДДС.
dispatched_card: KIO | None = None
dispatched_at: datetime | None = None
@ -124,6 +132,15 @@ class PersistedSession(BaseModel):
#: a lost WebSocket acknowledgement cannot apply an operation twice.
processed_station_commands: list[str] = Field(default_factory=list)
text_revealed_facts: dict[str, str] = Field(default_factory=dict)
#: Подряд идущих первичных отказов без принятой карточки между ними —
#: сигнал реестра преподавателя, не влияет на балл.
consecutive_refusals: int = 0
#: Последнее подключение/отключение сокета курсанта или начало фазы ДДС.
#: Сохраняется вместе с занятием, чтобы чтение реестра не меняло состояние.
socket_last_seen_at: datetime | None = None
#: На момент последнего checkpoint курсант был подключён на текущем АРМ.
#: После аварии узла момент потери связи неизвестен: окно начинается при takeover.
socket_connected_at_checkpoint: bool = False
@field_validator("processed_station_commands")
@classmethod
@ -181,6 +198,38 @@ class SessionState(PersistedSession):
"""Единственная точка, где событие двигает таймеры."""
self.timers.on_event(event_type)
def pause(self) -> None:
"""Заморозить таймеры занятия и карточек пульта; очередь не выдаёт следующую."""
if self.paused:
return
now = time.monotonic()
self.timers.pause(now)
for card in self.desk.cards.values():
card.timers.pause(now)
self.paused = True
self.paused_at = now_utc()
if self.voice is not None:
self.voice.pause()
def resume(self) -> None:
"""Снять паузу: таймеры продолжают с прежней отметки, очередь сдвигается на простой."""
if not self.paused:
return
now = time.monotonic()
self.timers.resume(now)
for card in self.desk.cards.values():
card.timers.resume(now)
if self.paused_at is not None:
resumed_at = now_utc()
idle = resumed_at - self.paused_at
self.total_paused_ms += max(0, int(idle.total_seconds() * 1000))
if resumed_at > self.paused_at:
self.pause_intervals.append((self.paused_at, resumed_at))
if self.desk.next_arrival_at is not None:
self.desk.next_arrival_at += idle
self.paused = False
self.paused_at = None
def append(self, speaker: Speaker, text: str, mood: Mood | None = None) -> TranscriptEntry:
entry = TranscriptEntry(
ref=f"u{len(self.transcript) + 1}",
@ -206,6 +255,9 @@ class SessionState(PersistedSession):
self.dispatched_at = now_utc()
if self.exercise is Exercise.CALL:
self._receive_call_card()
if self.handoff_to_dds:
self.socket_last_seen_at = self.dispatched_at
self.socket_connected_at_checkpoint = False
return self.dispatched_card
def _receive_call_card(self) -> None:
@ -319,14 +371,15 @@ class SessionState(PersistedSession):
services = self._managed_services_for(recipients)
log = list(card.status_log) if card is not None else []
statuses = {service: current(log, service) for service in services}
now_mono = time.monotonic()
overdue = False
if card is not None:
elapsed = card.timers.measured_ms(TimerCode.DDS_ACK)
# Через таймер, а не через `dispatched_at`: часы разошлись бы с
# паузой, которая держит `current_ms` на месте (lct-39).
ack_timer = card.timers.timers.get(TimerCode.DDS_ACK)
limit = card.timers.limits[TimerCode.DDS_ACK]
overdue = elapsed is None and (
(now_utc() - card.dispatched_at).total_seconds() * 1000 > limit
)
now_mono = time.monotonic()
elapsed_ms = ack_timer.current_ms(now_mono) if ack_timer else 0
overdue = not (ack_timer.stopped if ack_timer else False) and elapsed_ms > limit
queue_cards: list[DdsQueueCard] = []
for item in (desk.ordered() if self.dds_phase else []):
recipients_for_card = list(item.dispatched_card.notify)
@ -397,6 +450,7 @@ class SessionState(PersistedSession):
if desk.next_arrival_at is not None else None
),
max_waiting_cards=desk.max_waiting,
paused=self.paused,
)
def card_received_event(self):
@ -419,7 +473,14 @@ class SessionState(PersistedSession):
return self.ended_at is not None
def end(self, reason: CallEndReason) -> datetime:
"""Закрыть занятие; возвращает момент окончания для журнала."""
"""Закрыть занятие; возвращает момент окончания для журнала.
Занятие могут завершить прямо на паузе (преподаватель нажал «Завершить»,
не возобновив): снять флаг и досчитать простой сейчас, иначе курсант
навсегда останется за баннером «Пауза, ждите преподавателя», а разбор
потеряет последний, ещё не закрытый интервал паузы (lct-39).
"""
self.resume()
self.ended_at = now_utc()
self.end_reason = reason
return self.ended_at
@ -445,4 +506,5 @@ class SessionState(PersistedSession):
timers=self.shown_timers().snapshot(),
hints_used=len(self.hints_shown),
ended=self.ended,
paused=self.paused,
)

View file

@ -16,6 +16,8 @@ from typing import Protocol
from uuid import UUID
from app.domain.events import TranscriptEntry
from app.domain.taxonomy import Finding, FindingReview
from app.scoring.review import add_finding, review_finding
from app.session.checkpoint import dump_state
from app.session.state import SessionState
@ -83,8 +85,47 @@ class ScoreOverridden:
comment: str
@dataclass(frozen=True)
class LessonPaused:
at: datetime
author: str
role: str
@dataclass(frozen=True)
class FindingReviewed:
"""Преподаватель подтвердил или снял отметку разбора."""
index: int
review: FindingReview
role: str
@dataclass(frozen=True)
class LessonResumed:
at: datetime
author: str
role: str
#: Длительность этой конкретной паузы — видно в аудите без пересчёта.
paused_ms: int
@dataclass(frozen=True)
class FindingAdded:
"""Преподаватель добавил свою отметку (`source=instructor`)."""
finding: Finding
role: str
Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden)
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden
| LessonPaused | LessonResumed | FindingReviewed | FindingAdded)
def apply_finding_change(report: dict, change: "FindingReviewed | FindingAdded") -> dict:
"""Решение по отметке одинаково для живой сессии, строки `Score` и архива."""
if isinstance(change, FindingReviewed):
return review_finding(report, change.index, change.review)
return add_finding(report, change.finding)
def apply_score_override(report: dict, override: ScoreOverridden) -> dict:
@ -200,6 +241,10 @@ class MemorySessionStore:
if session_id not in self.scores:
raise LookupError(f"нет оценки занятия {session_id}")
self.scores[session_id] = apply_score_override(self.scores[session_id], record)
elif isinstance(record, FindingReviewed | FindingAdded):
if session_id not in self.scores:
raise LookupError(f"нет оценки занятия {session_id}")
self.scores[session_id] = apply_finding_change(self.scores[session_id], record)
self.records.setdefault(session_id, []).extend(records)
def snapshot(self, session_id: UUID) -> dict | None:

View file

@ -83,6 +83,9 @@ class Timer(BaseModel):
elapsed_ms: int = 0
attempt: int = 1
stopped: bool = False
#: Пауза занятия — таймер заморожен, но не завершён: `start()` его не
#: считает новой попыткой, в отличие от `stopped` (lct-39).
paused: bool = False
@model_serializer(mode="wrap")
def _dump(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
@ -100,13 +103,20 @@ class Timer(BaseModel):
data = dict(data)
elapsed = max(0, int(data.get("elapsed_ms", 0)))
stopped = bool(data.get("stopped"))
downtime = 0 if stopped else (info.context or {}).get("downtime_ms", 0)
paused = bool(data.get("paused"))
# На паузе занятие не простаивало без присмотра — оно ждало
# преподавателя, и это время не досчитывается таймеру при перезапуске.
downtime = 0 if stopped or paused else (info.context or {}).get("downtime_ms", 0)
started = data.pop("started")
data["started_at"] = time.monotonic() - (elapsed + downtime) / 1000 if started else None
data["elapsed_ms"] = elapsed if stopped else 0
data["elapsed_ms"] = elapsed if (stopped or paused) else 0
return data
def start(self, now: float) -> None:
if self.paused:
# Таймер уже идёт, просто заморожен: запуск с нуля потерял бы
# набранное время, а ход до `resume` посчитал бы паузу.
return
if self.stopped:
# Повторный запуск после остановки — это новая попытка (обратный дозвон).
self.attempt += 1
@ -125,6 +135,19 @@ class Timer(BaseModel):
return self.elapsed_ms
return int((now - self.started_at) * 1000)
def pause(self, now: float) -> None:
"""Зафиксировать прошедшее время и остановить ход часов до `resume`."""
if self.started_at is not None and not self.stopped:
self.elapsed_ms = self.current_ms(now)
self.started_at = None
self.paused = True
def resume(self, now: float) -> None:
"""Продолжить с той же отметки — простой в счёт не идёт."""
if self.paused:
self.started_at = now - self.elapsed_ms / 1000
self.paused = False
class SessionTimers(BaseModel):
"""Набор таймеров одной сессии. `limits` приходит из конфига —
@ -142,6 +165,16 @@ class SessionTimers(BaseModel):
for code in STOPS.get(event_type, ()):
self.timers.setdefault(code, Timer(code=code)).stop(now)
def pause(self, now: float | None = None) -> None:
now = time.monotonic() if now is None else now
for timer in self.timers.values():
timer.pause(now)
def resume(self, now: float | None = None) -> None:
now = time.monotonic() if now is None else now
for timer in self.timers.values():
timer.resume(now)
def snapshot(self, now: float | None = None) -> list[TimerSnapshot]:
"""Только запущенные таймеры: показывать нули по нормативам,
до которых занятие ещё не дошло, значит пугать курсанта зря."""

View file

@ -86,6 +86,8 @@ class VoiceSession:
# ── вход: кадры микрофона ──
def feed(self, frame: bytes) -> None:
if self.state.paused:
return
for event in self._vad.push(frame):
if isinstance(event, SpeechStarted) and self.speaking:
self.barge_in()
@ -104,6 +106,8 @@ class VoiceSession:
первая фраза «Алло! Помогите!» — ровно та, которую оператор перебивает
чаще всего, — не гасилась вовсе.
"""
if self.state.paused:
return asyncio.create_task(asyncio.sleep(0))
if self.speaking:
self._reply.cancel()
self._reply = asyncio.create_task(self.say(text, mood))
@ -119,6 +123,17 @@ class VoiceSession:
self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="barge_in"))
log.info("сессия %s: перебивание", self.session_id)
def pause(self) -> None:
"""Прервать текущую реплику и убрать речь, накопленную до паузы."""
if self.speaking:
self._reply.cancel()
if self._utterance_id is not None:
self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="director"))
self._utterance_id = None
while not self._queue.empty():
self._queue.get_nowait()
self._vad.reset()
async def close(self) -> None:
for task in (self._reply, self._worker):
if task is not None:
@ -129,6 +144,8 @@ class VoiceSession:
async def _work(self) -> None:
while True:
audio, ended_at = await self._queue.get()
if self.state.paused:
continue
# Новая фраза оператора, пока звонящий ещё говорит, — тоже перебивание.
if self.speaking:
self.barge_in()
@ -143,6 +160,8 @@ class VoiceSession:
timing = TurnTiming()
started = time.monotonic()
text = await self.models.transcribe(audio)
if self.state.paused:
return
timing.stt_ms = (time.monotonic() - started) * 1000
if not text:
return
@ -155,6 +174,8 @@ class VoiceSession:
started = time.monotonic()
line = await self._caller_line(text)
if self.state.paused:
return
timing.caller_ms = (time.monotonic() - started) * 1000
await self.say(line.text, line.mood, ended_at=ended_at, timing=timing)
@ -171,6 +192,8 @@ class VoiceSession:
self, text: str, mood: Mood, *, ended_at: float | None = None, timing: TurnTiming | None = None
) -> None:
"""Произнести реплику: событие с текстом, звук по предложениям, ожидание конца."""
if self.state.paused:
return
self._utterance_id = utterance_id = uuid4()
entry = self.state.append(Speaker.CALLER, text, mood)
self.send_event(CallerUtterance(utterance_id=utterance_id, text=text, at=entry.at, mood=mood))
@ -186,6 +209,8 @@ class VoiceSession:
pcm = await self._first_sentence(sentence, mood, ended_at, timing)
else:
pcm = await self.synthesize(sentence)
if self.state.paused:
return
if index == 0 and timing is not None:
timing.tts_first_ms = (time.monotonic() - synth_started) * 1000
if not pcm:
@ -205,6 +230,7 @@ class VoiceSession:
# Ждём, пока курсант дослушает: перебивание в это время отменит задачу.
await asyncio.sleep(max(0.0, playback_ends - time.monotonic()))
self.send_event(TtsEnd(utterance_id=utterance_id))
self._utterance_id = None
async def _first_sentence(
self, sentence: str, mood: Mood, ended_at: float, timing: TurnTiming | None
@ -212,17 +238,25 @@ class VoiceSession:
"""Первое предложение с филлером: если к секунде после конца фразы звука
ещё нет, звонящий «переспрашивает», а ответ встанет в очередь за ним."""
synthesis = asyncio.ensure_future(self.synthesize(sentence))
remaining = FILLER_AFTER_S - (time.monotonic() - ended_at)
if remaining > 0:
done, _ = await asyncio.wait({synthesis}, timeout=remaining)
if done:
return synthesis.result()
filler = await self.synthesize(FILLERS.get(mood, FILLERS[Mood.PANIC]))
if filler:
self.send_audio(filler)
if timing is not None:
timing.filler = True
return await synthesis
try:
remaining = FILLER_AFTER_S - (time.monotonic() - ended_at)
if remaining > 0:
done, _ = await asyncio.wait({synthesis}, timeout=remaining)
if done:
return synthesis.result()
filler = await self.synthesize(FILLERS.get(mood, FILLERS[Mood.PANIC]))
if filler and not self.state.paused:
self.send_audio(filler)
if timing is not None:
timing.filler = True
return await synthesis
finally:
if not synthesis.done():
synthesis.cancel()
try:
await synthesis
except asyncio.CancelledError:
pass
async def synthesize(self, text: str) -> bytes:
return await cached_synthesize(self.models, text)

View file

@ -71,6 +71,17 @@ class StreamingVad:
def in_speech(self) -> bool:
return self._in_speech
def reset(self) -> None:
"""Не склеивать фрагменты речи до и после паузы занятия."""
self._state.fill(0)
self._context.fill(0)
self._pending = np.zeros(0, dtype=np.float32)
self._preroll.clear()
self._speech.clear()
self._voiced_ms = 0
self._silence_ms = 0
self._in_speech = False
def _probability(self, window: np.ndarray) -> float:
frame = np.concatenate([self._context, window])[None, :]
output, self._state = self._session.run(