Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

View file

@ -1,15 +1,56 @@
# Скопировать в .env. Файл в .gitignore: ключи в репозиторий не едут.
BIND_HOST=127.0.0.1
POSTGRES_PORT=5432
BACKEND_PORT=8000
FRONTEND_PORT=5173
TLS_PORT=5443
TLS_CERT_DIR=./.local/tls
SIP_PORT=5060
SIPS_PORT=5061
SIP_WS_PORT=8088
RTP_PORT_START=10000
RTP_PORT_END=10099
SIP_TLS_DIR=./.local/sip-tls
# Если пусто, Asterisk создаст стойкие случайные пароли в volume sipdata.
SIP_6001_PASSWORD=
SIP_6002_PASSWORD=
SIP_6003_PASSWORD=
SIP_6101_PASSWORD=
SIP_6102_PASSWORD=
# IP/DNS сервера, который WebRTC-клиенты видят в локальной сети. При запуске
# только на той же машине оставьте 127.0.0.1; RTP-порты должны отображаться 1:1.
SIP_EXTERNAL_MEDIA_ADDRESS=127.0.0.1
DATABASE_URL=postgresql+asyncpg://lct:lct@localhost:5432/lct
# LLM: провайдер меняется значением, не кодом (docs/arch/STACK.md)
LLM_PROVIDER=gigachat
# Два локальных OpenAI-совместимых сервера (например, llama-server).
# Адреса должны быть loopback: OFFLINE=true не допускает внешний API.
LLM_PROVIDER=local
LLM_BASE_URL=http://127.0.0.1:18080/v1
# Адреса тех же процессов из backend-контейнера Docker Desktop (macOS/Windows).
DOCKER_LLM_BASE_URL=http://host.docker.internal:18080/v1
LLM_API_KEY=
LLM_MODEL_CALLER=
LLM_MODEL_JUDGE=
LLM_MODEL_CALLER=Qwen3-1.7B
LLM_CONTROL_BASE_URL=http://127.0.0.1:18081/v1
DOCKER_LLM_CONTROL_BASE_URL=http://host.docker.internal:18081/v1
LLM_MODEL_CONTROL=Vikhr-1B
GRAMMAR_LLM_ENABLED=true
DIALOGUE_MODEL_MODE=dialogue
JUDGE_TEMPERATURE=0
# Голосовой контур
MODELS_DIR=models
STT_MODEL=gigaam-v3-rnnt
STT_MODEL=whisper-small
WHISPER_MODEL_PATH=models/whisper-small
WHISPER_SERVER_URL=http://127.0.0.1:18082
VOICE_ENABLED=false
RECORD_CALLS=true
RECORDINGS_DIR=recordings
BACKUP_INTERVAL_SECONDS=86400
BACKUP_RETRY_SECONDS=300
BACKUP_KEEP=14
ENDPOINTING_MS=600
OFFLINE=false
OFFLINE=true
SECURE_COOKIES=false
# Необязательно для Compose: если пусто, стойкий случайный ключ создаётся в
# volume securitydata. На управляемом стенде можно задать свой 48+ байтный ключ.
SESSION_SECRET=

7
.gitignore vendored
View file

@ -7,6 +7,8 @@ backend/models/
# Резервные копии базы (make backup)
backend/backups/
backend/recordings/
recordings/
# Python
__pycache__/
@ -22,9 +24,14 @@ frontend/dist/
.env.*
!.env.example
*.local
.local/
# Python
.pytest_cache/
# Temporary SQLite files created by local in-memory demo/test runs
:memory:.ses
backend/:memory:.ses
# Артефакты Windows рядом со скачанными файлами
*:Zone.Identifier

View file

@ -21,10 +21,34 @@ dev: ## Поднять стенд: postgres + backend --reload + frontend
offline: ## Поднять карточки/ДДС из заранее собранных локальных образов, без скачивания
$(COMPOSE) up --pull never --no-build
tls: ## Поднять полный стенд по HTTPS/WSS с локальным сертификатом
$(COMPOSE) -f docker-compose.yml -f docker-compose.tls.yml up --build
offline-tls: ## Поднять HTTPS/WSS из уже собранных образов без сети
$(COMPOSE) -f docker-compose.yml -f docker-compose.tls.yml up --pull never --no-build
sip: ## Собрать и поднять локальный Asterisk SIP/VoIP
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --build -d sip
offline-sip: ## Поднять SIP из уже собранного образа без сети
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --pull never --no-build -d sip
sip-credentials: ## Показать локальные пароли софтфонов 6001–6003 и WebRTC 6101–6102
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml exec -T sip cat /var/lib/lct-sip/credentials.env
sip-test: ## Проверить регистрацию, звонок, RTP и задержку (args="--rtp-host-offset 1000")
python3 scripts/smoke_sip.py --port $(or $(SIP_PORT),5060) $(args)
webrtc-test: ## Проверить два браузерных SIP-клиента, аудио и запись Asterisk
python3 scripts/smoke_webrtc_browser.py \
--frontend-url https://127.0.0.1:$(or $(TLS_PORT),5443) \
--backend-url http://127.0.0.1:$(or $(BACKEND_PORT),8000) \
--database-url postgresql+asyncpg://lct:lct@127.0.0.1:$(or $(POSTGRES_PORT),5432)/lct $(args)
down: ## Погасить стенд
$(COMPOSE) down
back: ## Бэкенд нативно, с голосовым контуром (порт 8000: сначала docker compose stop backend)
back: ## Бэкенд нативно, без голоса по умолчанию (порт 8000: сначала docker compose stop backend)
cd backend && $(UV) run --extra voice uvicorn app.main:app --reload --port 8000 --workers 1
front: ## Только фронтенд, локально
@ -42,9 +66,18 @@ test-voice: ## Тест голосового контура на настоящ
typecheck: ## Проверить фронтенд компилятором
npm --prefix frontend run typecheck
models: ## Скачать модели в backend/models/: эмбеддинги, GigaAM, Silero VAD; Silero TTS — вручную
models: ## Скачать эмбеддинги и Silero VAD; Silero TTS — вручную
cd backend && $(UV) run python scripts/models.py
local-models: ## Скачать и проверить Qwen3 1.7B, Vikhr 1B, Whisper small (нужна сеть один раз)
cd backend && $(UV) run --offline --no-sync python scripts/download_local_models.py
local-llm: ## Запустить Qwen3 на localhost:18080 (mode=both для Qwen3 и Vikhr)
cd backend && python3 scripts/local_llms.py --mode $(or $(mode),dialogue)
local-stt: ## Запустить локальный whisper.cpp server на localhost:18082
cd backend && python3 scripts/local_stt.py
backup: ## Резервная копия базы в backend/backups/ (для cron: раз в сутки)
cd backend && $(UV) run python scripts/backup.py
@ -63,7 +96,7 @@ lesson: ## Запустить занятие и напечатать ссылк
test-llm: ## Живые проверки LLM по backend/.env.test (медленно: рассуждающая модель)
cd backend && $(UV) run --extra dev pytest tests/test_llm.py -m llm -q -s
llm-check: ## Один запрос к LLM: проверить ключ и адрес из backend/.env
llm-check: ## Один запрос к активной локальной модели из backend/.env
cd backend && $(UV) run python scripts/llm_check.py
latency: ## Замер задержки голосового контура по этапам — запускать на демо-машине
@ -81,10 +114,10 @@ repl: ## Текстовый диалог со звонящим без голос
pregen: ## Офлайн-таблицы реплик: make pregen [s=<сценарий>] [t=<билет>] [force=1]
cd backend && s="$(s)" t="$(t)" force="$(force)" $(UV) run python scripts/pregenerate.py
demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт
demo: ## Поднять основной стенд ДДС: база, готовые карточки, бэкенд и фронт
./scripts/demo.sh
demo-lite: ## Локальная демонстрация карточки/ДДС без Docker, БД, голоса и внешней сети
cd backend && UV_CACHE_DIR=/tmp/lct-uv-demo-cache OFFLINE=true VOICE_ENABLED=false DEV_AUTH_BYPASS=true DEMO_NO_DB=true $(UV) run --offline --no-sync uvicorn app.main:app --host 127.0.0.1 --port $(DEMO_PORT) --workers 1
.PHONY: help dev offline down back front types users users-docker backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo demo-lite
.PHONY: help dev offline tls offline-tls sip offline-sip sip-credentials sip-test webrtc-test down back front types users users-docker backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models local-models local-llm local-stt seed repl pregen demo demo-lite

View file

@ -7,7 +7,7 @@ ENV PYTHONUNBUFFERED=1
# Экспорт PDF требует локальный TTF с кириллицей; базовый slim-образ его не
# гарантирует. Шрифт запекается в образ при подготовке офлайн-комплекта.
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core \
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Зависимости — из pyproject.toml, а не отдельным списком: ручной список разошёлся

View file

@ -7,13 +7,16 @@
честно говорим, чего не хватает.
"""
import base64
import logging
import os
import re
import shutil
import subprocess
import tempfile
from datetime import datetime, timezone
from getpass import getuser
from pathlib import Path
from urllib.parse import unquote, urlsplit
from app.config import get_settings
@ -23,51 +26,122 @@ DIR = Path(__file__).resolve().parents[2] / "backups"
#: Контейнер базы в docker compose проекта.
CONTAINER = os.environ.get("POSTGRES_CONTAINER", "lct-hack-postgres-1")
TIMEOUT_S = 120
# pg_dump 17 добавляет эту настройку в plain SQL, а целевая PostgreSQL 16 из
# Compose её ещё не знает. Остальной plain dump обратно совместим; удаляем
# только служебный SET, не данные и не DDL.
INCOMPATIBLE_SETTINGS = (b"SET transaction_timeout = 0;\n",)
class BackupError(RuntimeError):
"""Копию снять нечем или не удалось. Сообщение показывается администратору."""
def _restrict_file_permissions(fd: int, path: Path, platform_name: str | None = None) -> None:
"""Закрыть резервную копию от остальных локальных пользователей.
chmod(0o600) не задаёт ACL на Windows, поэтому там icacls удаляет
наследуемые разрешения и оставляет полный доступ владельцу процесса и
системной учётной записи. При ошибке копию не публикуем.
"""
platform_name = platform_name or os.name
if platform_name == "nt":
username = getuser()
domain = os.environ.get("USERDOMAIN")
if domain and "\\" not in username:
username = f"{domain}\\{username}"
system_root = Path(os.environ.get("SystemRoot", "C:\\Windows"))
icacls = system_root / "System32" / "icacls.exe"
try:
result = subprocess.run(
[str(icacls), str(path), "/inheritance:r", "/grant:r",
f"{username}:(F)", "*S-1-5-18:(F)"],
capture_output=True,
text=True,
timeout=15,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise BackupError("не удалось задать ACL резервной копии через icacls") from exc
if result.returncode != 0:
raise BackupError("Windows не применила закрытый ACL к резервной копии")
return
if hasattr(os, "fchmod"):
try:
os.fchmod(fd, 0o600)
except OSError as exc:
raise BackupError("не удалось ограничить права резервной копии") from exc
else:
try:
os.chmod(path, 0o600)
except OSError as exc:
raise BackupError("не удалось ограничить права резервной копии") from exc
def _dsn() -> tuple[str, str, str, str, str]:
"""Разобрать DATABASE_URL на части для pg_dump."""
url = get_settings().database_url
match = re.match(r".*://([^:]+):([^@]+)@([^:/]+):(\d+)/(\w+)", url)
if not match:
raise BackupError(f"не разобрать DATABASE_URL: {url}")
return match.groups() # user, password, host, port, database
try:
parsed = urlsplit(url)
user = unquote(parsed.username or "")
password = unquote(parsed.password or "")
host = parsed.hostname or ""
port = parsed.port
database = unquote(parsed.path.lstrip("/"))
except ValueError as exc:
# Не включать исходный URL в диагностику: он содержит пароль.
raise BackupError("не удалось разобрать DATABASE_URL") from exc
if not all((user, password, host, port, database)) or "/" in database:
raise BackupError("не удалось разобрать DATABASE_URL")
return user, password, host, str(port), database
def listing() -> list[dict]:
"""Готовые копии, новые первыми."""
if not DIR.exists():
items = []
try:
with os.scandir(DIR) as entries:
for entry in entries:
if not entry.name.endswith(".sql") or not entry.is_file(follow_symlinks=False):
continue
stat = entry.stat(follow_symlinks=False)
items.append({
"name": entry.name,
"size_bytes": stat.st_size,
"at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
})
except FileNotFoundError:
return []
items = [
{
"name": path.name,
"size_bytes": path.stat().st_size,
"at": datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc),
}
for path in DIR.glob("*.sql")
]
except OSError as exc:
raise BackupError(f"не удалось прочитать каталог резервных копий: {exc}") from exc
return sorted(items, key=lambda item: item["at"], reverse=True)
def create() -> dict:
"""Снять копию. Возвращает описание файла, как в `listing()`."""
user, password, host, port, database = _dsn()
DIR.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
try:
DIR.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise BackupError(f"недоступен каталог резервных копий: {exc}") from exc
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
target = DIR / f"lct-{stamp}.sql"
if shutil.which("pg_dump"):
command = ["pg_dump", "-h", host, "-p", port, "-U", user, "-d", database]
env = {**os.environ, "PGPASSWORD": password}
stdin_data = None
elif shutil.which("docker"):
# В контейнере база доступна как localhost, а не по хосту из DSN.
command = ["docker", "exec", "-e", f"PGPASSWORD={password}", CONTAINER,
"pg_dump", "-U", user, "-d", database]
# Пароль передаём через stdin, а не аргументом `docker exec -e`,
# поскольку аргументы процесса могут быть видны другим пользователям.
script = (
"IFS= read -r encoded_password || exit 1; "
'PGPASSWORD="$(printf %s "$encoded_password" | base64 -d)" || exit 1; '
'export PGPASSWORD; exec pg_dump -U "$1" -d "$2"'
)
command = ["docker", "exec", "-i", CONTAINER, "sh", "-c", script,
"lct-pg-dump", user, database]
env = dict(os.environ)
stdin_data = base64.b64encode(password.encode("utf-8")) + b"\n"
else:
raise BackupError(
"нечем снять копию: нет ни pg_dump, ни docker. "
@ -75,7 +149,9 @@ def create() -> dict:
)
try:
result = subprocess.run(command, capture_output=True, timeout=TIMEOUT_S, env=env)
result = subprocess.run(
command, capture_output=True, timeout=TIMEOUT_S, env=env, input=stdin_data
)
except subprocess.TimeoutExpired as exc:
raise BackupError(f"pg_dump не уложился в {TIMEOUT_S} с") from exc
except OSError as exc:
@ -84,10 +160,38 @@ def create() -> dict:
if result.returncode != 0:
raise BackupError(result.stderr.decode(errors="replace")[:300] or "pg_dump завершился с ошибкой")
target.write_bytes(result.stdout)
log.info("резервная копия: %s, %d КБ", target.name, target.stat().st_size // 1024)
dump = result.stdout
for setting in INCOMPATIBLE_SETTINGS:
dump = dump.replace(setting, b"")
if not dump.startswith(b"--\n-- PostgreSQL database dump"):
raise BackupError("pg_dump вернул неожиданный или пустой формат")
# Публикуем только полностью записанный dump. При аварийном завершении
# процесса список копий не должен показывать обрезанный файл как готовый.
try:
fd, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=DIR)
except OSError as exc:
raise BackupError(f"не удалось создать временный файл копии: {exc}") from exc
temporary = Path(temporary_name)
try:
with os.fdopen(fd, "wb") as stream:
_restrict_file_permissions(stream.fileno(), temporary)
stream.write(dump)
stream.flush()
os.fsync(stream.fileno())
temporary.replace(target)
except Exception as exc:
try:
temporary.unlink(missing_ok=True)
except OSError:
log.exception("не удалось удалить временную копию %s", temporary.name)
if isinstance(exc, OSError):
raise BackupError(f"не удалось записать резервную копию: {exc}") from exc
raise
size_bytes = len(dump)
created_at = datetime.now(timezone.utc)
log.info("резервная копия: %s, %d КБ", target.name, size_bytes // 1024)
return {
"name": target.name,
"size_bytes": target.stat().st_size,
"at": datetime.fromtimestamp(target.stat().st_mtime, tz=timezone.utc),
"size_bytes": size_bytes,
"at": created_at,
}

View file

@ -16,6 +16,7 @@
"""
import asyncio
import hashlib
import logging
import secrets
import weakref
@ -24,7 +25,7 @@ from uuid import UUID
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import APIRouter, HTTPException, Request, WebSocket
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy import select
from app.config import get_settings
@ -37,10 +38,12 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
DEMO_TRAINEE_ID = UUID("00000000-0000-4000-8000-000000000112")
_hasher = PasswordHasher()
# Cookie is signed, but carries a role snapshot. A changed account must not
# keep its old privileges for the full 12-hour cookie lifetime. Compose runs
# one worker; the process marker also invalidates all cookies after restart.
_INSTANCE = secrets.token_urlsafe(32)
# Cookie is signed and may safely survive a backend restart. Marker changes
# only together with SESSION_SECRET; account edits are revoked independently
# through the persistent auth_version loaded below.
_INSTANCE = hashlib.sha256(
f"lct-auth-instance:{get_settings().session_secret}".encode()
).hexdigest()
_generations: dict[str, int] = {}
_active_sockets: dict[str, weakref.WeakKeyDictionary] = {}
@ -53,9 +56,21 @@ async def _close_revoked(ws: WebSocket) -> None:
pass
def invalidate_login(login: str) -> None:
def prime_generations(values: dict[str, int]) -> None:
"""Загрузить версии полномочий из БД при старте нового процесса."""
_generations.clear()
_generations.update(values)
async def load_generations() -> None:
async with get_sessionmaker()() as db:
rows = (await db.execute(select(User.login, User.auth_version))).all()
prime_generations({login: version for login, version in rows})
def invalidate_login(login: str, version: int | None = None) -> None:
"""Revoke previously issued cookies after account/role/password changes."""
_generations[login] = _generations.get(login, 0) + 1
_generations[login] = version if version is not None else _generations.get(login, 0) + 1
for ws, loop in list(_active_sockets.get(login, {}).items()):
try:
if not loop.is_closed():
@ -117,8 +132,8 @@ class Principal(BaseModel):
class LoginIn(BaseModel):
login: str
password: str
login: str = Field(min_length=3, max_length=80)
password: str = Field(min_length=1, max_length=1024)
def _demo_local(request: Request) -> None:
@ -199,10 +214,17 @@ async def login(payload: LoginIn, request: Request) -> dict:
# Одинаковый ответ на неизвестный логин и неверный пароль: иначе форма
# входа превращается в список действующих учётных записей.
if user is None or not verify_password(user.password_hash, payload.password):
# Не записываем пароль, IP либо факт существования учётной записи.
# Логин нужен администратору для расследования перебора; ограничиваем
# длину до размера поля AuditLog.actor.
await audit(payload.login[:80], "unknown", "login.failed")
raise HTTPException(status_code=401, detail="bad_credentials")
if user.blocked:
await audit(user.login, user.role, "login.blocked")
raise HTTPException(status_code=403, detail="blocked")
_generations[user.login] = user.auth_version
who = Principal(
login=user.login,
full_name=user.full_name,

View file

@ -11,10 +11,13 @@
import logging
import re
from datetime import datetime
from datetime import datetime, timedelta, timezone
from uuid import UUID
from xml.etree import ElementTree as ET
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
@ -27,13 +30,68 @@ from app.config import get_settings
from app.db.base import get_session
from app.db.models import AuditLog, Session as SessionRow, Trainee, User
from app.domain import ekp
from app.domain.roles import ROLE_LABELS, Role
from app.domain.roles import ROLE_LABELS, SCREENS, Role
from app.domain.timers import NORMATIVES
from app.dialog.llm import is_loopback_url
from app.monitoring import recent_events, sample_metrics
from app.session.hub import hub
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin", tags=["admin"])
def _configuration_xml() -> bytes:
"""Безопасный переносимый снимок конфигурации без паролей и ключей."""
settings = get_settings()
root = ET.Element("lctConfiguration", {"version": "1"})
ET.SubElement(root, "platform", {
"offline": str(settings.offline).lower(),
"voiceEnabled": str(settings.voice_enabled).lower(),
"secureCookies": str(settings.secure_cookies).lower(),
})
models = ET.SubElement(root, "localModels")
ET.SubElement(models, "dialogue", {"name": settings.llm_model_caller})
ET.SubElement(models, "russianControl", {
"name": settings.llm_model_control,
"grammarEnabled": str(settings.grammar_llm_enabled).lower(),
})
ET.SubElement(models, "speechToText", {
"name": settings.stt_model,
"enabled": str(settings.voice_enabled).lower(),
})
workstations = ET.SubElement(root, "workstations")
for role, screens in SCREENS.items():
workstation = ET.SubElement(workstations, "workstation", {
"role": role.value, "label": ROLE_LABELS[role],
})
for path in screens:
ET.SubElement(workstation, "screen", {"path": path})
timers = ET.SubElement(root, "timerLimits")
for code, normative in NORMATIVES.items():
ET.SubElement(timers, "timer", {
"code": code.value,
"milliseconds": str(settings.limit_ms(code)),
"defaultMilliseconds": str(normative.limit_ms),
})
reference = ekp.reference()
ET.SubElement(root, "ekp", {
"version": reference.version,
"incidents": str(len(reference.incidents)),
})
ET.indent(root, space=" ")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
@router.get("/config.xml")
async def configuration_xml(request: Request) -> Response:
require(request, Role.ADMIN)
return Response(
content=_configuration_xml(),
media_type="application/xml",
headers={"Content-Disposition": 'attachment; filename="lct-workstations.xml"'},
)
class UserOut(BaseModel):
id: UUID
login: str
@ -47,7 +105,7 @@ class UserOut(BaseModel):
class UserCreate(BaseModel):
login: str = Field(min_length=3, max_length=80)
full_name: str = Field(min_length=1, max_length=120)
password: str = Field(min_length=8, description="Короткий пароль не заводится")
password: str = Field(min_length=8, max_length=1024, description="Пароль должен быть от 8 до 1024 символов")
role: Role
service: str | None = None
@ -59,7 +117,7 @@ class UserPatch(BaseModel):
role: Role | None = None
service: str | None = None
blocked: bool | None = None
password: str | None = Field(default=None, min_length=8)
password: str | None = Field(default=None, min_length=8, max_length=1024)
def _out(user: User) -> UserOut:
@ -148,8 +206,9 @@ async def patch_user(
if not changed:
return _out(user)
user.auth_version += 1
await db.commit()
invalidate_login(user.login)
invalidate_login(user.login, user.auth_version)
await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed))
return _out(user)
@ -169,12 +228,18 @@ async def audit_log(
action: str | None = None,
actor: str | None = None,
limit: int = 200,
offset: int = 0,
db: AsyncSession = Depends(get_session),
) -> list[AuditOut]:
"""Журнал действий. Администратор его читает, но не правит: точки удаления
или изменения записи здесь нет — ТЗ требует хранения, а не управления."""
require(request, Role.ADMIN)
query = select(AuditLog).order_by(AuditLog.at.desc()).limit(max(1, min(limit, 1000)))
query = (
select(AuditLog)
.order_by(AuditLog.at.desc(), AuditLog.id.desc())
.limit(max(1, min(limit, 1001)))
.offset(max(0, min(offset, 10_000_000)))
)
if action:
query = query.where(AuditLog.action == action)
if actor:
@ -195,6 +260,104 @@ class ServiceState(BaseModel):
detail: str
class RuntimeMetrics(BaseModel):
at: datetime
uptime_seconds: float
cpu_percent: float
load_1m_percent: float | None
cpu_cores: int
rss_bytes: int | None
memory_total_bytes: int | None
memory_available_bytes: int | None
disk_total_bytes: int
disk_free_bytes: int
threads: int
active_sessions: int
restored_sessions: int
completed_sessions_24h: int
class DiagnosticEvent(BaseModel):
at: datetime
level: str
source: str
message: str
class FailureEvent(BaseModel):
at: datetime
actor: str
action: str
object_id: str | None
detail: str
class DiagnosticReport(BaseModel):
generated_at: datetime
metrics: RuntimeMetrics
recent_system_events: list[DiagnosticEvent]
failed_actions_24h: list[FailureEvent]
async def _runtime_metrics(db: AsyncSession) -> RuntimeMetrics:
from app.main import app
raw = sample_metrics()
since = datetime.now(timezone.utc) - timedelta(hours=24)
completed = await db.scalar(
select(func.count()).select_from(SessionRow).where(SessionRow.ended_at >= since)
)
return RuntimeMetrics(
**raw,
active_sessions=sum(not item.ended for item in hub._sessions.values()), # noqa: SLF001
restored_sessions=getattr(app.state, "sessions_restored", 0),
completed_sessions_24h=completed or 0,
)
async def _diagnostic_report(db: AsyncSession) -> DiagnosticReport:
since = datetime.now(timezone.utc) - timedelta(hours=24)
rows = await db.scalars(
select(AuditLog)
.where(AuditLog.at >= since, AuditLog.action.like("%.failed"))
.order_by(AuditLog.at.desc())
.limit(200)
)
return DiagnosticReport(
generated_at=datetime.now(timezone.utc),
metrics=await _runtime_metrics(db),
recent_system_events=[DiagnosticEvent(**item) for item in recent_events(limit=100)],
failed_actions_24h=[
FailureEvent(
at=row.at, actor=row.actor, action=row.action,
object_id=row.object_id, detail=row.detail,
)
for row in rows
],
)
@router.get("/diagnostics", response_model=DiagnosticReport)
async def diagnostics(
request: Request, db: AsyncSession = Depends(get_session)
) -> DiagnosticReport:
"""Live load plus a bounded, redacted incident report for the admin."""
require(request, Role.ADMIN)
return await _diagnostic_report(db)
@router.get("/diagnostics.json")
async def download_diagnostics(
request: Request, db: AsyncSession = Depends(get_session)
) -> JSONResponse:
require(request, Role.ADMIN)
report = await _diagnostic_report(db)
return JSONResponse(
jsonable_encoder(report),
headers={"Content-Disposition": 'attachment; filename="lct-diagnostics.json"'},
)
@router.get("/status", response_model=list[ServiceState])
async def status(request: Request, db: AsyncSession = Depends(get_session)) -> list[ServiceState]:
"""Состояние компонентов стенда — то, что администратор смотрит до занятия,
@ -220,6 +383,21 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
detail="распознавание и синтез готовы" if models_ready else "не загружены: занятие пойдёт без голоса",
)
)
metrics = sample_metrics()
disk_free = metrics["disk_free_bytes"]
disk_total = max(metrics["disk_total_bytes"], 1)
disk_ok = disk_free >= 1024 ** 3 and disk_free / disk_total >= 0.05
load = metrics["load_1m_percent"]
states.append(ServiceState(
name="Нагрузка backend",
ok=disk_ok and (load is None or load < 100),
detail=(
f"CPU процесса {metrics['cpu_percent']:.1f}%; "
+ (f"нагрузка хоста {load:.1f}%; " if load is not None else "")
+ f"RAM процесса {(metrics['rss_bytes'] or 0) / 1024 ** 2:.0f} МБ; "
+ f"свободно на диске {disk_free / 1024 ** 3:.1f} ГБ"
),
))
states.append(
ServiceState(
name="Эмбеддинги",
@ -228,11 +406,20 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
else "нет модели: подсказки идут по порядку чек-листа",
)
)
llm_configured = (
is_loopback_url(
settings.llm_base_url,
allow_docker_host=settings.allow_docker_host_models,
)
if settings.offline or settings.llm_provider == "local"
else bool(settings.llm_api_key and settings.llm_base_url)
)
states.append(
ServiceState(
name="Провайдер LLM",
ok=bool(settings.llm_api_key and settings.llm_base_url),
detail=settings.llm_base_url or "не настроен: звонящий читает офлайн-таблицу",
ok=llm_configured,
detail=(f"локальный адрес разрешён: {settings.llm_base_url}"
if llm_configured else "не настроен: звонящий читает офлайн-таблицу"),
)
)
@ -248,9 +435,39 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
ServiceState(
name="Живых занятий",
ok=True,
detail=str(len(hub._sessions)), # noqa: SLF001 — реестр в памяти процесса
detail=(
f"активно {sum(not item.ended for item in hub._sessions.values())}; "
f"восстановлено после запуска {getattr(app.state, 'sessions_restored', 0)}"
), # noqa: SLF001 — реестр в памяти процесса
)
)
try:
copies = backup_service.listing()
except backup_service.BackupError as exc:
states.append(ServiceState(
name="Резервное копирование",
ok=False,
detail=f"ошибка чтения копий: {exc}",
))
copies = []
if copies:
latest = copies[0]
age_seconds = max(0.0, (datetime.now(timezone.utc) - latest["at"]).total_seconds())
allowed_age = max(60, settings.backup_interval_seconds) + max(
60, settings.backup_retry_seconds
)
states.append(ServiceState(
name="Резервное копирование",
ok=age_seconds <= allowed_age,
detail=(f"последняя копия {latest['name']}, "
f"{age_seconds / 3600:.1f} ч назад; хранится {len(copies)}"),
))
else:
states.append(ServiceState(
name="Резервное копирование",
ok=False,
detail="успешных копий ещё нет",
))
# Секрет сессии по умолчанию — не ошибка запуска, но на стенде это дыра,
# и увидеть её должен администратор, а не проверяющий.
default_secret = settings.session_secret.startswith("dev-secret")
@ -273,7 +490,12 @@ class BackupOut(BaseModel):
@router.get("/backups", response_model=list[BackupOut])
async def backups(request: Request) -> list[BackupOut]:
require(request, Role.ADMIN)
return [BackupOut(**item) for item in await run_in_threadpool(backup_service.listing)]
try:
copies = await run_in_threadpool(backup_service.listing)
except backup_service.BackupError as exc:
detail = _safe_backup_error(exc)
raise HTTPException(status_code=503, detail=detail) from exc
return [BackupOut(**item) for item in copies]
def _safe_backup_error(exc: backup_service.BackupError) -> str:

View file

@ -10,9 +10,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee
from app.db.models import Group, Score, Session, Trainee, User
from app.domain.roles import Role
from app.scoring.group import ScoredAttempt, summarize
from app.scoring.group_insight import InsightInvalid, generate_group_insight
from app.dialog.llm import LlmUnavailable
router = APIRouter(prefix="/api/groups", tags=["groups"])
@ -26,6 +28,10 @@ class GroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
class GroupOwnerPatch(BaseModel):
owner_login: str | None
class GroupErrorOut(BaseModel):
code: str
title: str
@ -44,62 +50,23 @@ class GroupAnalyticsOut(BaseModel):
errors: list[GroupErrorOut]
@router.get("", response_model=list[GroupOut])
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]:
require(request, Role.INSTRUCTOR, Role.ADMIN)
groups = await db.scalars(select(Group).order_by(Group.name))
return [GroupOut(id=group.id, name=group.name) for group in groups]
class GroupInsightOut(BaseModel):
summary: str
priorities: list[str]
source: str = "local_qwen"
personal_data_sent: bool = False
@router.post("", response_model=GroupOut, status_code=201)
async def create(
body: GroupCreate, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
name = body.name.strip()
if not name:
raise HTTPException(status_code=422, detail="group_name_required")
group = Group(name=name)
db.add(group)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="group_exists") from exc
await audit(who.login, who.role.value, "group.create", str(group.id), group.name)
return GroupOut(id=group.id, name=group.name)
@router.put("/{group_id}/trainees/{trainee_id}", response_model=GroupOut)
async def assign_trainee(
group_id: UUID, trainee_id: UUID, request: Request,
db: AsyncSession = Depends(get_session),
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None:
raise HTTPException(status_code=404, detail="group_not_found")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
trainee.group_id = group_id
await db.commit()
await audit(who.login, who.role.value, "group.assign", str(group.id), str(trainee_id))
return GroupOut(id=group.id, name=group.name)
@router.get("/{group_id}/analytics", response_model=GroupAnalyticsOut)
async def analytics(
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
async def _analytics(
group_id: UUID, db: AsyncSession, *, owner_login: str | None = None
) -> GroupAnalyticsOut:
require(request, Role.INSTRUCTOR, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None:
if group is None or (owner_login is not None and group.owner_login != owner_login):
raise HTTPException(status_code=404, detail="group_not_found")
enrolled = await db.scalar(
select(func.count()).select_from(Trainee).where(Trainee.group_id == group_id)
)
rows = await db.execute(
attempts_query = (
select(Session.trainee_id, Score.score_final, Score.report)
.join(Score, Score.session_id == Session.id)
.join(Trainee, Trainee.id == Session.trainee_id, isouter=True)
@ -111,6 +78,9 @@ async def analytics(
Session.ended_at.is_not(None),
)
)
if owner_login is not None:
attempts_query = attempts_query.where(Session.owner_login == owner_login)
rows = await db.execute(attempts_query)
attempts = [
ScoredAttempt(
trainee_id=trainee_id,
@ -123,3 +93,116 @@ async def analytics(
group=GroupOut(id=group.id, name=group.name),
**summarize(attempts, int(enrolled or 0)),
)
@router.get("", response_model=list[GroupOut])
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
query = select(Group).order_by(Group.name)
if who.role is Role.INSTRUCTOR:
query = query.where(Group.owner_login == who.login)
groups = await db.scalars(query)
return [GroupOut(id=group.id, name=group.name) for group in groups]
@router.post("", response_model=GroupOut, status_code=201)
async def create(
body: GroupCreate, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
name = body.name.strip()
if not name:
raise HTTPException(status_code=422, detail="group_name_required")
group = Group(name=name, owner_login=who.login if who.role is Role.INSTRUCTOR else None)
db.add(group)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="group_exists") from exc
await audit(who.login, who.role.value, "group.create", str(group.id), group.name)
return GroupOut(id=group.id, name=group.name)
@router.patch("/{group_id}/owner", response_model=GroupOut)
async def transfer_ownership(
group_id: UUID,
body: GroupOwnerPatch,
request: Request,
db: AsyncSession = Depends(get_session),
) -> GroupOut:
"""Администратор безопасно закрепляет legacy-группу за преподавателем."""
who = require(request, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None:
raise HTTPException(status_code=404, detail="group_not_found")
if body.owner_login is not None:
user = await db.scalar(select(User).where(User.login == body.owner_login))
if user is None or user.role != Role.INSTRUCTOR.value or user.blocked:
raise HTTPException(status_code=422, detail="active_instructor_required")
previous_owner = group.owner_login
group.owner_login = body.owner_login
await db.commit()
await audit(
who.login,
who.role.value,
"group.transfer",
str(group.id),
f"{previous_owner or 'admin'} -> {body.owner_login or 'admin'}",
)
return GroupOut(id=group.id, name=group.name)
@router.put("/{group_id}/trainees/{trainee_id}", response_model=GroupOut)
async def assign_trainee(
group_id: UUID, trainee_id: UUID, request: Request,
db: AsyncSession = Depends(get_session),
) -> GroupOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
group = await db.get(Group, group_id)
if group is None or (who.role is Role.INSTRUCTOR and group.owner_login != who.login):
raise HTTPException(status_code=404, detail="group_not_found")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
if (
who.role is Role.INSTRUCTOR
and trainee.group_id is not None
and trainee.group_id != group_id
):
current_group = await db.get(Group, trainee.group_id)
if current_group is None or current_group.owner_login != who.login:
raise HTTPException(status_code=409, detail="trainee_in_other_instructor_group")
trainee.group_id = group_id
await db.commit()
await audit(who.login, who.role.value, "group.assign", str(group.id), str(trainee_id))
return GroupOut(id=group.id, name=group.name)
@router.get("/{group_id}/analytics", response_model=GroupAnalyticsOut)
async def analytics(
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupAnalyticsOut:
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
return await _analytics(
group_id, db, owner_login=who.login if who.role is Role.INSTRUCTOR else None
)
@router.post("/{group_id}/analytics/insight", response_model=GroupInsightOut)
async def ai_insight(
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
) -> GroupInsightOut:
"""Сформировать по запросу преподавателя локальный обезличенный инсайт."""
who = require(request, Role.INSTRUCTOR)
data = await _analytics(group_id, db, owner_login=who.login)
if data.scored_attempts == 0:
raise HTTPException(status_code=409, detail="no_scored_attempts")
try:
insight = await generate_group_insight(data.model_dump(mode="json"))
except LlmUnavailable as exc:
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
except InsightInvalid as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "group.ai_insight", str(group_id), "aggregated_only")
return GroupInsightOut(**insight)

View file

@ -0,0 +1,549 @@
"""Учебные материалы, локальные вложения и назначения курсантам.
Файлы хранятся в PostgreSQL и отдаются только как attachment: учебный контур
не зависит от внешнего файлового сервиса и не исполняет загруженный HTML.
"""
import base64
import binascii
import hashlib
from collections.abc import AsyncIterator
from datetime import datetime, timezone
from typing import Literal
from urllib.parse import quote
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import DEMO_TRAINEE_ID, audit, require
from app.config import get_settings
from app.db.base import get_session
from app.db.models import Group, LearningMaterial, MaterialAssignment, Trainee
from app.domain.events import Exercise, ScenarioStart, SessionMode
from app.domain.roles import Role
from app.scenarios import store
from app.session.hub import hub
router = APIRouter(prefix="/api/materials", tags=["materials"])
MAX_FILE_BYTES = 5 * 1024 * 1024
LEVELS = {"L1", "L2", "L3"}
_DEMO_GUIDE_ID = UUID("00000000-0000-4000-8000-000000000901")
_demo_materials: dict[UUID, LearningMaterial] = {}
_demo_assignments: dict[tuple[UUID, UUID], dict] = {}
def reset_demo_materials() -> None:
"""Демонстрационный справочник воспроизводим после каждого старта."""
_demo_materials.clear()
_demo_assignments.clear()
now = datetime.now(timezone.utc)
_demo_materials[_DEMO_GUIDE_ID] = LearningMaterial(
id=_DEMO_GUIDE_ID,
title="Памятка диспетчера ДДС",
description="Короткий алгоритм работы с готовой карточкой происшествия.",
level="L1",
kind="text",
body=(
"1. Подтвердите получение карточки.\n"
"2. Проверьте зону ответственности и список оповещения.\n"
"3. Назначьте бригаду и передайте адрес, событие и задачу.\n"
"4. Фиксируйте выезд, прибытие, локализацию и завершение работ."
),
scenario_id="t01-1-fire-container",
file_name=None,
media_type=None,
file_data=None,
file_sha256=None,
active=True,
created_by="system",
created_at=now,
updated_at=now,
)
_demo_assignments[(_DEMO_GUIDE_ID, DEMO_TRAINEE_ID)] = {
"assigned_by": "system", "assigned_at": now, "completed_at": None,
}
async def material_session() -> AsyncIterator[AsyncSession | None]:
if get_settings().demo_no_db:
yield None
else:
async for db in get_session():
yield db
class MaterialCreate(BaseModel):
title: str = Field(min_length=3, max_length=200)
description: str = Field(default="", max_length=2000)
level: Literal["L1", "L2", "L3"] = "L1"
kind: Literal["text", "file"] = "text"
body: str = Field(default="", max_length=30_000)
scenario_id: str | None = Field(default=None, max_length=80)
file_name: str | None = Field(default=None, max_length=240)
media_type: str | None = Field(default=None, max_length=120)
content_base64: str | None = None
@model_validator(mode="after")
def valid_content(self):
if self.kind == "text" and not self.body.strip():
raise ValueError("текст материала пуст")
if self.kind == "file" and (not self.file_name or not self.content_base64):
raise ValueError("для файла нужны имя и содержимое")
return self
class MaterialPatch(BaseModel):
title: str | None = Field(default=None, min_length=3, max_length=200)
description: str | None = Field(default=None, max_length=2000)
level: Literal["L1", "L2", "L3"] | None = None
body: str | None = Field(default=None, max_length=30_000)
scenario_id: str | None = Field(default=None, max_length=80)
active: bool | None = None
class MaterialOut(BaseModel):
id: UUID
title: str
description: str
level: str
kind: str
body: str
scenario_id: str | None
file_name: str | None
media_type: str | None
file_size: int
file_sha256: str | None
active: bool
created_by: str
created_at: datetime
assigned_at: datetime | None = None
completed_at: datetime | None = None
assignment_count: int = 0
def _out(
row: LearningMaterial, *, assignment: MaterialAssignment | dict | None = None,
assignment_count: int = 0,
) -> MaterialOut:
if isinstance(assignment, dict):
assigned_at = assignment.get("assigned_at")
completed_at = assignment.get("completed_at")
else:
assigned_at = assignment.assigned_at if assignment else None
completed_at = assignment.completed_at if assignment else None
return MaterialOut(
id=row.id,
title=row.title,
description=row.description,
level=row.level,
kind=row.kind,
body=row.body,
scenario_id=row.scenario_id,
file_name=row.file_name,
media_type=row.media_type,
file_size=len(row.file_data or b""),
file_sha256=row.file_sha256,
active=row.active,
created_by=row.created_by,
created_at=row.created_at,
assigned_at=assigned_at,
completed_at=completed_at,
assignment_count=assignment_count,
)
def _decode_file(payload: MaterialCreate) -> bytes | None:
if payload.kind != "file":
return None
try:
data = base64.b64decode(payload.content_base64 or "", validate=True)
except (binascii.Error, ValueError) as exc:
raise HTTPException(status_code=422, detail="invalid_file_base64") from exc
if not data:
raise HTTPException(status_code=422, detail="empty_file")
if len(data) > MAX_FILE_BYTES:
raise HTTPException(status_code=413, detail="file_too_large_5mb")
return data
def _validate_scenario(scenario_id: str | None) -> None:
if scenario_id and store.get(scenario_id) is None:
raise HTTPException(status_code=422, detail="scenario_not_found")
def _safe_filename(value: str) -> str:
"""Убрать и POSIX-, и Windows-путь; в БД остаётся только имя файла."""
return value.replace("\\", "/").rsplit("/", 1)[-1] or "resource.bin"
@router.get("", response_model=list[MaterialOut])
async def listing(
request: Request,
include_archived: bool = False,
db: AsyncSession | None = Depends(material_session),
) -> list[MaterialOut]:
who = require(request)
if db is None:
if who.role is Role.TRAINEE:
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
result = []
for material_id, row in _demo_materials.items():
assignment = _demo_assignments.get((material_id, who.trainee_id))
if assignment and row.active:
result.append(_out(row, assignment=assignment))
return result
return [
_out(row, assignment_count=sum(mid == row.id for mid, _ in _demo_assignments))
for row in _demo_materials.values()
if (include_archived or row.active)
and (who.role is not Role.INSTRUCTOR or row.created_by in {who.login, "system"})
]
if who.role is Role.TRAINEE:
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
rows = await db.execute(
select(LearningMaterial, MaterialAssignment)
.join(MaterialAssignment, MaterialAssignment.material_id == LearningMaterial.id)
.where(
MaterialAssignment.trainee_id == who.trainee_id,
LearningMaterial.active.is_(True),
)
.order_by(MaterialAssignment.assigned_at.desc())
)
return [_out(row, assignment=assignment) for row, assignment in rows]
require(request, Role.INSTRUCTOR, Role.ADMIN)
statement = (
select(LearningMaterial, func.count(MaterialAssignment.id))
.outerjoin(MaterialAssignment, MaterialAssignment.material_id == LearningMaterial.id)
.group_by(LearningMaterial.id)
.order_by(LearningMaterial.active.desc(), LearningMaterial.updated_at.desc())
)
if who.role is Role.INSTRUCTOR:
statement = statement.where(LearningMaterial.created_by == who.login)
if not include_archived:
statement = statement.where(LearningMaterial.active.is_(True))
rows = await db.execute(statement)
return [_out(row, assignment_count=count) for row, count in rows]
@router.post("", response_model=MaterialOut, status_code=201)
async def create(
payload: MaterialCreate,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> MaterialOut:
who = require(request, Role.INSTRUCTOR)
_validate_scenario(payload.scenario_id)
data = _decode_file(payload)
now = datetime.now(timezone.utc)
row = LearningMaterial(
id=uuid4(),
title=payload.title.strip(),
description=payload.description.strip(),
level=payload.level,
kind=payload.kind,
body=payload.body.strip(),
scenario_id=payload.scenario_id,
file_name=_safe_filename(payload.file_name) if payload.file_name else None,
media_type=(payload.media_type or "application/octet-stream") if data else None,
file_data=data,
file_sha256=hashlib.sha256(data).hexdigest() if data else None,
active=True,
created_by=who.login,
created_at=now,
updated_at=now,
)
if db is None:
_demo_materials[row.id] = row
else:
db.add(row)
await db.commit()
await audit(who.login, who.role.value, "material.create", str(row.id), row.title)
return _out(row)
async def _material(db: AsyncSession | None, material_id: UUID) -> LearningMaterial | None:
return _demo_materials.get(material_id) if db is None else await db.get(LearningMaterial, material_id)
def _require_owner(row: LearningMaterial, login: str) -> None:
"""Only the instructor who authored a resource may manage it."""
if row.created_by != login:
raise HTTPException(status_code=404, detail="material_not_found")
@router.patch("/{material_id}", response_model=MaterialOut)
async def update(
material_id: UUID,
payload: MaterialPatch,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> MaterialOut:
who = require(request, Role.INSTRUCTOR)
row = await _material(db, material_id)
if row is None:
raise HTTPException(status_code=404, detail="material_not_found")
_require_owner(row, who.login)
patch = payload.model_dump(exclude_unset=True)
if "scenario_id" in patch:
patch["scenario_id"] = patch["scenario_id"] or None
_validate_scenario(patch["scenario_id"])
if row.kind == "text" and "body" in patch and not (patch["body"] or "").strip():
raise HTTPException(status_code=422, detail="empty_material_body")
for key, value in patch.items():
setattr(row, key, value.strip() if isinstance(value, str) else value)
row.updated_at = datetime.now(timezone.utc)
if db is not None:
await db.commit()
await audit(who.login, who.role.value, "material.update", str(row.id))
return _out(row)
@router.delete("/{material_id}", response_model=MaterialOut)
async def archive(
material_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> MaterialOut:
who = require(request, Role.INSTRUCTOR)
row = await _material(db, material_id)
if row is None:
raise HTTPException(status_code=404, detail="material_not_found")
_require_owner(row, who.login)
row.active = False
row.updated_at = datetime.now(timezone.utc)
if db is not None:
await db.commit()
await audit(who.login, who.role.value, "material.archive", str(row.id))
return _out(row)
@router.put("/{material_id}/assign/{trainee_id}", response_model=MaterialOut)
async def assign(
material_id: UUID,
trainee_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> MaterialOut:
who = require(request, Role.INSTRUCTOR)
row = await _material(db, material_id)
if row is None or not row.active:
raise HTTPException(status_code=404, detail="material_not_found")
_require_owner(row, who.login)
if db is None:
if trainee_id != DEMO_TRAINEE_ID:
raise HTTPException(status_code=404, detail="trainee_not_found")
assignment = _demo_assignments.setdefault(
(material_id, trainee_id),
{"assigned_by": who.login, "assigned_at": datetime.now(timezone.utc), "completed_at": None},
)
else:
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
if trainee.group_id is not None:
group = await db.get(Group, trainee.group_id)
if group is None or group.owner_login != who.login:
raise HTTPException(status_code=404, detail="trainee_not_found")
assignment = await db.scalar(select(MaterialAssignment).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id == trainee_id,
))
if assignment is None:
assignment = MaterialAssignment(
material_id=material_id, trainee_id=trainee_id, assigned_by=who.login
)
db.add(assignment)
await db.commit()
await db.refresh(assignment)
await audit(who.login, who.role.value, "material.assign", str(row.id), str(trainee_id))
return _out(row, assignment=assignment)
@router.put("/{material_id}/assign-group/{group_id}")
async def assign_group(
material_id: UUID,
group_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await _material(db, material_id)
if row is None or not row.active:
raise HTTPException(status_code=404, detail="material_not_found")
_require_owner(row, who.login)
if db is None:
raise HTTPException(status_code=409, detail="groups_unavailable_in_demo")
group = await db.get(Group, group_id)
if group is None or group.owner_login != who.login:
raise HTTPException(status_code=404, detail="group_not_found")
trainee_ids = list(await db.scalars(select(Trainee.id).where(Trainee.group_id == group_id)))
existing = set(await db.scalars(select(MaterialAssignment.trainee_id).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id.in_(trainee_ids),
))) if trainee_ids else set()
for trainee_id in trainee_ids:
if trainee_id not in existing:
db.add(MaterialAssignment(
material_id=material_id, trainee_id=trainee_id, assigned_by=who.login
))
await db.commit()
await audit(who.login, who.role.value, "material.assign_group", str(row.id), str(group_id))
return {"material_id": str(row.id), "assigned": len(trainee_ids)}
@router.delete("/{material_id}/assign/{trainee_id}")
async def unassign(
material_id: UUID,
trainee_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await _material(db, material_id)
if row is None:
raise HTTPException(status_code=404, detail="material_not_found")
_require_owner(row, who.login)
if db is None:
removed = _demo_assignments.pop((material_id, trainee_id), None) is not None
else:
assignment = await db.scalar(select(MaterialAssignment).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id == trainee_id,
))
if assignment is not None and assignment.assigned_by != who.login:
raise HTTPException(status_code=404, detail="assignment_not_found")
if assignment is not None:
await db.delete(assignment)
await db.commit()
removed = assignment is not None
await audit(who.login, who.role.value, "material.unassign", str(material_id), str(trainee_id))
return {"removed": removed}
@router.post("/{material_id}/complete", response_model=MaterialOut)
async def complete(
material_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> MaterialOut:
who = require(request, Role.TRAINEE)
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
row = await _material(db, material_id)
if row is None or not row.active:
raise HTTPException(status_code=404, detail="material_not_found")
if db is None:
assignment = _demo_assignments.get((material_id, who.trainee_id))
else:
assignment = await db.scalar(select(MaterialAssignment).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id == who.trainee_id,
))
if assignment is None:
raise HTTPException(status_code=403, detail="material_not_assigned")
completed_at = datetime.now(timezone.utc)
if isinstance(assignment, dict):
assignment["completed_at"] = completed_at
else:
assignment.completed_at = completed_at
await db.commit()
await audit(who.login, who.role.value, "material.complete", str(material_id))
return _out(row, assignment=assignment)
@router.post("/{material_id}/start")
async def start_assigned_practice(
material_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> dict:
"""Курсант сам выбирает назначенный модуль и запускает текстовую практику."""
who = require(request, Role.TRAINEE)
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
row = await _material(db, material_id)
if row is None or not row.active:
raise HTTPException(status_code=404, detail="material_not_found")
if db is None:
assignment = _demo_assignments.get((material_id, who.trainee_id))
else:
assignment = await db.scalar(select(MaterialAssignment).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id == who.trainee_id,
))
if assignment is None:
raise HTTPException(status_code=403, detail="material_not_assigned")
if not row.scenario_id:
raise HTTPException(status_code=409, detail="material_has_no_practice")
scenario = store.get(row.scenario_id)
if scenario is None:
raise HTTPException(status_code=409, detail="scenario_not_found")
# Явное назначение преподавателя — и есть разрешение на самостоятельный
# модуль. Оно не открывает курсанту остальные сценарии библиотеки.
from app.api.ws.control import _start
session_id = uuid4()
await _start(session_id, ScenarioStart(
scenario_id=scenario.id,
trainee=who.full_name,
trainee_id=who.trainee_id,
mode=SessionMode.SELF,
exercise=Exercise.CARD,
), who)
if hub.get(session_id) is None:
raise HTTPException(status_code=409, detail="practice_start_failed")
return {
"session_id": str(session_id),
"scenario_id": scenario.id,
"mode": SessionMode.SELF.value,
"exercise": Exercise.CARD.value,
"path": f"/trainee?session={session_id}",
}
@router.get("/{material_id}/download")
async def download(
material_id: UUID,
request: Request,
db: AsyncSession | None = Depends(material_session),
) -> Response:
who = require(request)
row = await _material(db, material_id)
if row is None or not row.active or row.kind != "file" or row.file_data is None:
raise HTTPException(status_code=404, detail="file_not_found")
if who.role is Role.TRAINEE:
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
if db is None:
allowed = (material_id, who.trainee_id) in _demo_assignments
else:
allowed = await db.scalar(select(MaterialAssignment.id).where(
MaterialAssignment.material_id == material_id,
MaterialAssignment.trainee_id == who.trainee_id,
)) is not None
if not allowed:
raise HTTPException(status_code=403, detail="material_not_assigned")
elif who.role is Role.INSTRUCTOR:
_require_owner(row, who.login)
elif who.role not in {Role.INSTRUCTOR, Role.ADMIN}:
raise HTTPException(status_code=403, detail="forbidden")
filename = _safe_filename(row.file_name or "resource.bin")
disposition = f"attachment; filename=resource; filename*=UTF-8''{quote(filename)}"
return Response(
content=row.file_data,
media_type=row.media_type or "application/octet-stream",
headers={
"Content-Disposition": disposition,
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'",
},
)

View file

@ -10,6 +10,7 @@
`hint.shown` из живой сессии, эталонные вопросы — только в разборе.
"""
from collections.abc import AsyncIterator
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
@ -19,49 +20,126 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
from app.domain import ekp
from app.db.base import get_session
from app.config import get_settings
from app.domain.roles import Role
from app.scenarios import store
from app.scenarios.editor import validate
from app.scenarios.generation import GenerationError, generate, generate_from_description
from app.dialog.llm import LlmUnavailable
from app.scenarios.loader import ScenarioError
from app.session.hub import hub
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"}
async def scenario_session() -> AsyncIterator[AsyncSession | None]:
"""Только редактор в demo-lite использует временное хранилище без БД."""
if get_settings().demo_no_db:
yield None
else:
async for db in get_session():
yield db
class TemplateDraftIn(BaseModel):
source_id: str = Field(min_length=1)
title: str | None = Field(default=None, min_length=1, max_length=200)
class GenerateDraftIn(BaseModel):
source_id: str = Field(min_length=1)
instruction: str = Field(min_length=10, max_length=1000)
class GenerateFullDraftIn(BaseModel):
source_id: str = Field(min_length=1)
description: str = Field(min_length=20, max_length=1500)
class ReviseDraftIn(BaseModel):
comment: str = Field(min_length=10, max_length=1000)
def _draft_out(row) -> dict:
if row.id.startswith("ai-full-"):
generation = "ai_full"
elif row.id.startswith("ai-"):
generation = "ai_variant"
else:
generation = "template_copy"
return {
"id": row.id,
"status": row.status,
"generation": "template_copy",
"generation": generation,
"body": row.body,
}
@router.post("/drafts/from-template", status_code=201)
async def create_template_draft(
body: TemplateDraftIn, request: Request, db: AsyncSession = Depends(get_session)
body: TemplateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
who = require(request, Role.INSTRUCTOR)
source = store.get(body.source_id)
if source is None:
raise HTTPException(status_code=404, detail="published_source_not_found")
row = await store.create_draft(db, source=source, title=body.title)
row = await store.create_draft(db, source=source, title=body.title, owner_login=who.login)
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
return _draft_out(row)
@router.post("/drafts/generate", status_code=201)
async def create_ai_draft(
body: GenerateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
who = require(request, Role.INSTRUCTOR)
source = store.get(body.source_id)
if source is None:
raise HTTPException(status_code=404, detail="published_source_not_found")
try:
proposal = await generate(source, body.instruction.strip(), require_fact_change=False)
row = await store.create_draft(db, source=source, proposal=proposal, owner_login=who.login)
except LlmUnavailable as exc:
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
except GenerationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "scenario.draft.ai_generate", row.id,
f"source:{source.id}")
return _draft_out(row)
@router.post("/drafts/generate-from-description", status_code=201)
async def create_full_ai_draft(
body: GenerateFullDraftIn, request: Request,
db: AsyncSession | None = Depends(scenario_session),
) -> dict:
"""Новый сюжет и мягкий эталон внутри выбранного класса ЕКП."""
who = require(request, Role.INSTRUCTOR)
source = store.get(body.source_id)
if source is None:
raise HTTPException(status_code=404, detail="published_source_not_found")
try:
proposal = await generate_from_description(source, body.description.strip())
row = await store.create_draft(
db, source=source, full_proposal=proposal, owner_login=who.login
)
except LlmUnavailable as exc:
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
except GenerationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "scenario.draft.ai_generate_full", row.id,
f"class_source:{source.id}")
return _draft_out(row)
@router.get("/drafts/{scenario_id}")
async def read_draft(
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id)
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
return _draft_out(row)
@ -72,10 +150,10 @@ async def patch_draft(
scenario_id: str,
body: dict[str, Any],
request: Request,
db: AsyncSession = Depends(get_session),
db: AsyncSession | None = Depends(scenario_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id)
row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
@ -86,12 +164,36 @@ async def patch_draft(
return _draft_out(row)
@router.post("/drafts/{scenario_id}/revise")
async def revise_ai_draft(
scenario_id: str,
body: ReviseDraftIn,
request: Request,
db: AsyncSession | None = Depends(scenario_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
source = validate(row.body)
proposal = await generate(source, body.comment.strip(), require_fact_change=False)
row = await store.revise_draft(db, row, proposal)
except LlmUnavailable as exc:
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
except (GenerationError, ScenarioError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await audit(who.login, who.role.value, "scenario.draft.ai_revise", row.id,
body.comment.strip()[:500])
return _draft_out(row)
@router.post("/drafts/{scenario_id}/validate")
async def validate_draft(
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id)
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
@ -107,10 +209,10 @@ async def validate_draft(
@router.post("/drafts/{scenario_id}/approve")
async def approve_draft(
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> dict:
who = require(request, Role.INSTRUCTOR)
row = await store.draft(db, scenario_id)
row = await store.draft(db, scenario_id, owner_login=who.login)
if row is None:
raise HTTPException(status_code=404, detail="draft_not_found")
try:
@ -122,7 +224,15 @@ async def approve_draft(
@router.get("")
async def listing() -> list[dict]:
async def listing(
request: Request, db: AsyncSession | None = Depends(scenario_session)
) -> list[dict]:
who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
owned_ids = (
await store.owned_scenario_ids(db, who.login)
if who is not None and who.role is Role.INSTRUCTOR
else set()
)
return [
{
"id": scenario.id,
@ -131,19 +241,57 @@ async def listing() -> list[dict]:
"level": scenario.level.value,
"topics": scenario.topics,
"modes": scenario.modes,
# Преподаватель должен видеть не только название карточки, но и
# зафиксированный путь классификатора. ИИ меняет сюжет внутри
# этого пути, а не незаметно подменяет код происшествия.
"signs": scenario.signs,
"incident_code": scenario.ground_truth.incident_code,
"dds": scenario.ground_truth.dds.value if scenario.ground_truth.dds else None,
"ticket": scenario.ticket,
"position": scenario.position,
"ekp_group": (ekp.incident(scenario.ground_truth.incident_code).group
if scenario.ground_truth.incident_code
and ekp.incident(scenario.ground_truth.incident_code) else None),
"can_manage": scenario.id in owned_ids,
}
for scenario in store.all_scenarios()
]
@router.delete("/{scenario_id}")
async def archive_scenario(
scenario_id: str, request: Request,
db: AsyncSession | None = Depends(scenario_session),
) -> dict:
"""Мягкое удаление: история занятий остаётся целой, сценарий можно вернуть."""
who = require(request, Role.INSTRUCTOR)
if hub.has_active_scenario(scenario_id):
raise HTTPException(status_code=409, detail="scenario_is_used_by_active_session")
scenario = await store.archive(db, scenario_id, owner_login=who.login)
if scenario is None:
raise HTTPException(status_code=404, detail="scenario_not_found")
await audit(who.login, who.role.value, "scenario.archive", scenario_id)
return {"id": scenario_id, "status": "archived", "title": scenario.title}
@router.post("/{scenario_id}/restore")
async def restore_scenario(
scenario_id: str, request: Request,
db: AsyncSession | None = Depends(scenario_session),
) -> dict:
who = require(request, Role.INSTRUCTOR)
scenario = await store.restore_archived(db, scenario_id, owner_login=who.login)
if scenario is None:
raise HTTPException(status_code=404, detail="archived_scenario_not_found")
await audit(who.login, who.role.value, "scenario.restore", scenario_id)
return {"id": scenario_id, "status": "published", "title": scenario.title}
@router.get("/{scenario_id}")
async def read(scenario_id: str) -> dict:
async def read(scenario_id: str, request: Request) -> dict:
# Training content is local but not public: anonymous clients must not be
# able to enumerate cards or inspect even the trainee-safe scenario body.
require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
scenario = store.get(scenario_id)
if scenario is None:
raise HTTPException(status_code=404, detail="scenario_not_found")

View file

@ -8,18 +8,23 @@ from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import audit, require
from app.config import get_settings
from app.db import repo
from app.db.base import get_session
from app.db.models import AuditLog, Score
from app.domain.events import Exercise, SessionMode, SessionReport
from app.scenarios import store
from app.scoring.report import build as build_report
from app.scoring.export import to_csv, to_pdf
from app.domain.roles import Role
from app.session.hub import hub
from app.voice.recording import recording_path
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
@ -59,8 +64,11 @@ def _out(session) -> SessionOut:
@router.post("", response_model=SessionOut, status_code=201)
async def create(body: SessionCreate, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
require(request, Role.INSTRUCTOR)
group = await repo.ensure_group(db, body.group) if body.group else None
who = require(request, Role.INSTRUCTOR)
try:
group = await repo.ensure_group(db, body.group, owner_login=who.login) if body.group else None
except PermissionError as exc:
raise HTTPException(status_code=404, detail="group_not_found") from exc
trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None
session = await repo.create_session(
db,
@ -68,6 +76,14 @@ async def create(body: SessionCreate, request: Request, db: AsyncSession = Depen
mode=body.mode.value,
trainee_id=trainee.id if trainee else None,
group_id=group.id if group else None,
owner_login=who.login,
)
await audit(
who.login,
who.role.value,
"session.create",
str(session.id),
f"scenario={session.scenario_id}; mode={session.mode}; attempt={session.attempt}",
)
return _out(session)
@ -80,6 +96,8 @@ async def read(session_id: UUID, request: Request, db: AsyncSession = Depends(ge
raise HTTPException(status_code=404, detail="session_not_found")
if who.role is Role.TRAINEE and session.trainee_id != who.trainee_id:
raise HTTPException(status_code=403, detail="not_your_session")
if who.role is Role.INSTRUCTOR and session.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
return _out(session)
@ -102,6 +120,8 @@ async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut
raise HTTPException(status_code=404, detail="session_not_found")
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
raise HTTPException(status_code=403, detail="not_your_session")
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
if not state.ended:
raise HTTPException(status_code=409, detail="call_not_ended")
scenario = state.scenario or store.get(state.scenario_id)
@ -118,7 +138,15 @@ class ScoreOverride(BaseModel):
"""Коррекция оценки преподавателем. Автооценка сохраняется рядом."""
score_final: float = Field(ge=0, le=100)
comment: str = ""
comment: str = Field(min_length=1, max_length=2000)
@field_validator("comment")
@classmethod
def comment_must_not_be_blank(cls, comment: str) -> str:
cleaned = comment.strip()
if not cleaned:
raise ValueError("обоснование корректировки обязательно")
return cleaned
def _live(session_id: UUID):
@ -131,8 +159,9 @@ def _live(session_id: UUID):
return state, scenario
@router.get("/{session_id}/report", response_model=SessionReport)
async def report(session_id: UUID, request: Request) -> SessionReport:
async def _report_data(
session_id: UUID, request: Request, db: AsyncSession,
) -> SessionReport:
"""Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки.
Обучающийся открывает только свой разбор: ТЗ запрещает доступ к чужим
@ -140,20 +169,62 @@ async def report(session_id: UUID, request: Request) -> SessionReport:
занятия, а не по номеру в ссылке.
"""
who = require(request)
state, scenario = _live(session_id)
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
try:
state, scenario = _live(session_id)
except HTTPException as exc:
if exc.status_code != 404:
raise
state = None
scenario = None
if state is not None and scenario is not None:
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
raise HTTPException(status_code=403, detail="not_your_session")
if who.role is Role.TRAINEE and state.exercise is Exercise.CALL and not state.self_assessed:
raise HTTPException(status_code=409, detail="self_assessment_required")
if state.score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
return build_report(session_id, state, scenario)
session = await repo.get_session(db, session_id)
if session is None:
raise HTTPException(status_code=404, detail="session_not_found")
if who.role is Role.TRAINEE and session.trainee_id != who.trainee_id:
raise HTTPException(status_code=403, detail="not_your_session")
if who.role is Role.TRAINEE and state.exercise is Exercise.CALL and not state.self_assessed:
raise HTTPException(status_code=409, detail="self_assessment_required")
if state.score is None:
if who.role is Role.INSTRUCTOR and session.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
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")
return build_report(session_id, state, scenario)
archived = (score.report or {}).get("full_report")
if archived is None:
raise HTTPException(status_code=409, detail="report_not_archived")
data = SessionReport.model_validate(archived)
if (who.role is Role.TRAINEE and data.reference_questions
and data.self_assessment is None):
raise HTTPException(status_code=409, detail="self_assessment_required")
return data.model_copy(update={
"score_auto": score.score_auto,
"score_final": score.score_final,
"overridden_by": score.overridden_by,
"override_comment": score.override_comment,
})
@router.get("/{session_id}/report", response_model=SessionReport)
async def report(
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
) -> SessionReport:
return await _report_data(session_id, request, db)
@router.get("/{session_id}/report.csv")
async def report_csv(session_id: UUID, request: Request) -> Response:
async def report_csv(
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
) -> Response:
"""Те же права и готовность оценки, что у JSON-разбора."""
data = await report(session_id, request)
data = await _report_data(session_id, request, db)
return Response(
content=to_csv(data), media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.csv"'},
@ -161,9 +232,11 @@ async def report_csv(session_id: UUID, request: Request) -> Response:
@router.get("/{session_id}/report.pdf")
async def report_pdf(session_id: UUID, request: Request) -> Response:
async def report_pdf(
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
) -> Response:
"""Печатный разбор; генерация полностью локальна."""
data = await report(session_id, request)
data = await _report_data(session_id, request, db)
try:
content = to_pdf(data)
except RuntimeError as exc:
@ -174,31 +247,120 @@ async def report_pdf(session_id: UUID, request: Request) -> Response:
)
@router.get("/{session_id}/recording.wav")
async def recording(session_id: UUID, request: Request, db: AsyncSession = Depends(get_session)):
"""Запись учебного звонка: преподавателю либо владельцу занятия."""
who = require(request, Role.INSTRUCTOR, Role.TRAINEE)
if get_settings().demo_no_db:
state = hub.get(session_id)
if state is None:
raise HTTPException(status_code=404, detail="session_not_found")
owner_id = state.trainee_id
owner_login = state.owner_login
ended = state.ended
else:
row = await repo.get_session(db, session_id)
if row is None:
raise HTTPException(status_code=404, detail="session_not_found")
owner_id = row.trainee_id
owner_login = row.owner_login
ended = row.ended_at is not None
if who.role is Role.TRAINEE and (owner_id is None or owner_id != who.trainee_id):
raise HTTPException(status_code=403, detail="not_your_recording")
if who.role is Role.INSTRUCTOR and owner_login != who.login:
raise HTTPException(status_code=404, detail="recording_not_found")
if not ended:
raise HTTPException(status_code=409, detail="recording_not_ready")
path = recording_path(session_id)
if not path.is_file():
raise HTTPException(status_code=404, detail="recording_not_found")
return FileResponse(
path,
media_type="audio/wav",
filename=f"session-{session_id}-recording.wav",
)
@router.patch("/{session_id}/report", response_model=SessionReport)
async def override(session_id: UUID, body: ScoreOverride, request: Request) -> SessionReport:
async def override(
session_id: UUID,
body: ScoreOverride,
request: Request,
db: AsyncSession = Depends(get_session),
) -> SessionReport:
"""Тренажёр готовит материал, преподаватель имеет последнее слово.
Администратору сюда нельзя: ТЗ запрещает ему вмешиваться в оценки прямо.
"""
who = require(request, Role.INSTRUCTOR)
state, scenario = _live(session_id)
if state.score is None:
session = await repo.get_session(db, session_id)
if session is None:
raise HTTPException(status_code=404, detail="session_not_found")
if session.owner_login != who.login:
raise HTTPException(status_code=404, detail="session_not_found")
state = hub.get(session_id)
live_ready = state is not None and state.score is not None
scenario = (state.scenario or store.get(state.scenario_id)) if live_ready else None
if live_ready and scenario is None:
raise HTTPException(status_code=409, detail="scenario_not_found")
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")
state.score = {
**state.score,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
}
if hub.journal:
await hub.journal.score_override(
session_id, body.score_final, who.login, body.comment
)
await audit(
who.login, who.role.value, "score.override", str(session_id),
f"{state.score.get('score_auto')} → {body.score_final}: {body.comment}",
)
return build_report(session_id, state, scenario)
archived = (score.report or {}).get("full_report")
if not live_ready and archived is None:
raise HTTPException(status_code=409, detail="report_not_archived")
# Persist score and audit entry in the same request transaction: the API
# must not report success if either durable record failed to commit.
score.score_final = body.score_final
score.overridden_by = who.login
score.override_comment = body.comment
report_payload = dict(score.report or {})
if archived is not None:
archived_payload = dict(archived)
archived_payload.update({
"score_auto": score.score_auto,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
})
report_payload["full_report"] = archived_payload
score.report = report_payload
db.add(AuditLog(
actor=who.login,
role=who.role.value,
action="score.override",
object_id=str(session_id),
detail=f"{score.score_auto} → {body.score_final}: {body.comment}"[:2000],
))
await db.commit()
if live_ready:
assert state is not None and state.score is not None and scenario is not None
state.score = {
**state.score,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
}
state.score["full_report"] = {
**state.score.get("full_report", {}),
"score_auto": score.score_auto,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
}
result = build_report(session_id, state, scenario)
else:
# Historical/archived sessions no longer have a live object in the hub.
# Keep score correction available from the instructor's report history.
result = SessionReport.model_validate(archived).model_copy(update={
"score_auto": score.score_auto,
"score_final": body.score_final,
"overridden_by": who.login,
"override_comment": body.comment,
})
return result
@router.get("", response_model=list[SessionOut])
@ -217,11 +379,13 @@ async def listing(
if who.trainee_id is None:
raise HTTPException(status_code=403, detail="trainee_profile_required")
trainee = who.trainee_id
owner_login = who.login if who.role is Role.INSTRUCTOR else None
rows = await repo.history(
db,
trainee_id=trainee,
group_id=group,
mode=mode.value if mode else None,
owner_login=owner_login,
since=since,
limit=limit,
)

View file

@ -8,24 +8,77 @@
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.auth import DEMO_TRAINEE_ID, require
from app.config import get_settings
from app.domain.roles import Role
from app.db.base import get_session, get_sessionmaker
from app.db.models import Group, Score, Session, Trainee
from app.db.models import Group, Score, Session, Trainee, User
from app.scoring.export import certificate_pdf
from app.voice.recording import recording_path
router = APIRouter(prefix="/api/trainees", tags=["trainees"])
@router.get("/{trainee_id}/certificate.pdf")
async def certificate(
trainee_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
) -> Response:
who = require(request)
if who.role is Role.TRAINEE and who.trainee_id != trainee_id:
raise HTTPException(status_code=403, detail="not_your_certificate")
if who.role is Role.INSTRUCTOR:
owns_trainee = await db.scalar(
select(Session.id)
.where(Session.trainee_id == trainee_id, Session.owner_login == who.login)
.limit(1)
)
if owns_trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
group = await db.get(Group, trainee.group_id) if trainee.group_id else None
if who.role is Role.INSTRUCTOR and group is not None and group.owner_login != who.login:
group = None
scores_query = (
select(func.count(Score.id), func.avg(Score.score_final), func.max(Score.created_at))
.join(Session, Session.id == Score.session_id)
.where(Session.trainee_id == trainee_id)
)
if who.role is Role.INSTRUCTOR:
scores_query = scores_query.where(Session.owner_login == who.login)
result = await db.execute(scores_query)
attempts, average_score, completed_at = result.one()
if not attempts:
raise HTTPException(status_code=409, detail="no_scored_attempts")
try:
content = certificate_pdf(
trainee_name=trainee.name,
trainee_id=trainee.id,
group_name=group.name if group else None,
attempts=int(attempts),
average_score=float(average_score),
issued_at=completed_at.date().isoformat(),
)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return Response(
content=content,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="trainee-{trainee_id}-certificate.pdf"'},
)
class TraineeOut(BaseModel):
id: UUID
name: str
group: str | None = None
service: str | None = None
class AttemptOut(BaseModel):
@ -40,6 +93,7 @@ class AttemptOut(BaseModel):
facts_required: int | None = None
hints: int | None = None
codes: dict[str, int] = {}
recording_available: bool = False
class DeltaOut(BaseModel):
@ -65,14 +119,39 @@ class ProfileOut(BaseModel):
async def listing(request: Request) -> list[TraineeOut]:
"""Список курсантов — преподавателю и администратору: обучающемуся он
не нужен, а чужие фамилии из него видны."""
require(request, Role.INSTRUCTOR, Role.ADMIN)
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
if get_settings().demo_no_db:
return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант")]
return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант", service="Служба 101")]
async with get_sessionmaker()() as db:
rows = await db.execute(
select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True)
query = (
select(Trainee, Group.name, Group.owner_login, User.service)
.join(Group, Group.id == Trainee.group_id, isouter=True)
.join(User, User.trainee_id == Trainee.id, isouter=True)
)
return [TraineeOut(id=trainee.id, name=trainee.name, group=group) for trainee, group in rows]
if who.role is Role.INSTRUCTOR:
has_owned_attempt = exists(
select(Session.id).where(
Session.trainee_id == Trainee.id,
Session.owner_login == who.login,
)
)
query = query.where(
or_(
Trainee.group_id.is_(None),
Group.owner_login == who.login,
has_owned_attempt,
)
)
rows = await db.execute(query)
return [
TraineeOut(
id=trainee.id,
name=trainee.name,
group=(group if who.role is not Role.INSTRUCTOR or group_owner == who.login else None),
service=service,
)
for trainee, group, group_owner, service in rows
]
@router.get("/{trainee_id}/profile", response_model=ProfileOut)
@ -82,17 +161,30 @@ async def profile(
who = require(request)
if who.role is Role.TRAINEE and who.trainee_id != trainee_id:
raise HTTPException(status_code=403, detail="not_your_profile")
if who.role is Role.INSTRUCTOR:
owns_trainee = await db.scalar(
select(Session.id)
.where(Session.trainee_id == trainee_id, Session.owner_login == who.login)
.limit(1)
)
if owns_trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
trainee = await db.get(Trainee, trainee_id)
if trainee is None:
raise HTTPException(status_code=404, detail="trainee_not_found")
group = await db.get(Group, trainee.group_id) if trainee.group_id else None
if who.role is Role.INSTRUCTOR and group is not None and group.owner_login != who.login:
group = None
rows = await db.execute(
attempts_query = (
select(Session, Score)
.join(Score, Score.session_id == Session.id, isouter=True)
.where(Session.trainee_id == trainee_id)
.order_by(Session.created_at)
)
if who.role is Role.INSTRUCTOR:
attempts_query = attempts_query.where(Session.owner_login == who.login)
rows = await db.execute(attempts_query)
attempts: list[AttemptOut] = []
competency_sums: dict[str, list[float]] = {}
for session, score in rows:
@ -110,6 +202,7 @@ async def profile(
facts_required=summary.get("facts_required"),
hints=summary.get("hints"),
codes=summary.get("codes", {}),
recording_available=recording_path(session.id).is_file(),
)
)
for item in (score.report or {}).get("competencies", []) if score else []:

View file

@ -33,14 +33,15 @@ from app.domain.events import (
)
from app.domain.events import BgStart
from app.scenarios import store
from app.session.finish import finish, release_score
from app.session.finish import finish, refresh_archived_report, release_score
from app.api.auth import principal_of
from app.domain.roles import Role
from app.session.hub import hub
from app.session.state import now_utc
from app.domain.kio import ResponseStatus
from app.voice.models import get_voice_models
from app.voice.models import TTS_RATE, get_voice_models
from app.voice.pipeline import VoiceSession
from app.voice.recording import start_recording
log = logging.getLogger(__name__)
router = APIRouter()
@ -62,6 +63,8 @@ def _on_audio(session_id: UUID, state, frame: bytes) -> None:
session_id, len(frame), FRAME_BYTES)
return
state.audio_frames += 1
if state.recorder is not None:
state.recorder.add_pcm(frame, sample_rate=16_000)
if state.voice is not None:
state.voice.feed(frame)
if state.audio_frames % FRAMES_PER_LOG == 0:
@ -138,6 +141,8 @@ async def _handle(session_id: UUID, state, event) -> None:
hub.to_observers(session_id, state.snapshot())
if hub.journal:
await hub.journal.session_started(session_id, state.started_at)
if state.recorder is None:
state.recorder = start_recording(session_id)
_start_voice(session_id, state)
case "kio.patch":
@ -220,6 +225,7 @@ async def _handle(session_id: UUID, state, event) -> None:
await hub.journal.self_assessment(
session_id, event.missed, event.comment, now_utc()
)
await refresh_archived_report(session_id, state)
# Оценка могла быть готова раньше самооценки — теперь её можно отдать.
await release_score(session_id, state)
@ -237,6 +243,8 @@ async def _handle(session_id: UUID, state, event) -> None:
)
await finish(session_id, state)
await hub.checkpoint(session_id)
def _start_voice(session_id: UUID, state) -> None:
"""Голос включается, когда курсант снял трубку: звонящий сразу кричит первую реплику."""
@ -244,13 +252,18 @@ def _start_voice(session_id: UUID, state) -> None:
scenario = store.get(state.scenario_id)
if models is None or scenario is None or state.voice is not None:
return
def send_audio(pcm: bytes) -> None:
if state.recorder is not None:
state.recorder.add_pcm(pcm, sample_rate=TTS_RATE)
hub.to_trainee(session_id, pcm)
state.voice = VoiceSession(
session_id=session_id,
state=state,
models=models,
send_event=lambda event: hub.to_trainee(session_id, event),
send_observer=lambda event: hub.to_observers(session_id, event),
send_audio=lambda pcm: hub.to_trainee(session_id, pcm),
send_audio=send_audio,
journal=hub.journal,
)
if scenario.background:

View file

@ -8,43 +8,43 @@
преподавателю, уходит на его же сокет `observe`.
"""
import asyncio
import logging
import math
from uuid import UUID, uuid4
from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.domain.events import (
CallEndReason,
CallEnded,
ErrorEvent,
ErrorKind,
ScoreReady,
CallIncoming,
CardBriefing,
Exercise,
StationState,
InstructorToServer,
InstructorNoteShown,
ModeSet,
ReferenceStarted,
SessionEnded,
)
import asyncio
from app.dialog.factory import build_caller
from app.api.auth import audit, principal_of
from app.dialog.director import apply as apply_directive
from app.dialog.director import mood_of
from app.dialog.factory import build_caller
from app.dialog.persona import PersonaState
from app.dialog.runtime import get_embedder
from app.dialog.slots import SlotMachine
from app.scenarios import store
from app.api.auth import audit, principal_of
from app.domain.events import (
CallEnded,
CallEndReason,
CallIncoming,
CardBriefing,
ErrorEvent,
ErrorKind,
Exercise,
InstructorNoteShown,
InstructorToServer,
ModeSet,
ReferenceStarted,
ScoreReady,
SessionEnded,
StationState,
)
from app.domain.roles import Role
from app.domain.timers import TimerCode
from app.scenarios import store
from app.session.dds import prepare_queue
from app.session.hub import hub
from app.session.state import SessionState, now_utc
from app.session.dds import prepare_card
from app.voice.models import get_voice_models
from app.voice.pipeline import FILLERS, prefetch
@ -93,10 +93,10 @@ async def _start(session_id: UUID, event, who=None) -> None:
scenario_ids = event.scenario_ids or [event.scenario_id]
if event.exercise is Exercise.DDS:
if not scenario_ids or scenario_ids[0] != event.scenario_id or len(scenario_ids) > 10:
if not scenario_ids or scenario_ids[0] != event.scenario_id or len(scenario_ids) > 96:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.SCENARIO_INVALID,
message="Очередь ДДС должна начинаться с scenario_id и содержать не более 10 карточек",
message="Очередь ДДС должна начинаться с scenario_id и содержать не более 96 карточек",
))
return
scenarios = [store.get(scenario_id) for scenario_id in scenario_ids]
@ -110,10 +110,19 @@ async def _start(session_id: UUID, event, who=None) -> None:
attempt = 1
recorded_trainee_id = event.trainee_id
recorded_service = None
if hub.journal:
attempt, recorded_trainee_id = await hub.journal.start_lesson(
session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id
)
try:
attempt, recorded_trainee_id, recorded_service = await hub.journal.start_lesson(
session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id,
owner_login=who.login if who is not None else None,
)
except PermissionError:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
message="Занятие создано другим преподавателем",
))
return
# Занятие собирается целиком и только потом регистрируется: иначе
# наблюдатель, подключившийся в эту щель, увидит полусобранное состояние
@ -124,23 +133,35 @@ async def _start(session_id: UUID, event, who=None) -> None:
scenario_title=scenario.title,
level=scenario.level.value,
mode=event.mode,
owner_login=who.login if who is not None else None,
exercise=event.exercise,
handoff_to_dds=event.handoff_to_dds,
scenario=scenario.model_copy(deep=True),
required_fields=scenario.required_fields,
trainee_name=event.trainee,
trainee_id=recorded_trainee_id,
dds_service=recorded_service or event.dds_service,
attempt=attempt,
criteria=event.criteria,
)
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
if event.exercise is Exercise.CALL:
embedder = get_embedder()
if embedder is not None:
state.slots = SlotMachine(state.scenario, embedder)
state.persona = PersonaState(state.scenario.persona)
state.caller = build_caller(scenario.id)
state.caller = build_caller(
scenario.id,
use_pregenerated=scenario.tree.pregenerated,
)
elif event.exercise is Exercise.DDS:
state.dds_scenarios = [item.model_copy(deep=True) for item in scenarios]
prepare_card(state, state.dds_scenarios[0])
prepare_queue(
state,
state.dds_scenarios,
arrival_interval_seconds=event.dds_arrival_interval_seconds,
max_waiting=event.dds_max_waiting,
)
state.started_at = state.dispatched_at
else:
state.started_at = now_utc()
@ -155,13 +176,19 @@ async def _start(session_id: UUID, event, who=None) -> None:
if models is not None:
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
state.on_event("call.incoming")
await hub.checkpoint(session_id)
hub.start_ticker(session_id)
if who is not None:
# Запуск занятия меняет чужой результат — значит попадает в журнал
# аудита (ТЗ, хранение не менее шести месяцев).
asyncio.create_task(
audit(who.login, who.role.value, "lesson.start", str(session_id),
f"{scenario.id}, режим {event.mode.value}, курсант {event.trainee}")
# Сохраняем до продолжения сценария, чтобы завершение процесса не
# потеряло событие. ФИО курсанта в долгоживущий журнал не дублируем.
await audit(
who.login,
who.role.value,
"lesson.start",
str(session_id),
f"{scenario.id}, режим {event.mode.value}",
)
if event.exercise is Exercise.CALL:
@ -191,18 +218,22 @@ async def _stop(session_id: UUID) -> None:
return
state.ended_at = now_utc()
state.end_reason = CallEndReason.INSTRUCTOR
if state.voice is not None:
await state.voice.close()
hub.stop_ticker(session_id)
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
if state.exercise in {Exercise.DDS, Exercise.CARD}:
from app.session.finish import finish
from app.session.finish import finish
if state.exercise in {Exercise.DDS, Exercise.CARD}:
if state.exercise is Exercise.DDS or state.handoff_to_dds and state.dispatched_card is not None:
hub.to_station(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
else:
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR))
await finish(session_id, state)
if state.exercise is Exercise.DDS or state.handoff_to_dds and state.dispatched_card is not None:
hub.to_station(session_id, ScoreReady(session_id=session_id))
else:
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR))
await finish(session_id, state)
if hub.journal:
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value)
@ -236,6 +267,21 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
)
continue
existing_state = hub.get(session_id)
if event.type == "scenario.start":
if existing_state is not None:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
message="Для нового занятия создайте новый номер сессии",
))
continue
elif existing_state is None or existing_state.owner_login != who.login:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.FORBIDDEN,
message="Это занятие принадлежит другому преподавателю",
))
continue
match event.type:
case "scenario.start":
await _start(session_id, event, who)
@ -315,7 +361,7 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
if result.needs_network:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.DIRECTIVE_NEEDS_NETWORK,
message="Свободный текст требует LLM: офлайн доступны только кнопки",
message="Свободный текст требует локально запущенной модели; без неё доступны кнопки",
))
continue
state.directives.append(event.directive)
@ -338,5 +384,6 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
message=f"{event.type} ещё не реализовано",
),
)
await hub.checkpoint(session_id)
except WebSocketDisconnect:
return

View file

@ -10,23 +10,41 @@
import asyncio
import logging
import re
from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.domain.events import (
CallEndReason, ErrorEvent, ErrorKind, Exercise, PhoneReport, ScoreReady,
SessionEnded, StationState, StationToServer,
)
from app.domain.statuses import PRIMARY, PhoneReportRecord, ServiceStatus, StatusError, current
from app.api.auth import principal_of
from app.domain.events import (
CallEndReason,
ErrorEvent,
ErrorKind,
Exercise,
PhoneLine,
PhoneReport,
ScoreReady,
SessionEnded,
StationState,
StationToServer,
)
from app.domain.roles import Role
from app.domain.statuses import (
PRIMARY,
SERVICE_STATUS_LABELS,
PhoneCallPending,
PhoneLineRecord,
PhoneReportRecord,
ServiceStatus,
StatusError,
current,
)
from app.scoring.grammar import assess
from app.session.dds import deliver_due_cards
from app.session.finish import finish, score_current_dds
from app.session.hub import hub
from app.session.state import now_utc
from app.session.finish import finish
from app.session.finish import score_current_dds
from app.session.dds import prepare_card
log = logging.getLogger(__name__)
router = APIRouter()
@ -34,24 +52,85 @@ router = APIRouter()
_adapter = TypeAdapter(StationToServer)
REPORT_PHASES = ("dispatched", "arrived", "working", "completed")
REPORT_TEXT = {
"dispatched": "Бригада выехала к месту происшествия.",
"arrived": "Бригада прибыла на место происшествия.",
"working": "Бригада приступила к проведению работ.",
"completed": "Работы завершены, бригада освобождена.",
REQUIRED_STATUS = {
"dispatched": ServiceStatus.ACCEPTED,
"arrived": ServiceStatus.RESPONDING,
"working": ServiceStatus.ARRIVED,
"completed": ServiceStatus.WORKING,
}
REPORT_FOR_STATUS = {
ServiceStatus.RESPONDING: "dispatched",
ServiceStatus.ARRIVED: "arrived",
ServiceStatus.WORKING: "working",
ServiceStatus.COMPLETED: "completed",
STATUS_AT_OR_AFTER = {
"dispatched": {ServiceStatus.ACCEPTED, ServiceStatus.RESPONDING,
ServiceStatus.ARRIVED, ServiceStatus.WORKING},
"arrived": {ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING},
"working": {ServiceStatus.ARRIVED, ServiceStatus.WORKING},
"completed": {ServiceStatus.WORKING, ServiceStatus.COMPLETED},
}
def _error(session_id: UUID, message: str) -> None:
hub.to_station(session_id, ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=message))
def _line(session_id: UUID, state, speaker: str, text: str) -> None:
call = state.phone_pending
if call is None:
return
line = PhoneLineRecord(service=call.service, crew=call.crew,
speaker=speaker, text=text, at=now_utc())
state.phone_lines.append(line)
state.dds_log.append((f"phone.line.{speaker}", line.at, f"{call.crew}: {text}"))
hub.to_station(session_id, PhoneLine(**line.model_dump()))
def _address_matches(expected: str | None, supplied: str) -> bool:
"""Не даём сообщить бригаде другой номер дома/другую улицу."""
if not expected:
return bool(supplied.strip())
numbers = re.findall(r"\d+", expected)
spoken_numbers = re.findall(r"\d+", supplied)
words = re.findall(r"[а-яё]{4,}", expected.casefold())
spoken_words = re.findall(r"[а-яё]{4,}", supplied.casefold())
return (all(number in spoken_numbers for number in numbers)
and any(word[:4] == spoken[:4] for word in words for spoken in spoken_words))
def _incident_matches(state, supplied: str) -> bool:
text = supplied.casefold()
# Описание часто начинается с адреса: его нельзя считать совпадением
# характера происшествия. Берём только название сценария и признаки ЕКП.
source = " ".join((state.scenario_title, " ".join(state.kio.signs)))
anchors = {word[:4] for word in re.findall(r"[а-яё]{5,}", source.casefold())}
return len(supplied.strip()) >= 8 and any(anchor in text for anchor in anchors)
def _has_purpose(text: str, stems: tuple[str, ...]) -> bool:
normalized = text.casefold()
return len(text.strip()) >= 8 and any(stem in normalized for stem in stems)
def _report_text(phase: str, crew: str, address: str) -> str:
match phase:
case "dispatched":
return f"{crew}: вызов по адресу {address} принят, выезжаем. О прибытии доложу."
case "arrived":
return f"{crew}: прибыли по адресу {address}. Уточняем обстановку на месте."
case "working":
return f"{crew}: обстановка уточнена, приступили к работам. Сообщим о завершении."
case _:
return f"{crew}: работы завершены. Дальнейшая помощь от нашей бригады не требуется."
def _finish_phone_call(session_id: UUID, state) -> None:
call = state.phone_pending
assert call is not None
text = _report_text(call.phase, call.crew, state.dispatched_card.address or "из карточки")
_line(session_id, state, "crew", text)
report = PhoneReportRecord(service=call.service, crew=call.crew,
phase=call.phase, text=text, at=now_utc())
state.phone_reports.append(report)
state.dds_log.append(("phone.report", now_utc(), f"{call.crew}: {call.phase}"))
state.phone_pending = None
hub.to_station(session_id, PhoneReport(**report.model_dump()))
async def _finish_dds(session_id: UUID, state) -> None:
state.ended_at = now_utc()
state.end_reason = CallEndReason.COMPLETE
@ -75,22 +154,15 @@ async def _handle(session_id: UUID, state, event) -> None:
# диспетчер один и выбирать службу не из чего.
state.on_event("card.ack")
state.dds_log.append(("card.ack", now_utc(), None))
services = state.notified_services()
services = state.managed_services()
if services:
try:
state.set_service_status(services[0], ServiceStatus.ACCEPTED)
except StatusError:
pass # статус уже стоит: повторное нажатие ничего не меняет
case "card.status":
if event.service not in state.notified_services():
_error(session_id, "Служба отсутствует в списке оповещения карточки")
return
phase = REPORT_FOR_STATUS.get(event.status)
if (state.exercise is Exercise.DDS or state.handoff_to_dds) and phase is not None and not any(
report.service == event.service and report.phase == phase
for report in state.phone_reports
):
_error(session_id, f"Статус «{event.status.value}» требует доклада бригады")
if event.service not in state.managed_services():
_error(session_id, "Можно менять статусы только своей ДДС")
return
try:
state.set_service_status(
@ -116,33 +188,73 @@ async def _handle(session_id: UUID, state, event) -> None:
):
_error(session_id, "После первого доклада бригаду этой службы менять нельзя")
return
if state.phone_pending is not None:
_error(session_id, "Завершите текущий разговор перед сменой бригады")
return
state.crew_selected = event.crew
state.crew_assignments[service] = event.crew
state.dds_log.append(("crew.select", now_utc(), event.crew))
case "phone.dial":
if state.phone_pending is not None:
_error(session_id, "Разговор уже идёт: передайте сведения или завершите звонок")
return
crew = state.crew_selected
service = state.crew_service(crew) if crew else None
if service is None:
_error(session_id, "Сначала выберите бригаду")
return
if current(state.status_log, service) not in {
ServiceStatus.ACCEPTED, ServiceStatus.RESPONDING,
ServiceStatus.ARRIVED, ServiceStatus.WORKING,
}:
_error(session_id, "Сначала примите карточку этой службы")
return
previous = [report for report in state.phone_reports if report.service == service]
if len(previous) >= len(REPORT_PHASES):
_error(session_id, "Все доклады этой бригады уже получены")
return
phase = REPORT_PHASES[len(previous)]
report = PhoneReportRecord(
service=service, crew=crew, phase=phase,
text=REPORT_TEXT[phase], at=now_utc(),
)
state.phone_reports.append(report)
required = REQUIRED_STATUS[phase]
if current(state.status_log, service) not in STATUS_AT_OR_AFTER[phase]:
_error(session_id, f"Перед звонком нужен статус «{SERVICE_STATUS_LABELS[required]}» этой службы")
return
state.phone_pending = PhoneCallPending(service=service, crew=crew, phase=phase)
state.dds_log.append(("phone.dial", now_utc(), crew))
hub.to_station(session_id, PhoneReport(**report.model_dump()))
greeting = (f"{crew}, старший группы на связи. Назовите адрес, характер происшествия "
"и что требуется от бригады."
if phase == "dispatched" else
f"{crew}, старший группы на связи. Слушаю ваш запрос по карточке.")
_line(session_id, state, "crew", greeting)
case "phone.brief":
call = state.phone_pending
if call is None or call.phase != "dispatched":
_error(session_id, "Сначала соединитесь со старшим группы для передачи вызова")
return
if not _address_matches(state.dispatched_card.address, event.address):
_error(session_id, "Проверьте адрес: улица и номер дома должны совпадать с карточкой")
return
if not _incident_matches(state, event.incident):
_error(session_id, "Уточните характер происшествия по данным карточки")
return
if not _has_purpose(event.request, ("выезд", "выех", "направ", "прибыт",
"реагир", "помощ", "подтверд", "долож")):
_error(session_id, "Сформулируйте задачу: выезд, помощь или доклад бригады")
return
_line(session_id, state, "dispatcher", f"Адрес: {event.address.strip()}. "
f"Происшествие: {event.incident.strip()}. {event.request.strip()}")
_finish_phone_call(session_id, state)
case "phone.check":
call = state.phone_pending
if call is None or call.phase == "dispatched":
_error(session_id, "Сначала передайте вызов, затем запросите обстановку")
return
if not _has_purpose(event.text, ("обстанов", "статус", "прибыл", "доех",
"выех", "работ", "заверш", "мест",
"ход", "сообщ", "долож", "уточн")):
_error(session_id, "Спросите обстановку, прибытие или ход работ по карточке")
return
_line(session_id, state, "dispatcher", event.text.strip())
_finish_phone_call(session_id, state)
case "phone.hangup":
if state.phone_pending is None:
_error(session_id, "Нет активного разговора")
return
state.dds_log.append(("phone.hangup", now_utc(), state.phone_pending.crew))
state.phone_pending = None
case "card.reply":
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.dispatched_card or (
event.card_id != state.dispatched_card.card_id
@ -150,7 +262,18 @@ async def _handle(session_id: UUID, state, event) -> None:
_error(session_id, "Ответ относится не к текущей карточке")
return
state.reply_text = event.text
state.reply_grammar = await assess(event.text)
state.reply_log.append((now_utc(), event.text))
case "card.open":
if state.exercise is not Exercise.DDS or not state.activate_dds_card(event.card_id):
_error(session_id, "Карточка отсутствует в текущей очереди")
return
hub.to_station(session_id, state.card_received_event())
# CardReceived carries the contents, while StationState carries
# the status journal and current queue. Send both on every switch
# so the newly opened card cannot briefly inherit the previous
# card's status snapshot until the next periodic tick.
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
case "card.next":
if state.exercise is not Exercise.DDS or not state.dispatched_card or (
event.card_id != state.dispatched_card.card_id
@ -161,11 +284,32 @@ async def _handle(session_id: UUID, state, event) -> None:
_error(session_id, "Эта карточка уже завершена")
return
state.dds_completed.append(score_current_dds(state))
if state.dds_card_index + 1 < len(state.dds_scenarios):
state.dds_card_index += 1
prepare_card(state, state.dds_scenarios[state.dds_card_index])
completed_index = state.dds_card_index
state.dds_live_cards = [
item for item in state.dds_live_cards if item.card_id != event.card_id
]
remaining = sorted(state.dds_live_cards, key=lambda item: item.original_index)
if remaining:
following = next(
(item for item in remaining if item.original_index > completed_index),
remaining[0],
)
state.activate_dds_card(following.card_id, capture=False)
hub.to_station(session_id, state.card_received_event())
else:
# Keep the lesson alive if selected cards have not arrived yet.
# The next delivery may become the active card immediately or
# after its configured interval; no completed card is reused.
state.dds_active_card_id = None
state.dispatched_card = None
state.dispatched_at = None
state.dds_card_index = completed_index
active_before_delivery = state.dds_active_card_id
deliver_due_cards(state)
if state.dds_active_card_id and state.dds_active_card_id != active_before_delivery:
hub.to_station(session_id, state.card_received_event())
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
if not state.dds_live_cards and len(state.dds_completed) >= len(state.dds_scenarios):
await _finish_dds(session_id, state)
case "station.finish":
if state.exercise is not Exercise.DDS and not state.handoff_to_dds:
@ -173,9 +317,11 @@ async def _handle(session_id: UUID, state, event) -> None:
return
await _finish_dds(session_id, state)
case "card.bounce":
# Карточка вернулась: в разборе это E6 с конкретной причиной.
state.bounced_fields = list(event.missing_fields)
state.dds_log.append(("card.bounce", now_utc(), event.comment))
_error(
session_id,
"ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112",
)
return
case "zone.decision":
state.on_event("zone.decision")
state.dds_log.append(("zone.decision", now_utc(), "в зоне" if event.in_zone else "не в зоне"))
@ -186,6 +332,8 @@ async def _handle(session_id: UUID, state, event) -> None:
state.on_event("crew.arrived")
state.kio = state.kio.model_copy(update={"arrival_at": event.at})
state.dds_log.append(("crew.arrived", now_utc(), None))
state.capture_active_dds()
await hub.checkpoint(session_id)
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
hub.to_observers(session_id, state.snapshot())

View file

@ -19,36 +19,63 @@ class Settings(BaseSettings):
# Голосовой контур
models_dir: str = "models"
stt_model: str = "gigaam-v3-rnnt"
stt_model: str = "whisper-small"
# Локальный файл GGML Whisper small для whisper.cpp. Имя модели из сети
# не принимается: учебный контур не скачивает веса при запуске.
whisper_model_path: str = "models/whisper-small"
whisper_server_url: str = "http://127.0.0.1:18082"
endpointing_ms: int = 600
mic_sample_rate: int = 16_000
tts_sample_rate: int = 24_000
offline: bool = False
# Голосовой контур: грузит ~5 с моделей при старте. В тестах выключен.
voice_enabled: bool = True
# STT/голос не входят в MVP ДДС; включаются лишь для отдельного эксперимента.
voice_enabled: bool = False
# Запись хранится локально и отдаётся только через авторизованный API.
record_calls: bool = True
recordings_dir: str = "recordings"
# Автокопирование полного стенда. Интервал по умолчанию ровно сутки;
# небольшой grace нужен только для отображения просрочки в мониторинге.
backup_interval_seconds: int = 86_400
backup_retry_seconds: int = 300
backup_keep: int = 14
# LLM. Провайдер меняется значением, не кодом (docs/arch/STACK.md).
# Имена COMPAT_MODEL_* принимаются тоже — так их выставляет командный сниппет.
llm_provider: str = "openai_compatible"
llm_provider: str = "local"
llm_base_url: str = Field(
default="", validation_alias=AliasChoices("llm_base_url", "compat_model_url")
default="http://127.0.0.1:18080/v1",
validation_alias=AliasChoices("llm_base_url", "compat_model_url"),
)
llm_api_key: str = Field(
default="", validation_alias=AliasChoices("llm_api_key", "compat_model_api_key"),
repr=False,
)
llm_model_caller: str = Field(
default="", validation_alias=AliasChoices("llm_model_caller", "compat_model_name")
default="Qwen3-1.7B", validation_alias=AliasChoices("llm_model_caller", "compat_model_name")
)
llm_model_judge: str = Field(
default="", validation_alias=AliasChoices("llm_model_judge", "compat_model_name")
)
judge_temperature: float = 0.0
# Vikhr — отдельный русскоязычный контрольный профиль диалога. Никакая
# модель не решает, какие факты раскрыть, как маршрутизировать карточку или
# какой балл поставить: это делает протокол и оценка в коде.
llm_control_base_url: str = "http://127.0.0.1:18081/v1"
llm_model_control: str = "Vikhr-1B"
grammar_llm_enabled: bool = False
dialogue_model_mode: str = "dialogue" # dialogue | russian_control
# Docker Desktop даёт контейнеру специальное имя хоста. Оно разрешается
# только явным флагом: обычный OFFLINE по-прежнему принимает лишь literal
# loopback и не может незаметно уйти во внешний API.
allow_docker_host_models: bool = False
# Вход и роли (lct-23). `session_secret` обязателен на рабочем стенде:
# значение по умолчанию годится только для разработки, и администратор
# видит в состоянии сервисов, что оно не заменено.
session_secret: str = "dev-secret-поменять-на-стенде"
# В TLS-профиле cookie уходит только по HTTPS/WSS. Для нативной разработки
# на loopback флаг выключен, иначе браузер не сможет войти по HTTP.
secure_cookies: bool = False
#: Вход без пароля для `make lesson` и тестов. На стенде выключен.
dev_auth_bypass: bool = False

View file

@ -0,0 +1,63 @@
"""учебные материалы и назначения
Revision ID: a4c9d1e2f3b4
Revises: e6e2ab171dd7
Create Date: 2026-09-23
"""
from alembic import op
import sqlalchemy as sa
revision = "a4c9d1e2f3b4"
down_revision = "e6e2ab171dd7"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"learning_materials",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("level", sa.String(length=4), nullable=False),
sa.Column("kind", sa.String(length=16), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("scenario_id", sa.String(length=80), nullable=True),
sa.Column("file_name", sa.String(length=240), nullable=True),
sa.Column("media_type", sa.String(length=120), nullable=True),
sa.Column("file_data", sa.LargeBinary(), nullable=True),
sa.Column("file_sha256", sa.String(length=64), nullable=True),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("created_by", sa.String(length=80), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["scenario_id"], ["scenarios.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"material_assignments",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("material_id", sa.Uuid(), nullable=False),
sa.Column("trainee_id", sa.Uuid(), nullable=False),
sa.Column("assigned_by", sa.String(length=80), nullable=False),
sa.Column("assigned_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["material_id"], ["learning_materials.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["trainee_id"], ["trainees.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("material_id", "trainee_id", name="uq_material_assignment"),
)
op.create_index(
"ix_material_assignments_trainee",
"material_assignments",
["trainee_id", "assigned_at"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_material_assignments_trainee", table_name="material_assignments")
op.drop_table("material_assignments")
op.drop_table("learning_materials")

View file

@ -0,0 +1,37 @@
"""снимки активных занятий для восстановления
Revision ID: b5e0f2a3c4d5
Revises: a4c9d1e2f3b4
Create Date: 2026-09-23
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "b5e0f2a3c4d5"
down_revision = "a4c9d1e2f3b4"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("sessions", sa.Column("live_state", postgresql.JSONB(), nullable=True))
op.add_column(
"sessions",
sa.Column("checkpoint_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(
"ix_sessions_active_checkpoint",
"sessions",
["checkpoint_at"],
unique=False,
postgresql_where=sa.text("ended_at IS NULL AND live_state IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("ix_sessions_active_checkpoint", table_name="sessions")
op.drop_column("sessions", "checkpoint_at")
op.drop_column("sessions", "live_state")

View file

@ -0,0 +1,26 @@
"""сохраняемая версия полномочий пользователя
Revision ID: c6f1a3b4d5e6
Revises: b5e0f2a3c4d5
Create Date: 2026-09-23
"""
from alembic import op
import sqlalchemy as sa
revision = "c6f1a3b4d5e6"
down_revision = "b5e0f2a3c4d5"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("auth_version", sa.Integer(), server_default="0", nullable=False),
)
def downgrade() -> None:
op.drop_column("users", "auth_version")

View file

@ -0,0 +1,31 @@
"""ограничение авторского контента и занятий владельцем-преподавателем
Revision ID: d7e2f4a5b6c7
Revises: c6f1a3b4d5e6
Create Date: 2026-09-23
"""
from alembic import op
import sqlalchemy as sa
revision = "d7e2f4a5b6c7"
down_revision = "c6f1a3b4d5e6"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"scenarios",
sa.Column("owner_login", sa.String(length=80), nullable=True),
)
op.add_column(
"sessions",
sa.Column("owner_login", sa.String(length=80), nullable=True),
)
def downgrade() -> None:
op.drop_column("sessions", "owner_login")
op.drop_column("scenarios", "owner_login")

View file

@ -0,0 +1,25 @@
"""закрепить учебные группы за преподавателем
Revision ID: f7a3c9d1e2b4
Revises: d7e2f4a5b6c7
Create Date: 2026-09-23
"""
from alembic import op
import sqlalchemy as sa
revision = "f7a3c9d1e2b4"
down_revision = "d7e2f4a5b6c7"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Existing groups are not auto-assigned: historical membership/analytics may
# span instructors, so they remain admin-managed until explicitly migrated.
op.add_column("groups", sa.Column("owner_login", sa.String(length=80), nullable=True))
def downgrade() -> None:
op.drop_column("groups", "owner_login")

View file

@ -8,7 +8,7 @@
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -26,6 +26,8 @@ class Group(Base):
id: Mapped[UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(String(120), unique=True)
# NULL — legacy/admin-managed group; instructor-created groups are private.
owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
trainees: Mapped[list["Trainee"]] = relationship(back_populates="group")
@ -43,6 +45,52 @@ class Trainee(Base):
sessions: Mapped[list["Session"]] = relationship(back_populates="trainee")
class LearningMaterial(Base):
"""Локальный учебный материал или загруженный преподавателем ресурс."""
__tablename__ = "learning_materials"
id: Mapped[UUID] = _uuid_pk()
title: Mapped[str] = mapped_column(String(200))
description: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(4), default="L1")
kind: Mapped[str] = mapped_column(String(16), default="text")
body: Mapped[str] = mapped_column(Text, default="")
scenario_id: Mapped[str | None] = mapped_column(
ForeignKey("scenarios.id", ondelete="SET NULL"), nullable=True
)
file_name: Mapped[str | None] = mapped_column(String(240))
media_type: Mapped[str | None] = mapped_column(String(120))
file_data: Mapped[bytes | None] = mapped_column(LargeBinary)
file_sha256: Mapped[str | None] = mapped_column(String(64))
active: Mapped[bool] = mapped_column(default=True)
created_by: Mapped[str] = mapped_column(String(80))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class MaterialAssignment(Base):
"""Назначение материала курсанту и факт изучения."""
__tablename__ = "material_assignments"
id: Mapped[UUID] = _uuid_pk()
material_id: Mapped[UUID] = mapped_column(
ForeignKey("learning_materials.id", ondelete="CASCADE")
)
trainee_id: Mapped[UUID] = mapped_column(ForeignKey("trainees.id", ondelete="CASCADE"))
assigned_by: Mapped[str] = mapped_column(String(80))
assigned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (
UniqueConstraint("material_id", "trainee_id", name="uq_material_assignment"),
Index("ix_material_assignments_trainee", "trainee_id", "assigned_at"),
)
class Scenario(Base):
"""Сценарий целиком лежит в `body`: библиотека — контент, а не схема.
Отдельными колонками вынесено только то, по чему идёт выборка в списке."""
@ -56,6 +104,9 @@ class Scenario(Base):
topics: Mapped[list] = mapped_column(JSONB, default=list)
modes: Mapped[list] = mapped_column(JSONB, default=list)
status: Mapped[str] = mapped_column(String(16), default="published")
# У черновика и утверждённого авторского сценария ровно один владелец;
# NULL означает базовую/унаследованную системную библиотеку.
owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True)
body: Mapped[dict] = mapped_column(JSONB)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
@ -74,6 +125,7 @@ class Session(Base):
id: Mapped[UUID] = _uuid_pk()
scenario_id: Mapped[str] = mapped_column(ForeignKey("scenarios.id", ondelete="RESTRICT"))
owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True)
trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL"))
group_id: Mapped[UUID | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"))
mode: Mapped[str] = mapped_column(String(16))
@ -82,6 +134,10 @@ class Session(Base):
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
end_reason: Mapped[str | None] = mapped_column(String(16))
kio: Mapped[dict] = mapped_column(JSONB, default=dict)
# Последний подтверждённый снимок незавершённого занятия. Он очищается при
# завершении и позволяет новому backend-процессу продолжить карточку/ДДС.
live_state: Mapped[dict | None] = mapped_column(JSONB)
checkpoint_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
trainee: Mapped["Trainee | None"] = relationship(back_populates="sessions")
@ -214,6 +270,9 @@ class User(Base):
service: Mapped[str | None] = mapped_column(String(120))
trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL"))
blocked: Mapped[bool] = mapped_column(default=False)
# Версия полномочий попадает в подписанную cookie. Смена роли, пароля или
# блокировки увеличивает её и отзывает старые cookie даже после restart.
auth_version: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View file

@ -30,10 +30,12 @@ async def create_session(
trainee_id: UUID | None = None,
group_id: UUID | None = None,
session_id: UUID | None = None,
owner_login: str | None = None,
) -> Session:
session = Session(
scenario_id=scenario_id,
mode=mode,
owner_login=owner_login,
trainee_id=trainee_id,
group_id=group_id,
attempt=await next_attempt(db, trainee_id, scenario_id),
@ -54,6 +56,7 @@ async def ensure_session(
trainee_name: str | None = None,
trainee_id: UUID | None = None,
group_name: str | None = None,
owner_login: str | None = None,
) -> Session:
"""Занятие, запущенное с пульта, должно иметь строку в журнале.
@ -62,9 +65,11 @@ async def ensure_session(
"""
existing = await db.get(Session, session_id)
if existing is not None:
if existing.owner_login != owner_login:
raise PermissionError("занятие принадлежит другому преподавателю")
return existing
group = await ensure_group(db, group_name) if group_name else None
group = await ensure_group(db, group_name, owner_login=owner_login) if group_name else None
trainee = await db.get(Trainee, trainee_id) if trainee_id else None
if trainee_id and trainee is None:
raise ValueError(f"курсант {trainee_id} не найден")
@ -77,6 +82,7 @@ async def ensure_session(
trainee_id=trainee.id if trainee else None,
group_id=group.id if group else trainee.group_id if trainee else None,
session_id=session_id,
owner_login=owner_login,
)
@ -137,6 +143,7 @@ async def history(
trainee_id: UUID | None = None,
group_id: UUID | None = None,
mode: str | None = None,
owner_login: str | None = None,
since: datetime | None = None,
limit: int = 100,
) -> list[Session]:
@ -149,17 +156,23 @@ async def history(
query = query.where(Session.group_id == group_id)
if mode is not None:
query = query.where(Session.mode == mode)
if owner_login is not None:
query = query.where(Session.owner_login == owner_login)
if since is not None:
query = query.where(Session.created_at >= since)
return list(await db.scalars(query))
async def ensure_group(db: AsyncSession, name: str) -> Group:
async def ensure_group(
db: AsyncSession, name: str, *, owner_login: str | None = None
) -> Group:
group = await db.scalar(select(Group).where(Group.name == name))
if group is None:
group = Group(name=name)
group = Group(name=name, owner_login=owner_login)
db.add(group)
await db.commit()
elif group.owner_login != owner_login:
raise PermissionError("группа принадлежит другому преподавателю или администратору")
return group

View file

@ -9,6 +9,7 @@
"""
import logging
import re
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
@ -20,6 +21,11 @@ from app.domain.events import Mood
log = logging.getLogger(__name__)
NUMBER_WORDS = {
"один", "одна", "одно", "двое", "два", "две", "трое", "три", "четверо", "четыре",
"пять", "шесть", "семь", "восемь", "девять", "десять",
}
@dataclass
class CallerLine:
@ -152,6 +158,11 @@ class LlmCaller:
persona.on_repeat()
mood = persona.remember()
# Непонятная реплика не передаёт модели право импровизировать фактами.
# Отбор фактов и вопросная карта — только слот-автомат, не LLM.
if not (turn.revealed or turn.refined or turn.repeated):
return await self._fallback.reply(turn, persona, slots)
facts = {fact.id: fact.value for fact in slots.revealed_facts()}
say_now = [
facts[fact_id]
@ -161,7 +172,7 @@ class LlmCaller:
repeated = [facts[fact_id] for fact_id in turn.repeated if fact_id in facts]
system = _prompt("caller.md").format(
scenario=slots.scenario.title,
scenario="учебное происшествие",
mood=MOOD_WORDS.get(mood, mood.value),
directive=_directive_line(persona),
revealed="\n".join(f"- {value}" for value in facts.values()) or "- пока ничего",
@ -173,13 +184,15 @@ class LlmCaller:
repeated="; ".join(repeated), repeats=persona.repeats
)
self._history.append({"role": "user", "content": turn.text})
current_message = {"role": "user", "content": turn.text}
try:
text = await self._client.complete(
LlmRequest(
messages=[{"role": "system", "content": system}, *self._history[-6:]],
messages=[{"role": "system", "content": system},
*self._history[-6:], current_message],
model=self._model,
temperature=self._temperature,
max_tokens=160,
)
)
except LlmUnavailable as exc:
@ -187,7 +200,13 @@ class LlmCaller:
log.warning("звонящий на заготовках: %s", exc)
return await self._fallback.reply(turn, persona, slots)
self._history.append({"role": "assistant", "content": text})
if not _allowed_reply(text, facts, say_now + repeated, slots):
self.fallbacks += 1
log.warning("ответ модели нарушил протокол раскрытия фактов — использована заготовка")
return await self._fallback.reply(turn, persona, slots)
# Отклонённый ответ и провокационный вопрос не должны загрязнять
# последующий контекст. Запоминаем только проверенную пару ходов.
self._history.extend((current_message, {"role": "assistant", "content": text}))
return CallerLine(text=text, mood=mood)
async def aclose(self) -> None:
@ -196,6 +215,48 @@ class LlmCaller:
await self._client.aclose()
def _allowed_reply(text: str, allowed: dict[str, str], required_now: list[str], slots: SlotMachine) -> bool:
"""Консервативная граница для текста модели; протокол 112 всё равно в коде.
Невозможно доказать истинность произвольной русской фразы регулярками,
поэтому сомнительный ответ заменяется детерминированной репликой.
"""
if not text or len(text) > 300 or "\n" in text or "<think>" in text.lower():
return False
normalized = text.casefold().replace("ё", "е")
# Модель не вправе назвать числовой адрес или телефон, которого нет в
# раскрытых фактах, даже если оператор предположил его в своей реплике.
allowed_digits = set(re.findall(r"\d+", " ".join(allowed.values())))
if any(number not in allowed_digits for number in re.findall(r"\d+", normalized)):
return False
allowed_words = set(re.findall(r"[а-яё]+", " ".join(allowed.values()).casefold().replace("ё", "е")))
spoken_words = set(re.findall(r"[а-яё]+", normalized))
if (spoken_words & NUMBER_WORDS) - allowed_words:
return False
allowed_stems = {word[:4] for word in allowed_words if len(word) >= 4}
spoken_stems = {word[:4] for word in spoken_words if len(word) >= 4}
for fact in slots.scenario.facts:
if fact.id in allowed:
continue
for value in (fact.value, fact.refined):
if not value:
continue
if len(value) >= 7 and value.casefold().replace("ё", "е") in normalized:
return False
# Полная фраза — не единственный способ слить скрытый факт:
# «муж курил» раскрывает причину, даже без «на балконе».
hidden_stems = {word[:4] for word in re.findall(
r"[а-яё]{4,}", value.casefold().replace("ё", "е")
)} - allowed_stems
if hidden_stems & spoken_stems:
return False
# Новый обязательный факт нельзя опустить ради красивой реплики.
for value in required_now:
if value.casefold().replace("ё", "е") not in normalized:
return False
return True
MOOD_WORDS = {
Mood.PANIC: "паника, ты кричишь",
Mood.AGGRESSIVE: "злость, ты срываешься на оператора",
@ -210,6 +271,8 @@ def _directive_line(persona: PersonaState) -> str:
if persona.directive in SOFT:
return f"ПРЕПОДАВАТЕЛЬ ВЕДЁТ СИТУАЦИЮ: {SOFT[persona.directive].lower()}."
if persona.directive:
return f"ПРЕПОДАВАТЕЛЬ ПРОСИТ ИЗМЕНИТЬ ПОДАЧУ: {persona.directive}. Факты не меняй."
return ""

View file

@ -41,7 +41,7 @@ class DirectiveResult:
say: str | None = None
#: Оборвать связь: звук на полуслове, дальше обратный дозвон.
drop_line: bool = False
#: Директива требует сети (свободный текст без LLM).
#: Директива требует локально работающую модель (без неё — только кнопки).
needs_network: bool = False
@ -72,14 +72,15 @@ def apply(state, directive: str) -> DirectiveResult:
state.slots.invalidate(fact.id)
return DirectiveResult(applied=True, say=HARD_LINES[directive])
# Свободный текст уходит в контекст персоны — но подставить его в реплику
# может только LLM. Офлайн-дерево предгенерировано, произвольную фразу
# взять неоткуда (docs/arch/CONTRACT.md).
# Свободный текст разрешён лишь в режиме локальной модели. Он управляет
# интонацией, но не раскрывает факты и не меняет правила 112.
from app.dialog.caller import LlmCaller
if not isinstance(state.caller, LlmCaller) or len(directive) > 500:
return DirectiveResult(applied=False, needs_network=True)
if state.persona is not None:
state.persona.directive = None
if state.directives is not None:
state.directives.append(directive)
return DirectiveResult(applied=False, needs_network=True)
state.persona.directive = directive
return DirectiveResult(applied=True)
def mood_of(state) -> Mood:

View file

@ -1,4 +1,4 @@
"""Кто играет звонящего: LLM, если есть ключ, иначе заготовки.
"""Кто играет звонящего: локальная LLM, таблица или заготовки.
Провайдер и модель меняются значением в конфиге, а не кодом. Заготовки —
не запасной костыль, а рабочий режим: занятие идёт и без сети.
@ -9,30 +9,45 @@ import logging
from app.config import get_settings
from app.dialog.caller import Caller, LlmCaller, TemplateCaller
from app.dialog.llm import LlmClient
from app.dialog.llm import is_loopback_url
from app.dialog.tree import TreeCaller, has_table
from app.db.base import get_sessionmaker
log = logging.getLogger(__name__)
def build_caller(scenario_id: str | None = None, sessionmaker=None) -> Caller:
def build_caller(
scenario_id: str | None = None,
sessionmaker=None,
*,
use_pregenerated: bool = False,
) -> Caller:
settings = get_settings()
# Офлайн и «нет ключа» — это один и тот же путь: предгенерированная таблица,
# а не локальная модель. Формулировки в ней от облачной модели, а задержка
# нулевая (docs/arch/STACK.md).
if (settings.offline or not settings.llm_api_key) and scenario_id and has_table(scenario_id):
local = settings.llm_provider == "local"
mode = settings.dialogue_model_mode
if mode not in {"dialogue", "russian_control"}:
raise ValueError(f"неизвестный DIALOGUE_MODEL_MODE: {mode}")
model = settings.llm_model_control if mode == "russian_control" else settings.llm_model_caller
base_url = settings.llm_control_base_url if mode == "russian_control" else settings.llm_base_url
model_allowed = settings.llm_provider != "disabled" and bool(model and base_url and (
is_loopback_url(base_url, allow_docker_host=settings.allow_docker_host_models)
if settings.offline or local else settings.llm_api_key
))
# Локальный loopback не считается внешней сетью. Если сервер модели не
# отвечает, LlmCaller откатится на проверенные заготовки этой же реплики.
if model_allowed:
client = LlmClient(sessionmaker=sessionmaker or _safe_sessionmaker(), base_url=base_url)
log.info("звонящий: режим %s, модель %s, адрес %s", mode, model, base_url)
return LlmCaller(client, model=model)
if use_pregenerated and scenario_id and has_table(scenario_id):
log.info("звонящий по предгенерированной таблице сценария %s", scenario_id)
return TreeCaller(scenario_id)
if not settings.llm_api_key or settings.offline:
reason = "офлайн-режим" if settings.offline else "нет ключа LLM"
log.info("звонящий отвечает заготовками: %s (таблицы нет — make pregen)", reason)
return TemplateCaller()
client = LlmClient(sessionmaker=sessionmaker or _safe_sessionmaker())
log.info("звонящий на модели %s", settings.llm_model_caller)
return LlmCaller(client, model=settings.llm_model_caller)
log.info("звонящий отвечает фактами сценария: модель отключена или предгенерация не разрешена")
return TemplateCaller()
def _safe_sessionmaker():

View file

@ -1,4 +1,4 @@
"""Клиент облачной LLM за интерфейсом: провайдер меняется значением в конфиге.
"""Клиент совместимого API для локальных или внешних моделей.
Кэш ответов по хешу контекста лежит в Postgres, а не в Redis: база уже поднята,
лишняя движущаяся часть на стенде не нужна (docs/arch/STACK.md). Кэш работает
@ -9,7 +9,10 @@
import hashlib
import json
import logging
import re
from dataclasses import dataclass
from ipaddress import ip_address
from urllib.parse import urlsplit
import httpx
from sqlalchemy import select
@ -26,6 +29,48 @@ class LlmUnavailable(RuntimeError):
занятие продолжается — молчащий звонящий хуже шаблонной фразы."""
def is_loopback_url(value: str, *, allow_docker_host: bool = False) -> bool:
"""В офлайн-режиме модели разрешены лишь на той же машине.
Не доверяем доменам или hosts-записям: они могут указывать наружу.
"""
try:
url = urlsplit(value)
host = url.hostname or ""
try:
local_host = ip_address(host).is_loopback
except ValueError:
local_host = allow_docker_host and host == "host.docker.internal"
return (url.scheme == "http" and local_host and url.port is not None
and not url.username and not url.password)
except (ValueError, TypeError):
return False
def _spoken_content(raw: str, *, strip_reasoning: bool = False) -> str:
"""Убрать только пустой служебный хвост Qwen3, не рассуждения модели.
llama.cpp с выключенным thinking иногда возвращает в `content` один
закрывающий `</think>` перед самой репликой. Внутренний текст размышлений
мы намеренно не вырезаем: если он есть, ответ небезопасен и идёт fallback.
"""
text = raw.strip()
if strip_reasoning:
closing = list(re.finditer(r"</think>\s*", text, re.IGNORECASE))
if closing:
text = text[closing[-1].end():].strip()
elif text.startswith("<|"):
start = text.find("{")
if start >= 0:
text = text[start:].strip()
text = re.sub(r"^(?:</think>\s*)+", "", text, flags=re.IGNORECASE).strip()
if re.search(r"</?think\b", text, re.IGNORECASE) or "<|" in text:
raise LlmUnavailable("ответ содержит служебные токены модели")
if not text:
raise LlmUnavailable("пустой ответ модели: весь бюджет токенов ушёл в рассуждение")
return text
@dataclass
class LlmRequest:
messages: list[dict]
@ -34,10 +79,15 @@ class LlmRequest:
# С запасом на рассуждающие модели: Qwen3 тратит на размышление сотни токенов
# и при малом бюджете возвращает пустой ответ с finish_reason="length".
max_tokens: int = 400
response_format: dict | None = None
# Только для внутренних структурированных задач. В репликах звонящего
# рассуждение всегда отвергается, чтобы оно не попало в эфир.
strip_reasoning: bool = False
def cache_key(self) -> str:
payload = json.dumps(
{"m": self.model, "t": self.temperature, "msgs": self.messages},
{"m": self.model, "t": self.temperature, "msgs": self.messages,
"format": self.response_format, "strip_reasoning": self.strip_reasoning},
ensure_ascii=False,
sort_keys=True,
)
@ -50,36 +100,43 @@ class LlmClient:
*,
sessionmaker: async_sessionmaker | None = None,
transport: httpx.AsyncBaseTransport | None = None,
base_url: str | None = None,
# Ответ дольше этого бессмысленен: бюджет хода — 1.5 с, а звонящий
# с заготовками ответит сразу.
timeout: float = 8.0,
) -> None:
settings = get_settings()
self._base_url = settings.llm_base_url.rstrip("/")
self._base_url = (base_url or settings.llm_base_url).rstrip("/")
self._key = settings.llm_api_key
self._local_only = settings.offline or settings.llm_provider == "local"
self._allow_docker_host = settings.allow_docker_host_models
self._sessionmaker = sessionmaker
self._client = httpx.AsyncClient(timeout=timeout, transport=transport)
self._client = httpx.AsyncClient(timeout=timeout, transport=transport, trust_env=False)
@property
def configured(self) -> bool:
if self._local_only:
return is_loopback_url(
self._base_url, allow_docker_host=self._allow_docker_host
)
return bool(self._key and self._base_url)
async def complete(self, request: LlmRequest, *, use_cache: bool = True) -> str:
"""Ответ модели. Кэш по хешу контекста: та же реплика на том же месте
занятия звучит одинаково у каждой группы."""
if not self.configured:
raise LlmUnavailable("не задан ключ или адрес провайдера")
raise LlmUnavailable("локальный адрес модели недопустим или провайдер не настроен")
key = request.cache_key()
if use_cache:
cached = await self._from_cache(key)
if cached is not None:
return cached
return _spoken_content(cached, strip_reasoning=request.strip_reasoning)
try:
response = await self._client.post(
f"{self._base_url}/chat/completions",
headers={"Authorization": f"Bearer {self._key}"},
headers={"Authorization": f"Bearer {self._key}"} if self._key else {},
json={
"model": request.model,
"messages": request.messages,
@ -88,6 +145,8 @@ class LlmClient:
# Рассуждение в ответе не нужно: оно только раздувает трафик.
# Провайдеры, которые про это поле не знают, его игнорируют.
"reasoning": {"exclude": True},
**({"response_format": request.response_format}
if request.response_format is not None else {}),
},
)
except httpx.HTTPError as exc:
@ -97,13 +156,14 @@ class LlmClient:
# Тело ошибки в лог, ключ в заголовке — не логируется.
raise LlmUnavailable(f"HTTP {response.status_code}: {response.text[:200]}")
message = response.json()["choices"][0]["message"]
text = (message.get("content") or "").strip()
if not text:
# У рассуждающих моделей при нехватке бюджета весь ответ уходит
# в размышление, а content приходит пустым. Для занятия это отказ:
# звонящий откатится на заготовку, а не промолчит.
raise LlmUnavailable("пустой ответ модели: весь бюджет токенов ушёл в рассуждение")
try:
message = response.json()["choices"][0]["message"]
content = message.get("content")
if content is not None and not isinstance(content, str):
raise TypeError("content не строка")
text = _spoken_content(content or "", strip_reasoning=request.strip_reasoning)
except (ValueError, KeyError, IndexError, TypeError, AttributeError) as exc:
raise LlmUnavailable("некорректный ответ локальной модели") from exc
if use_cache and text:
await self._to_cache(key, request, text)
return text

View file

@ -19,3 +19,5 @@
4. Если состояние — паника или крик: обрывки, повторы, незаконченные фразы.
5. Не задавай оператору вопросов о ходе разговора и не подсказывай ему, что спросить.
6. Отвечай только репликой, без пояснений и без кавычек.
7. Каждый факт из раздела «ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ» произнеси
полностью и дословно. Одного «да», «нет» или намёка недостаточно.

View file

@ -15,14 +15,15 @@ from enum import StrEnum
from typing import Annotated, Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
from app.domain.classifiers import DDSCode, Level, Outcome
from app.domain.kio import KIO, Coords
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
class SessionMode(StrEnum):
@ -40,6 +41,30 @@ class Exercise(StrEnum):
CARD = "card"
class LessonCriteria(BaseModel):
"""Настраиваемые преподавателем условия именно этого занятия.
Нормативы ГОСТ для приёма вызова сюда не входят. Для занятия меняются
учебный лимит решения, порог успешности и веса метрик; веса сценария
остаются базовыми, а настройки занятия могут их переопределить.
"""
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
allowed_errors: int = Field(default=0, ge=0, le=50)
require_correct_grammar: bool = True
score_weights: dict[str, float] = Field(default_factory=dict)
@field_validator("score_weights")
@classmethod
def validate_score_weights(cls, weights: dict[str, float]) -> dict[str, float]:
unknown = weights.keys() - METRIC_MAP.keys()
if unknown:
raise ValueError(f"неизвестные метрики весов: {', '.join(sorted(unknown))}")
if any(not 0 <= weight <= 10 for weight in weights.values()):
raise ValueError("каждый вес метрики должен быть от 0 до 10")
return weights
class Speaker(StrEnum):
CALLER = "caller"
OPERATOR = "operator"
@ -346,6 +371,7 @@ class SessionSnapshot(BaseModel):
level: Level
mode: SessionMode
exercise: Exercise = Exercise.CALL
criteria: LessonCriteria = Field(default_factory=LessonCriteria)
trainee_name: str | None = None
started_at: datetime | None = None
kio: KIO
@ -420,12 +446,20 @@ class ScenarioStart(BaseModel):
type: Literal["scenario.start"] = "scenario.start"
scenario_id: str
scenario_ids: list[str] | None = None
# Pace defaults to simultaneous for older clients; the instructor UI
# explicitly sends its slower training default.
dds_arrival_interval_seconds: int = Field(default=0, ge=0, le=300)
dds_max_waiting: int = Field(default=3, ge=1, le=10)
trainee: str
trainee_id: UUID | None = None
# Служба обучающегося определяет, чьи статусы он ведёт. В рабочем режиме
# сервер заменяет это значение данными учётной записи; поле нужно demo без БД.
dds_service: str | None = None
group_id: str | None = None
mode: SessionMode
exercise: Exercise = Exercise.CALL
handoff_to_dds: bool = False
criteria: LessonCriteria = Field(default_factory=LessonCriteria)
class DirectorInject(BaseModel):
@ -527,6 +561,35 @@ class PhoneDial(BaseModel):
type: Literal["phone.dial"] = "phone.dial"
class PhoneBrief(BaseModel):
"""Что диспетчер передал старшему группы при первом исходящем звонке."""
type: Literal["phone.brief"] = "phone.brief"
address: str = Field(min_length=3, max_length=300)
incident: str = Field(min_length=3, max_length=500)
request: str = Field(default="", max_length=500)
class PhoneCheck(BaseModel):
"""Запрос диспетчера об обстановке после предыдущего доклада."""
type: Literal["phone.check"] = "phone.check"
text: str = Field(min_length=8, max_length=500)
class PhoneHangup(BaseModel):
type: Literal["phone.hangup"] = "phone.hangup"
class PhoneLine(BaseModel):
type: Literal["phone.line"] = "phone.line"
service: str
crew: str
speaker: Literal["dispatcher", "crew"]
text: str
at: datetime
class PhoneReport(BaseModel):
type: Literal["phone.report"] = "phone.report"
service: str
@ -551,6 +614,13 @@ class CardNext(BaseModel):
card_id: UUID
class CardOpen(BaseModel):
"""Открыть одну из уже выданных карточек, не меняя её таймер."""
type: Literal["card.open"] = "card.open"
card_id: UUID
class StationState(BaseModel):
"""Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше."""
@ -574,13 +644,14 @@ class CrewArrived(BaseModel):
ServerToStation = Annotated[
CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent,
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent,
Field(discriminator="type"),
]
StationToServer = Annotated[
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish
| CardReply | CardNext
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck
| PhoneHangup | StationFinish
| CardReply | CardNext | CardOpen
| ZoneDecision | CrewDispatched | CrewArrived,
Field(discriminator="type"),
]
@ -651,6 +722,9 @@ class SessionReport(BaseModel):
scenario_id: str
mode: SessionMode
attempt: int = 1
criteria: LessonCriteria
failed_metrics: int
passed: bool
transcript: list[TranscriptEntry]
findings: list[Finding]
metrics: list[Metric]

View file

@ -90,6 +90,7 @@ class KIO(BaseModel):
caller_number: str | None = None
caller_name: str | None = None
caller_contact: str | None = None
phone_on_scene: str | None = None
language: str = "ru"
# Место

View file

@ -23,7 +23,9 @@ ROLE_LABELS: dict[Role, str] = {
#: настройки, администратор не вмешивается в оценки, обучающийся не видит
#: чужих результатов.
SCREENS: dict[Role, tuple[str, ...]] = {
Role.ADMIN: ("/admin", "/profile"),
Role.INSTRUCTOR: ("/instructor", "/wall", "/profile", "/dds"),
Role.TRAINEE: ("/trainee", "/profile"),
Role.ADMIN: ("/admin", "/profile", "/groups", "/materials"),
Role.INSTRUCTOR: (
"/instructor", "/wall", "/profile", "/dds", "/phone", "/groups", "/materials",
),
Role.TRAINEE: ("/trainee", "/dds", "/phone", "/profile", "/materials"),
}

View file

@ -135,12 +135,46 @@ class PhoneReportRecord(BaseModel):
at: datetime
class PhoneLineRecord(BaseModel):
service: str
crew: str
speaker: str # dispatcher | crew
text: str
at: datetime
class PhoneCallPending(BaseModel):
service: str
crew: str
phase: str
class DdsCardSummary(BaseModel):
card_id: UUID
scenario_id: str
score_auto: float
class DdsQueueCard(BaseModel):
"""Строка общей очереди ДДС; таймер каждой карточки идёт независимо."""
card_id: UUID
scenario_id: str
title: str
address: str | None = None
description: str | None = None
incident_type: str | None = None
victims_count: int | None = None
received_at: datetime
managed_service: str | None = None
service_status: ServiceStatus
card_status: CardStatus
elapsed_ms: int
limit_ms: int
timer_stopped: bool
active: bool
class StatusError(ValueError):
"""Переход запрещён автоматом или нет обязательного комментария."""
@ -208,6 +242,8 @@ class StationSnapshot(BaseModel):
"""
services: list[str]
recipient_services: list[str] = []
managed_service: str | None = None
statuses: dict[str, ServiceStatus]
available: dict[str, list[ServiceStatus]]
card: CardStatus
@ -215,8 +251,14 @@ class StationSnapshot(BaseModel):
crew_options: list[str] = []
crew_selected: str | None = None
phone_reports: list[PhoneReportRecord] = []
phone_lines: list[PhoneLineRecord] = []
phone_pending: PhoneCallPending | None = None
card_id: UUID | None = None
card_index: int = 1
card_total: int = 1
reply_text: str = ""
completed_cards: list[DdsCardSummary] = []
queue_cards: list[DdsQueueCard] = []
pending_cards_count: int = 0
next_arrival_in_seconds: int | None = None
max_waiting_cards: int = 3

View file

@ -14,6 +14,7 @@ from app.api import auth
from app.api.http import admin as admin_api
from app.api.http import ekp as ekp_api
from app.api.http import groups as groups_api
from app.api.http import materials as materials_api
from app.api.http import scenarios as scenarios_api
from app.api.http import sessions
from app.api.http import trainees
@ -29,6 +30,7 @@ from app.scenarios import store
from app.session.hub import hub
from app.session.journal import DbJournal
from app.scenarios.loader import ScenarioError
from app.monitoring import install_diagnostics
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@ -38,6 +40,7 @@ LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
# а до лога доходят одни предупреждения. Чужие библиотеки — от WARNING.
logging.basicConfig(level=logging.WARNING, format="%(levelname)-8s %(name)s: %(message)s")
logging.getLogger("app").setLevel(logging.INFO)
install_diagnostics()
@asynccontextmanager
@ -52,6 +55,9 @@ async def lifespan(app: FastAPI):
except ScenarioError as exc:
raise RuntimeError(f"библиотека сценариев не прошла проверку: {exc}") from exc
app.state.scenarios_loaded = len(loaded)
if settings.demo_no_db:
store.reset_demo_drafts()
materials_api.reset_demo_materials()
# Утверждённые преподавателем сценарии хранятся в БД и должны переживать
# перезапуск процесса. При недоступной БД остаётся базовая YAML-библиотека.
@ -71,8 +77,34 @@ async def lifespan(app: FastAPI):
"DEMO_NO_DB: занятия и оценки живут только до перезапуска; журнал БД выключен"
)
if not settings.demo_no_db:
try:
async with asyncio.timeout(2):
await auth.load_generations()
except (SQLAlchemyError, OSError, TimeoutError) as exc:
logging.getLogger(__name__).warning(
"не удалось загрузить версии полномочий: %s", exc
)
# Журнал: всё, что не записано, для оценки не существует.
hub.journal = None if settings.demo_no_db else DbJournal(get_sessionmaker())
app.state.sessions_restored = 0
if hub.journal is not None:
try:
async with asyncio.timeout(3):
restored = await hub.journal.restore_active()
for state in restored:
hub.register(state)
hub.start_ticker(state.session_id)
app.state.sessions_restored = len(restored)
if restored:
logging.getLogger(__name__).info(
"восстановлено активных занятий: %d", len(restored)
)
except (SQLAlchemyError, OSError, TimeoutError) as exc:
logging.getLogger(__name__).warning(
"не удалось восстановить активные занятия: %s", exc
)
# Эмбеддинги для слот-автомата — грузятся один раз, до первого занятия.
app.state.embeddings_ready = not settings.demo_no_db and get_embedder() is not None
@ -81,6 +113,10 @@ async def lifespan(app: FastAPI):
app.state.models_ready = get_voice_models() is not None
yield
if hub.journal is not None:
for state in list(hub._sessions.values()):
if not state.ended:
await hub.journal.checkpoint(state)
await hub.shutdown()
for state in list(hub._sessions.values()):
if state.voice is not None:
@ -94,7 +130,7 @@ app.add_middleware(
SessionMiddleware,
secret_key=get_settings().session_secret,
session_cookie="lct_session",
https_only=False, # учебный класс живёт по http во внутренней сети
https_only=get_settings().secure_cookies,
max_age=12 * 60 * 60, # смена занятий, не месяц
)
app.include_router(auth.router)
@ -102,6 +138,7 @@ app.include_router(sessions.router)
app.include_router(scenarios_api.router)
app.include_router(ekp_api.router)
app.include_router(groups_api.router)
app.include_router(materials_api.router)
app.include_router(admin_api.router)
app.include_router(trainees.router)
app.include_router(call_ws.router)
@ -119,6 +156,7 @@ async def health() -> dict:
"models_ready": getattr(app.state, "models_ready", False),
"scenarios_loaded": getattr(app.state, "scenarios_loaded", 0),
"embeddings_ready": getattr(app.state, "embeddings_ready", False),
"sessions_restored": getattr(app.state, "sessions_restored", 0),
"offline": settings.offline,
"demo_no_db": settings.demo_no_db,
}

167
backend/app/monitoring.py Normal file
View file

@ -0,0 +1,167 @@
"""Small, dependency-free runtime diagnostics for the local stand.
The product has to run on Windows as well as Linux. Keep the collector in
the standard library: unavailable host counters are returned as ``None``
instead of making the administrator screen fail.
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import sys
import threading
import time
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
STARTED_MONOTONIC = time.monotonic()
_events: deque[dict] = deque(maxlen=500)
_installed = False
_sample_lock = threading.Lock()
_last_wall = time.monotonic()
_last_cpu = time.process_time()
_CREDENTIALS = re.compile(r"(://[^:/\s]+:)([^@/\s]+)(@)")
_TOKENS = re.compile(
r"(?i)\b(session_secret|llm_api_key|authorization|password)\b\s*[:=]\s*([^\s,;]+)"
)
def _safe(message: str) -> str:
message = _CREDENTIALS.sub(r"\1[скрыто]\3", message)
message = _TOKENS.sub(r"\1=[скрыто]", message)
return message.replace("\x00", "")[:2000]
class DiagnosticHandler(logging.Handler):
"""Bounded warning/error journal safe enough to expose to an admin."""
def emit(self, record: logging.LogRecord) -> None:
try:
_events.append({
"at": datetime.fromtimestamp(record.created, timezone.utc),
"level": record.levelname,
"source": record.name[:120],
"message": _safe(record.getMessage()),
})
except Exception: # logging must never take the service down
pass
def install_diagnostics() -> None:
global _installed
if _installed:
return
handler = DiagnosticHandler(level=logging.WARNING)
logging.getLogger().addHandler(handler)
_installed = True
def recent_events(*, limit: int = 100) -> list[dict]:
return list(_events)[-max(1, min(limit, 500)):][::-1]
def _rss_bytes() -> int | None:
try:
if sys.platform.startswith("linux"):
resident_pages = int(Path("/proc/self/statm").read_text().split()[1])
return resident_pages * int(os.sysconf("SC_PAGE_SIZE"))
if os.name == "nt":
import ctypes
from ctypes import wintypes
class Counters(ctypes.Structure):
_fields_ = [
("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD),
("PeakWorkingSetSize", ctypes.c_size_t),
("WorkingSetSize", ctypes.c_size_t),
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
("QuotaPagedPoolUsage", ctypes.c_size_t),
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
("PagefileUsage", ctypes.c_size_t),
("PeakPagefileUsage", ctypes.c_size_t),
]
counters = Counters()
counters.cb = ctypes.sizeof(counters)
ctypes.windll.psapi.GetProcessMemoryInfo( # type: ignore[attr-defined]
ctypes.windll.kernel32.GetCurrentProcess(), ctypes.byref(counters), counters.cb
)
return int(counters.WorkingSetSize)
import resource
value = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
return int(value if sys.platform == "darwin" else value * 1024)
except (OSError, ValueError, AttributeError, ImportError, IndexError):
return None
def _memory() -> tuple[int | None, int | None]:
try:
if sys.platform.startswith("linux"):
fields: dict[str, int] = {}
for line in Path("/proc/meminfo").read_text().splitlines():
name, value = line.split(":", 1)
fields[name] = int(value.strip().split()[0]) * 1024
return fields.get("MemTotal"), fields.get("MemAvailable")
if os.name == "nt":
import ctypes
class MemoryStatus(ctypes.Structure):
_fields_ = [
("length", ctypes.c_ulong), ("memory_load", ctypes.c_ulong),
("total_physical", ctypes.c_ulonglong),
("available_physical", ctypes.c_ulonglong),
("total_page_file", ctypes.c_ulonglong),
("available_page_file", ctypes.c_ulonglong),
("total_virtual", ctypes.c_ulonglong),
("available_virtual", ctypes.c_ulonglong),
("available_extended_virtual", ctypes.c_ulonglong),
]
status = MemoryStatus()
status.length = ctypes.sizeof(status)
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)) # type: ignore[attr-defined]
return int(status.total_physical), int(status.available_physical)
page = int(os.sysconf("SC_PAGE_SIZE"))
return int(os.sysconf("SC_PHYS_PAGES")) * page, None
except (OSError, ValueError, AttributeError, ImportError):
return None, None
def sample_metrics() -> dict:
"""Return a cheap point-in-time sample; the first CPU sample is zero."""
global _last_wall, _last_cpu
now = time.monotonic()
cpu_now = time.process_time()
with _sample_lock:
wall_delta = max(now - _last_wall, 0.001)
cpu_percent = max(0.0, (cpu_now - _last_cpu) / wall_delta * 100.0)
_last_wall, _last_cpu = now, cpu_now
cores = os.cpu_count() or 1
try:
load_1m_percent = os.getloadavg()[0] / cores * 100.0
except (AttributeError, OSError):
load_1m_percent = None
memory_total, memory_available = _memory()
disk = shutil.disk_usage(Path.cwd())
return {
"at": datetime.now(timezone.utc),
"uptime_seconds": max(0.0, now - STARTED_MONOTONIC),
"cpu_percent": round(cpu_percent, 2),
"load_1m_percent": round(load_1m_percent, 2) if load_1m_percent is not None else None,
"cpu_cores": cores,
"rss_bytes": _rss_bytes(),
"memory_total_bytes": memory_total,
"memory_available_bytes": memory_available,
"disk_total_bytes": disk.total,
"disk_free_bytes": disk.free,
"threads": threading.active_count(),
}

View file

@ -5,6 +5,7 @@
"""
from copy import deepcopy
import re
from pydantic import ValidationError
@ -12,6 +13,31 @@ from app.scenarios.loader import ScenarioError, _derive_ground_truth
from app.scenarios.schema import Scenario
DERIVED_TRUTH = {"incident_type", "dds", "incident_code", "notify", "required_facts"}
PLACEHOLDER = re.compile(r"\{\{?\s*[a-zA-Z_][\w.-]*\s*\}?\}")
def classifier_sign_gaps(signs: list[str], narrative: list[str]) -> list[str]:
"""Найти признаки ЕКП, которые не подтверждены текстом сценария.
Сравниваются трёхбуквенные основы, поэтому русские окончания
(``жилой дом`` / ``жилого дома``) не создают ложного расхождения. Это
намеренно строгая, детерминированная граница: модель не может заявить
один объект, а в карточку незаметно положить код другого.
"""
def stems(value: str) -> set[str]:
return {
word[:3]
for word in re.findall(r"[0-9a-zа-яё]+", value.casefold())
if len(word) >= 3
}
present = stems(" ".join(narrative))
gaps: list[str] = []
for sign in signs:
required = stems(sign)
if required and not required <= present:
gaps.append(sign)
return gaps
def template_copy(source: Scenario, draft_id: str, title: str | None = None) -> dict:
@ -92,9 +118,26 @@ def validate(body: dict) -> Scenario:
if item.fact and item.fact not in known:
raise ScenarioError(f"пункт {item.id} ссылается на факт {item.fact}, которого нет")
for fact in scenario.facts:
if PLACEHOLDER.search(fact.value) or (fact.refined and PLACEHOLDER.search(fact.refined)):
raise ScenarioError(
f"факт {fact.id}: остался плейсхолдер вместо конкретного ответа заявителя"
)
question_id = fact.reveal_on.question if fact.reveal_on else None
if question_id and question_id not in question_ids:
raise ScenarioError(f"факт {fact.id} раскрывается несуществующим вопросом {question_id}")
if fact.refine_on and fact.refine_on not in question_ids:
raise ScenarioError(f"факт {fact.id} уточняется несуществующим вопросом {fact.refine_on}")
# Для старых карточек и безопасных вариаций классификатор уже утверждён
# заказчиком. Новый свободный сюжет обязан доказать свой путь ЕКП текстом.
if scenario.id.startswith("ai-full-"):
gaps = classifier_sign_gaps(
scenario.signs,
[scenario.title, scenario.first_line]
+ [fact.value for fact in scenario.facts]
+ [fact.refined for fact in scenario.facts if fact.refined],
)
if gaps:
raise ScenarioError(
"signs: сюжет не подтверждает путь ЕКП: " + ", ".join(gaps)
)
return _derive_ground_truth(scenario)

View file

@ -0,0 +1,634 @@
"""Локальная ИИ-вариация утверждённого сценария для проверки преподавателем.
Модель пишет только сюжетные поля. Адрес, число пострадавших, классификацию,
список оповещения и протокол оценки она менять не может. Публикация — отдельное
действие преподавателя после просмотра черновика и эталона.
"""
import json
import re
from app.config import get_settings
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url
from app.scenarios.editor import classifier_sign_gaps, template_copy, validate
from app.scenarios.loader import ScenarioError
from app.scenarios.schema import Scenario
class GenerationError(ValueError):
pass
PROTECTED_IDS = ("address", "people", "victim", "floor", "location", "region")
PLACEHOLDER = re.compile(r"\{\{?\s*[a-zA-Z_][\w.-]*\s*\}?\}")
NUMBER_WORD = re.compile(
r"\b(?:ноль|один|одна|одно|одного|одну|два|две|двое|двух|три|трое|тр[её]х|"
r"четыре|четверо|четыр[её]х|пять|пятеро|пяти|шесть|шестеро|шести|семь|"
r"семеро|семи|восемь|восьмеро|восьми|девять|девятеро|девяти|десять|"
r"десятеро|десяти)\b",
re.IGNORECASE,
)
def reveals_number(value: str) -> bool:
return bool(re.search(r"\d", value) or NUMBER_WORD.search(value))
def editable_fact_ids(source: Scenario) -> list[str]:
return [fact.id for fact in source.facts
if not fact.hidden and not fact.refined and not fact.refine_on
and not any(part in fact.id.casefold() for part in PROTECTED_IDS)]
def correction_target(source: Scenario, instruction: str) -> str | None:
"""Указать маленькой модели конкретный факт для правки при повторе."""
eligible = [fact for fact in source.facts if fact.id in editable_fact_ids(source)]
if not eligible:
return None
def stems(text: str) -> set[str]:
return {word[:4] for word in re.findall(r"[а-яё]{3,}", text.casefold())}
words = stems(instruction)
questions = {item.fact: item.question or "" for item in source.checklist if item.fact}
def relevance(fact) -> int:
haystack = stems(f"{fact.value} {questions.get(fact.id, '')}")
return len(words & haystack)
_, best = max(enumerate(eligible), key=lambda pair: (relevance(pair[1]), pair[0]))
return best.id if relevance(best) > 0 else None
def style_fallback(source: Scenario, instruction: str) -> dict | None:
"""Безопасно сократить реплику, если 1.7B дважды вернула копию."""
if not re.search(r"\b(?:короч|кратк)", instruction.casefold()):
return None
chunks = re.findall(r"[^.!?…]+[.!?…]+|[^.!?…]+$", source.first_line)
useful: list[str] = []
for chunk in chunks:
value = chunk.strip()
letters = re.sub(r"[^а-яё]", "", value.casefold())
if not letters or letters in {"алло", "ааа", "аа", "а"}:
continue
useful.append(value)
if not useful:
return None
shortened = " ".join(useful[:2])[:300].strip()
if shortened == source.first_line.strip():
return None
return {"title": source.title, "first_line": shortened, "facts": {}}
def parse_proposal(
raw: str, source: Scenario, *, require_fact_change: bool = True,
instruction: str = "", restrict_first_line_facts: bool = False,
) -> dict:
"""Никакой произвольный JSON модели не попадает в схему сценария."""
text = raw.strip()
fenced = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
if fenced:
text = fenced.group(1)
try:
proposal = json.loads(text)
except (ValueError, TypeError) as exc:
raise GenerationError("модель не вернула корректный JSON") from exc
if not isinstance(proposal, dict) or set(proposal) != {"title", "first_line", "facts"}:
raise GenerationError("модель вернула поля вне разрешённого формата")
title, first_line, facts = (proposal[key] for key in ("title", "first_line", "facts"))
if not isinstance(title, str) or not 5 <= len(title.strip()) <= 200:
raise GenerationError("название варианта пустое или слишком длинное")
if not isinstance(first_line, str) or not 8 <= len(first_line.strip()) <= 300:
raise GenerationError("первая реплика пустая или слишком длинная")
if reveals_number(first_line):
raise GenerationError("первая реплика раскрывает числовые детали до опроса")
if instruction and restrict_first_line_facts:
stop = {"есть", "если", "меня", "очень", "этот", "только", "сейчас", "помогите"}
def stems(value: str) -> set[str]:
return {word[:3] for word in re.findall(r"[а-яё]{3,}", value.casefold())
if word not in stop}
fact_stems = set().union(*(stems(fact.value) for fact in source.facts))
allowed_stems = stems(f"{source.first_line} {instruction}")
leaked = (stems(first_line) & fact_stems) - allowed_stems
if leaked:
raise GenerationError(
"первая реплика добавляет не запрошенные обстоятельства карточки"
)
if not isinstance(facts, dict):
raise GenerationError("факты должны быть объектом")
allowed = set(editable_fact_ids(source))
if set(facts) - allowed:
raise GenerationError("модель попыталась изменить защищённые или неизвестные факты")
for fact_id, value in facts.items():
if not isinstance(value, str) or not 3 <= len(value.strip()) <= 300:
raise GenerationError(f"факт {fact_id}: значение пустое или слишком длинное")
if PLACEHOLDER.search(value):
raise GenerationError(f"факт {fact_id}: модель вернула плейсхолдер вместо ответа")
if "?" in value:
raise GenerationError(f"факт {fact_id}: модель вернула вопрос вместо ответа")
source_values = {fact.id: fact.value for fact in source.facts}
fact_changed = any(value.strip() != source_values[fact_id] for fact_id, value in facts.items())
story_changed = title.strip() != source.title or first_line.strip() != source.first_line
if require_fact_change and not fact_changed:
raise GenerationError("модель не предложила новых обстоятельств происшествия")
if not fact_changed and not story_changed:
raise GenerationError("модель не изменила черновик по комментарию")
return {"title": title.strip(), "first_line": first_line.strip(), "facts": facts}
def proposal_body(source: Scenario, draft_id: str, proposal: dict) -> dict:
body = template_copy(source, draft_id, proposal["title"])
by_id = proposal["facts"]
for fact in body["facts"]:
if fact["id"] in by_id:
fact["value"] = by_id[fact["id"]].strip()
body["first_line"] = proposal["first_line"]
# Схема, связи фактов и эталон проверяются до сохранения черновика.
validate(body)
return body
def parse_full_proposal(raw: str, source: Scenario) -> dict:
"""Проверить новый сюжет, где модель заполняет все ответы и мягкий эталон.
Тип, признаки ЕКП, маршрутизация и чек-лист остаются структурным каркасом,
но сами обстоятельства, адрес и число пострадавших создаются заново.
"""
text = raw.strip()
fenced = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
if fenced:
text = fenced.group(1)
try:
proposal = json.loads(text)
except (ValueError, TypeError) as exc:
raise GenerationError("модель не вернула корректный JSON нового сюжета") from exc
if not isinstance(proposal, dict) or set(proposal) != {
"title", "first_line", "facts", "ground_truth"
}:
raise GenerationError("новый сюжет содержит поля вне разрешённого формата")
title = proposal["title"]
first_line = proposal["first_line"]
facts = proposal["facts"]
truth = proposal["ground_truth"]
if not isinstance(title, str) or not 5 <= len(title.strip()) <= 200:
raise GenerationError("название нового сюжета пустое или слишком длинное")
if not isinstance(first_line, str) or not 8 <= len(first_line.strip()) <= 300:
raise GenerationError("первая реплика нового сюжета пустая или слишком длинная")
if reveals_number(first_line):
raise GenerationError("первая реплика раскрывает числовые детали до опроса")
expected = {fact.id for fact in source.facts}
if not isinstance(facts, dict) or set(facts) != expected:
raise GenerationError("новый сюжет должен заполнить каждый факт каркаса ровно один раз")
for fact_id, value in facts.items():
if not isinstance(value, str) or not 3 <= len(value.strip()) <= 300:
raise GenerationError(f"факт {fact_id}: значение пустое или слишком длинное")
if PLACEHOLDER.search(value):
raise GenerationError(f"факт {fact_id}: модель вернула плейсхолдер вместо ответа")
if "?" in value:
raise GenerationError(f"факт {fact_id}: модель вернула вопрос вместо ответа")
if not isinstance(truth, dict) or set(truth) != {"address", "victims"}:
raise GenerationError("ground_truth нового сюжета должен содержать address и victims")
address, victims = truth["address"], truth["victims"]
if not isinstance(address, str) or not 5 <= len(address.strip()) <= 200:
raise GenerationError("эталонный адрес пустой или слишком длинный")
if not isinstance(victims, int) or isinstance(victims, bool) or not 0 <= victims <= 20:
raise GenerationError("число пострадавших должно быть целым от 0 до 20")
address_id = next((fact.id for fact in source.facts if "address" in fact.id.casefold()), None)
if address_id is None:
raise GenerationError("в выбранном каркасе нет адресного факта")
def normalized(value: str) -> str:
return re.sub(r"\s+", " ", value.casefold().replace("ё", "е")).strip(" .,")
if not normalized(facts[address_id]).startswith(normalized(address)):
raise GenerationError("эталонный адрес не совпадает с началом адресного факта")
evidence = " ".join(facts.values()).casefold().replace("ё", "е")
count_words = {
0: ("ноль",), 1: ("один", "одна", "одного"),
2: ("два", "двое", "двух"), 3: ("три", "трое", "трех"),
4: ("четыре", "четверо", "четырех"), 5: ("пять", "пятеро", "пяти"),
6: ("шесть", "шестеро", "шести"), 7: ("семь", "семеро", "семи"),
8: ("восемь", "восьмеро", "восьми"), 9: ("девять", "девятеро", "девяти"),
10: ("десять", "десятеро", "десяти"),
}
numeric = re.search(rf"пострадавш\w*\s*[:—-]?\s*{victims}(?:\D|$)", evidence)
worded = any(re.search(
rf"(?:пострадавш\w*.{{0,25}}\b{word}\b|\b{word}\b.{{0,25}}пострада\w*)", evidence
) for word in count_words.get(victims, ()))
none_hurt = victims == 0 and re.search(
r"пострадавш(?:их|ие|их лиц)\s+нет|никто\s+не\s+пострадал|"
r"все\s+(?:люди\s+)?(?:успели\s+)?(?:выйти|эвакуироваться)", evidence
)
if not (numeric or worded or none_hurt):
raise GenerationError("в фактах нет явного подтверждения числа пострадавших")
old_values = {fact.id: fact.value.strip() for fact in source.facts}
changed = sum(value.strip() != old_values[fact_id] for fact_id, value in facts.items())
if changed < max(1, (len(facts) + 1) // 2):
raise GenerationError("новый сюжет слишком близко копирует исходный каркас")
if normalized(address) in normalized(first_line):
raise GenerationError("первая реплика раскрывает адрес до опроса")
return {
"title": title.strip(), "first_line": first_line.strip(),
"facts": {key: value.strip() for key, value in facts.items()},
"ground_truth": {"address": address.strip(), "victims": victims},
}
def full_proposal_body(source: Scenario, draft_id: str, proposal: dict) -> dict:
"""Собрать самостоятельный сценарий из проверенного нового сюжета."""
body = template_copy(source, draft_id, proposal["title"])
for fact in body["facts"]:
fact["value"] = proposal["facts"][fact["id"]]
# Старое уточнение относится к старому сюжету и не переносится.
fact["refined"] = None
fact["refine_on"] = None
body["first_line"] = proposal["first_line"]
body["ground_truth"] = dict(proposal["ground_truth"])
validate(body)
return body
async def generate_from_description(source: Scenario, description: str) -> dict:
"""Создать все обстоятельства и мягкий эталон внутри выбранной категории ЕКП.
Qwen 1.7B заметно надёжнее отвечает на один вопрос за раз. Поэтому вместо
одного длинного JSON, где малая модель копировала вопросы и старую карточку,
используются короткие строгие запросы. Итог всё равно проходит единую
сквозную проверку непротиворечивости.
"""
settings = get_settings()
if (not is_loopback_url(
settings.llm_base_url,
allow_docker_host=settings.allow_docker_host_models,
) or not settings.llm_model_caller):
raise LlmUnavailable("для генерации нужна разрешённая локальная модель")
questions = {item.fact: item.question for item in source.checklist if item.fact}
address_id = next((fact.id for fact in source.facts if "address" in fact.id.casefold()), None)
if address_id is None:
raise GenerationError("в выбранном каркасе нет адресного факта")
def victim_relevance(fact_id: str) -> int:
text = f"{fact_id} {questions.get(fact_id, '')}".casefold()
return sum(word in text for word in (
"people", "victim", "injur", "who", "люд", "пострад", "кто", "кому"
))
victim_id = max((fact.id for fact in source.facts), key=victim_relevance)
client = LlmClient(base_url=settings.llm_base_url, timeout=45)
def clean_answer(value: object) -> str:
"""Нормализовать пробелы и ограничить ответ без порчи `ул.`/`д.`."""
text = re.sub(r"\s+", " ", str(value)).strip()
for marker in (", но", ". В данном", ". Однако", ". Необходимо",
". Рекомендуется", ". Следует"):
text = text.split(marker, 1)[0]
return text[:300].strip()
def validate_value(data: dict, *, extra_key: str | None = None) -> None:
value = clean_answer(data.get("value", ""))
if not 3 <= len(value) <= 300:
raise GenerationError("значение пустое или слишком длинное")
if PLACEHOLDER.search(value):
raise GenerationError("модель вернула плейсхолдер вместо ответа")
if "?" in value:
raise GenerationError("модель вернула вопрос вместо ответа")
if extra_key is not None and extra_key not in data:
raise GenerationError(f"нет обязательного поля {extra_key}")
def validate_headline(data: dict) -> None:
title = clean_answer(data.get("title", ""))
first_line = clean_answer(data.get("first_line", ""))
if not 5 <= len(title) <= 200:
raise GenerationError("название пустое или слишком длинное")
if not 8 <= len(first_line) <= 300:
raise GenerationError("первая реплика пустая или слишком длинная")
if reveals_number(first_line):
raise GenerationError("первая реплика раскрывает числовые детали")
def validate_victims(data: dict) -> None:
validate_value(data, extra_key="victims")
victims = data["victims"]
if not isinstance(victims, int) or isinstance(victims, bool) or not 0 <= victims <= 20:
raise GenerationError("victims должен быть целым от 0 до 20")
def victim_answer(count: int) -> str:
"""Число модели сохраняем, формулировку делаем воспроизводимой и грамотной."""
if count == 0:
return "Никто не пострадал. Пострадавших: 0"
if count == 1:
return "Пострадал один человек. Пострадавших: 1"
noun = "человека" if count % 10 in (2, 3, 4) and count % 100 not in (12, 13, 14) else "человек"
return f"Пострадали {count} {noun}. Пострадавших: {count}"
async def ask(
system: str, payload: dict, schema: dict, *, max_tokens: int = 180,
validator=None,
) -> dict:
last_error: Exception | None = None
raw = ""
for attempt in range(3):
messages = [
{"role": "system", "content": system},
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
]
if attempt:
messages += [
{"role": "assistant", "content": raw},
{"role": "user", "content": (
f"Ответ отклонён: {last_error}. Верни исправленный JSON. "
"Нужен конкретный ответ, не повторяй вопрос."
)},
]
try:
raw = await client.complete(LlmRequest(
messages=messages, model=settings.llm_model_caller,
temperature=0.25, max_tokens=max_tokens,
response_format={"type": "json_object", "schema": schema},
strip_reasoning=True,
), use_cache=False)
data = json.loads(raw)
if not isinstance(data, dict) or set(data) != set(schema["required"]):
raise GenerationError("неверный набор полей")
if validator is not None:
validator(data)
return data
except (ValueError, TypeError, GenerationError, LlmUnavailable) as exc:
last_error = exc
if isinstance(exc, LlmUnavailable) and not any(marker in str(exc) for marker in (
"служебные токены", "пустой ответ модели"
)):
raise
raise GenerationError(f"локальная модель не заполнила часть сценария: {last_error}")
value_schema = {
"type": "object", "properties": {"value": {"type": "string"}},
"required": ["value"], "additionalProperties": False,
}
try:
# Заголовочный запрос не получает числовые детали даже словами: малая
# модель склонна повторять их в первой реплике, раскрывая ответ до
# обязательного вопроса оператора. Полное описание остаётся у запросов
# фактов ниже, поэтому эталон числа пострадавших не теряется.
headline_description = re.sub(r"\d+", "", description)
headline_description = re.sub(r"\s+", " ", NUMBER_WORD.sub("", headline_description)).strip()
headline = await ask(
"Создай название и первую эмоциональную реплику заявителя для учебного вызова 112. "
"Верни JSON title и first_line. first_line без цифр, адреса и служебных пояснений. "
"classifier_signs — обязательный путь классификатора ЕКП: сюжет не должен ему "
"противоречить. Если свободное description конфликтует с classifier_signs, "
"приоритет всегда у classifier_signs.",
{"incident_type": source.type.value, "classifier_signs": source.signs,
"level": source.level.value, "description": headline_description},
{"type": "object", "properties": {
"title": {"type": "string"}, "first_line": {"type": "string"},
}, "required": ["title", "first_line"], "additionalProperties": False},
max_tokens=220,
validator=validate_headline,
)
facts: dict[str, str] = {}
address = ""
victims: int | None = None
for fact in source.facts:
question = questions.get(fact.id, fact.id)
base_payload = {
"description": description, "incident_type": source.type.value,
"classifier_signs": source.signs,
"question": question, "already_known_answers": facts,
}
if fact.id == address_id:
data = await ask(
"Придумай конкретный вымышленный московский адрес для учебного сценария. "
"Верни JSON value и address. value должно начинаться дословно с address, "
"после него можно добавить квартиру, этаж или ориентир. Это ответ, не вопрос. "
"Адрес и тип объекта не должны противоречить classifier_signs.",
base_payload,
{"type": "object", "properties": {
"value": {"type": "string"}, "address": {"type": "string"},
}, "required": ["value", "address"], "additionalProperties": False},
validator=lambda data: validate_value(data, extra_key="address"),
)
facts[fact.id] = clean_answer(data["value"])
# Эталон выводится из факта, а не из второго пересказа модели:
# малая Qwen иногда меняет пунктуацию или номер между полями.
address = facts[fact.id][:200].rstrip(" ,.;")
elif fact.id == victim_id:
data = await ask(
"Ответь на вопрос заявителя конкретным фактом по описанию. Верни JSON value "
"и victims. victims — число пострадавших от 0 до 20. value обязательно "
"заканчивается точной фразой `Пострадавших: N`, где N равно victims. "
"Не пиши вопрос и не считай всех присутствующих пострадавшими. "
"Ответ не должен противоречить classifier_signs.",
base_payload,
{"type": "object", "properties": {
"value": {"type": "string"},
"victims": {"type": "integer", "minimum": 0, "maximum": 20},
}, "required": ["value", "victims"], "additionalProperties": False},
validator=validate_victims,
)
victims = data["victims"] if isinstance(data["victims"], int) else None
if victims is not None:
facts[fact.id] = victim_answer(victims)
else:
data = await ask(
"Ответь по-русски конкретным обстоятельством заявителя на question, используя "
"description, classifier_signs и already_known_answers. classifier_signs — "
"обязательный путь ЕКП и важнее конфликтующих деталей description. Верни JSON "
"только с ключом value. value — ответ, не вопрос; не используй знак вопроса "
"и плейсхолдеры.",
base_payload, value_schema, validator=validate_value,
)
facts[fact.id] = clean_answer(data["value"])
if victims is None:
raise GenerationError("модель не указала число пострадавших")
gaps = classifier_sign_gaps(
source.signs,
[str(headline["title"]), str(headline["first_line"]), *facts.values()],
)
if gaps:
target = next(
fact for fact in source.facts
if fact.id not in {address_id, victim_id}
)
def validate_alignment(data: dict) -> None:
validate_value(data)
candidate = dict(facts)
candidate[target.id] = clean_answer(data["value"])
missing = classifier_sign_gaps(
source.signs,
[str(headline["title"]), str(headline["first_line"]), *candidate.values()],
)
if missing:
raise GenerationError(
"ответ не подтверждает признаки ЕКП: " + ", ".join(missing)
)
aligned = await ask(
"Перепиши один ответ заявителя так, чтобы он оставался конкретным ответом на "
"question и дословно, осмысленно подтверждал все missing_classifier_signs. "
"Это обязательные признаки ЕКП, их нельзя заменять другим объектом или событием. "
"Верни JSON только с ключом value, без вопроса и служебных пояснений.",
{
"description": description,
"question": questions.get(target.id, target.id),
"current_answer": facts[target.id],
"missing_classifier_signs": gaps,
"all_classifier_signs": source.signs,
"already_known_answers": facts,
},
value_schema,
max_tokens=220,
validator=validate_alignment,
)
facts[target.id] = clean_answer(aligned["value"])
first_line = str(headline["first_line"]).strip()
if first_line and first_line[-1] not in ".!?…":
first_line += "!"
proposal = {
"title": headline["title"], "first_line": first_line,
"facts": facts, "ground_truth": {"address": address, "victims": victims},
}
proposal = parse_full_proposal(json.dumps(proposal, ensure_ascii=False), source)
full_proposal_body(source, "ai-full-validation", proposal)
return proposal
finally:
await client.aclose()
async def generate(
source: Scenario, instruction: str, *, require_fact_change: bool = True
) -> dict:
settings = get_settings()
if (not is_loopback_url(
settings.llm_base_url,
allow_docker_host=settings.allow_docker_host_models,
) or not settings.llm_model_caller):
raise LlmUnavailable("для генерации нужна разрешённая локальная модель")
all_editable = editable_fact_ids(source)
target = correction_target(source, instruction)
style_only = target is None and not require_fact_change
# Для 1.7B один комментарий = один изменяемый факт. Это не даёт модели
# «заодно» испортить газ, пока преподаватель просит сделать плотнее дым.
editable = [] if style_only else ([target] if target else all_editable)
fact_questions = {item.fact: item.question for item in source.checklist
if item.fact and item.question}
context = {
"incident_type": source.type.value,
"source_title": source.title,
"editable_facts": {
fact.id: {"question": fact_questions.get(fact.id, fact.id), "old_answer": fact.value}
for fact in source.facts if fact.id in editable
},
}
if not require_fact_change:
context["current_first_line"] = source.first_line
change_rule = (
"Измени обстоятельства хотя бы одного факта: дословная копия недопустима. "
if require_fact_change else
"Измени черновик по комментарию: допустимо менять только заголовок или first_line; "
"полностью прежняя версия недопустима. "
)
if style_only:
change_rule += (
"Это стилистическая правка: не добавляй в first_line новые обстоятельства, "
"которых нет в current_first_line или комментарии. facts оставь пустым. "
)
system = (
"Сочини НОВУЮ вариацию учебного вызова 112 на русском языке. "
"Верни ровно JSON с тремя ключами: title, first_line, facts. "
"facts содержит только id из editable_facts. " + change_rule +
"Значения facts — НОВЫЕ ответы "
"заявителя на question, не повторение вопроса. Не добавляй ключи. "
"first_line — прямая речь заявителя без цифр и адреса. "
"Тип происшествия, адрес и люди не меняются; их не включай в facts. "
"Не придумывай службы, коды и регламенты."
)
request = LlmRequest(
messages=[{"role": "system", "content": system},
{"role": "user", "content": json.dumps(
{"source": context, "request": instruction}, ensure_ascii=False)}],
model=settings.llm_model_caller, temperature=0.7, max_tokens=500,
response_format={"type": "json_object", "schema": {
"type": "object",
"properties": {
"title": {"type": "string"},
"first_line": {"type": "string"},
"facts": {"type": "object", "properties": {
fact_id: {"type": "string"} for fact_id in editable
}, "additionalProperties": False},
},
"required": ["title", "first_line", "facts"],
"additionalProperties": False,
}},
strip_reasoning=True,
)
client = LlmClient(base_url=settings.llm_base_url, timeout=45)
async def complete_safely(candidate: LlmRequest) -> str:
last_error: LlmUnavailable | None = None
for _ in range(2):
try:
return await client.complete(candidate, use_cache=False)
except LlmUnavailable as exc:
last_error = exc
if not any(marker in str(exc) for marker in (
"служебные токены", "пустой ответ модели"
)):
raise
assert last_error is not None
raise last_error
try:
raw = await complete_safely(request)
try:
proposal = parse_proposal(
raw, source, require_fact_change=require_fact_change, instruction=instruction,
restrict_first_line_facts=style_only,
)
except GenerationError as first_error:
feedback = (
f"Предыдущий ответ отклонён: {first_error}. "
"Верни исправленный JSON той же схемы. "
"Значения facts должны быть ответами заявителя, не вопросами. "
"Запрещены плейсхолдеры вида {fact_id}: пиши конкретный русский текст. "
)
if target:
old = next(fact.value for fact in source.facts if fact.id == target)
feedback += (
f"Обязательно замени факт {target}. Старый ответ: {old}. "
f"Новый ответ должен учитывать запрос: {instruction}. "
"Не повторяй старый ответ дословно."
)
retry = LlmRequest(
messages=[*request.messages, {"role": "assistant", "content": raw},
{"role": "user", "content": feedback}],
model=request.model, temperature=0.8, max_tokens=request.max_tokens,
response_format=request.response_format,
strip_reasoning=True,
)
raw = await complete_safely(retry)
try:
proposal = parse_proposal(
raw, source, require_fact_change=require_fact_change, instruction=instruction,
restrict_first_line_facts=style_only,
)
except GenerationError:
fallback = style_fallback(source, instruction) if not require_fact_change else None
if fallback is None:
raise
# Даже детерминированный fallback проходит те же запреты утечек.
proposal = parse_proposal(
json.dumps(fallback, ensure_ascii=False), source,
require_fact_change=False, instruction=instruction,
restrict_first_line_facts=style_only,
)
finally:
await client.aclose()
# Проверка на фиксированном ID гарантирует, что генерация не выдаст
# черновик, который потом невозможно утвердить из-за сломанной схемы.
try:
proposal_body(source, "ai-validation", proposal)
except ScenarioError as exc:
raise GenerationError(f"сгенерированный сценарий не прошёл проверку: {exc}") from exc
return proposal

View file

@ -16,6 +16,18 @@ from app.scenarios.schema import Scenario
from app.scenarios import editor
_library: dict[str, Scenario] = {}
_demo_drafts: dict[str, ScenarioRow] = {}
_demo_archived: dict[str, Scenario] = {}
_demo_scenario_owners: dict[str, str] = {}
_demo_archived_owners: dict[str, str] = {}
def reset_demo_drafts() -> None:
"""Черновики и архив demo-lite живут только в текущем процессе."""
_demo_drafts.clear()
_demo_archived.clear()
_demo_scenario_owners.clear()
_demo_archived_owners.clear()
def set_library(scenarios: list[Scenario]) -> None:
@ -56,6 +68,7 @@ async def seed(db: AsyncSession, scenarios: list[Scenario]) -> int:
row.topics = scenario.topics
row.modes = scenario.modes
row.body = payload
row.owner_login = None
await db.commit()
return len(scenarios)
@ -66,21 +79,128 @@ async def restore_published(db: AsyncSession) -> int:
Вызвать после ``load_from_disk``. Базовую библиотеку не перезаписываем:
её источником остаются проверенные YAML из репозитория.
"""
rows = await db.scalars(select(ScenarioRow).where(ScenarioRow.status == "published"))
restored = 0
rows = await db.scalars(select(ScenarioRow).where(
ScenarioRow.status.in_(["published", "archived"])
))
delta = 0
for row in rows:
if row.status == "archived":
if row.owner_login:
_demo_scenario_owners.pop(row.id, None)
_demo_archived_owners[row.id] = row.owner_login
if _library.pop(row.id, None) is not None:
delta -= 1
continue
if row.owner_login:
_demo_scenario_owners[row.id] = row.owner_login
if row.id in _library:
continue
_library[row.id] = Scenario.model_validate(row.body)
restored += 1
return restored
delta += 1
return delta
async def owned_scenario_ids(db: AsyncSession | None, owner_login: str) -> set[str]:
"""IDs the current instructor may edit/archive; base and legacy rows are read-only."""
if db is None:
return {
scenario_id for scenario_id, owner in _demo_scenario_owners.items()
if owner == owner_login and scenario_id in _library
}
rows = await db.scalars(select(ScenarioRow.id).where(
ScenarioRow.owner_login == owner_login,
ScenarioRow.status == "published",
))
values = set(rows)
# Test doubles and alternate SQLAlchemy result wrappers may return rows
# for a scalar selection; normalize them without leaking owner data.
return {value if isinstance(value, str) else value.id for value in values}
async def archive(
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
) -> Scenario | None:
"""Скрыть опубликованный сценарий без удаления истории и внешних ключей."""
scenario = _library.get(scenario_id)
if scenario is None:
return None
if db is None:
owner = _demo_scenario_owners.get(scenario_id)
if owner_login is not None and owner != owner_login:
return None
_demo_archived[scenario_id] = scenario
if owner is not None:
_demo_archived_owners[scenario_id] = owner
_demo_scenario_owners.pop(scenario_id, None)
else:
row = await db.get(ScenarioRow, scenario_id)
if owner_login is not None and (row is None or row.owner_login != owner_login):
return None
if row is None:
row = ScenarioRow(
id=scenario.id,
title=scenario.title,
incident_type=scenario.type.value,
level=scenario.level.value,
topics=scenario.topics,
modes=scenario.modes,
body=scenario.model_dump(mode="json"),
)
db.add(row)
row.status = "archived"
await db.commit()
_library.pop(scenario_id, None)
return scenario
async def restore_archived(
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
) -> Scenario | None:
"""Вернуть мягко удалённый сценарий в библиотеку назначения."""
if db is None:
owner = _demo_archived_owners.get(scenario_id)
if owner_login is not None and owner != owner_login:
return None
scenario = _demo_archived.pop(scenario_id, None)
if scenario is not None and owner is not None:
_demo_scenario_owners[scenario_id] = owner
_demo_archived_owners.pop(scenario_id, None)
else:
row = await db.get(ScenarioRow, scenario_id)
if (row is None or row.status != "archived"
or owner_login is not None and row.owner_login != owner_login):
return None
# В опубликованном body уже лежат вычисленные поля ground_truth (ЕКП,
# ДДС, список оповещения). Редакторская validate предназначена для
# черновика до вычисления и закономерно запрещает такие поля.
scenario = Scenario.model_validate(row.body)
row.status = "published"
await db.commit()
if scenario is None:
return None
publish(scenario)
return scenario
async def create_draft(
db: AsyncSession, *, source: Scenario, title: str | None = None
db: AsyncSession | None, *, source: Scenario, title: str | None = None,
proposal: dict | None = None, full_proposal: dict | None = None,
owner_login: str | None = None,
) -> ScenarioRow:
draft_id = f"draft-{uuid4().hex}"
body = editor.template_copy(source, draft_id, title)
if proposal is not None and full_proposal is not None:
raise ValueError("нельзя одновременно передать вариацию и полный сюжет")
prefix = "ai-full" if full_proposal is not None else "ai" if proposal is not None else "draft"
draft_id = f"{prefix}-{uuid4().hex}"
if full_proposal is not None:
from app.scenarios.generation import full_proposal_body
body = full_proposal_body(source, draft_id, full_proposal)
elif proposal is None:
body = editor.template_copy(source, draft_id, title)
else:
from app.scenarios.generation import proposal_body
body = proposal_body(source, draft_id, proposal)
row = ScenarioRow(
id=draft_id,
title=body["title"],
@ -89,26 +209,53 @@ async def create_draft(
topics=body["topics"],
modes=body["modes"],
status="draft",
owner_login=owner_login,
body=body,
)
db.add(row)
await db.commit()
if db is None:
_demo_drafts[row.id] = row
else:
db.add(row)
await db.commit()
return row
async def draft(db: AsyncSession, scenario_id: str) -> ScenarioRow | None:
row = await db.get(ScenarioRow, scenario_id)
return row if row is not None and row.status == "draft" else None
async def draft(
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
) -> ScenarioRow | None:
row = _demo_drafts.get(scenario_id) if db is None else await db.get(ScenarioRow, scenario_id)
if row is None or row.status != "draft":
return None
if owner_login is not None and row.owner_login != owner_login:
return None
return row
async def update_draft(db: AsyncSession, row: ScenarioRow, patch: dict) -> ScenarioRow:
async def update_draft(db: AsyncSession | None, row: ScenarioRow, patch: dict) -> ScenarioRow:
row.body = editor.merge_patch(row.body, patch)
row.title = str(row.body.get("title") or "")[:200]
await db.commit()
if db is not None:
await db.commit()
return row
async def approve_draft(db: AsyncSession, row: ScenarioRow) -> Scenario:
async def revise_draft(db: AsyncSession | None, row: ScenarioRow, proposal: dict) -> ScenarioRow:
"""Заменить сюжетную версию того же черновика после комментария преподавателя."""
from app.scenarios.generation import proposal_body
source = editor.validate(row.body)
row.body = proposal_body(source, row.id, proposal)
row.title = row.body["title"]
row.incident_type = row.body["type"]
row.level = row.body["level"]
row.topics = row.body["topics"]
row.modes = row.body["modes"]
if db is not None:
await db.commit()
return row
async def approve_draft(db: AsyncSession | None, row: ScenarioRow) -> Scenario:
scenario = editor.validate(row.body)
row.title = scenario.title
row.incident_type = scenario.type.value
@ -117,6 +264,11 @@ async def approve_draft(db: AsyncSession, row: ScenarioRow) -> Scenario:
row.modes = scenario.modes
row.body = scenario.model_dump(mode="json")
row.status = "published"
await db.commit()
if db is None:
_demo_drafts.pop(row.id, None)
if row.owner_login is not None:
_demo_scenario_owners[row.id] = row.owner_login
else:
await db.commit()
publish(scenario)
return scenario

View file

@ -130,16 +130,13 @@ def evaluate_dispatcher(
def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
Веса предварительные — до утверждения методистом. Неподтверждённые
телефонные действия нельзя засчитывать по одной кнопке статуса.
Веса предварительные — до утверждения методистом. Телефон — возможный
источник информации, но не обязательный шлюз статуса.
"""
metrics: list[Metric] = []
for service in state.notified_services():
for service in state.managed_services():
marks = [mark for mark in state.status_log if mark.service == service]
statuses = {mark.status for mark in marks}
reports = {report.phase for report in state.phone_reports if report.service == service}
selected_crew = state.crew_assignments.get(service)
selected = selected_crew is not None
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
metrics.append(Metric(
@ -153,7 +150,7 @@ def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
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", "первичная отметка за 30 с",
add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с",
elapsed is not None and elapsed <= deadline_ms,
f"{elapsed / 1000:.1f} с" if elapsed is not None else "не отмечено",
f"≤ {deadline_ms // 1000} с")
@ -164,22 +161,14 @@ def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
primary.status.value, "профильную заявку принять", 2.0)
if primary.status is ServiceStatus.DECLINED:
continue
add("dds_crew", "ручной выбор бригады", selected,
selected_crew or "бригада не выбрана", "выбрать конкретную бригаду", 1.5)
add("dds_contact", "связь с бригадой", bool(reports),
", ".join(sorted(reports)) or "докладов нет", "получить доклад о выезде", 1.5)
expected = {
"dispatched": ServiceStatus.RESPONDING,
"arrived": ServiceStatus.ARRIVED,
"working": ServiceStatus.WORKING,
ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING,
}
aligned = all(status in statuses for phase, status in expected.items() if phase in reports)
all_progress = all(phase in reports and status in statuses for phase, status in expected.items())
add("dds_progress", "статусы по докладам", aligned and all_progress,
add("dds_progress", "ведение хода реагирования", expected <= statuses,
", ".join(status.value for status in statuses) or "статусов нет",
"выезд, прибытие и работы отмечены после докладов", 2.0)
add("dds_completion", "закрытие работ по докладу",
"completed" in reports and ServiceStatus.COMPLETED in statuses,
"начало реагирования, прибытие и работы отмечены по полученной информации", 2.0)
add("dds_completion", "закрытие работ",
ServiceStatus.COMPLETED in statuses,
"завершено" if ServiceStatus.COMPLETED in statuses else "не завершено",
"доклад о завершении и статус «Работы завершены»")
"по факту поставить статус «Работы завершены»")
return metrics

View file

@ -37,13 +37,19 @@ def to_csv(report: SessionReport) -> bytes:
row("Занятие", "", "Сценарий", report.scenario_id)
row("Занятие", "", "Режим", report.mode.value)
row("Занятие", "", "Попытка", report.attempt)
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
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_final)
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
row("Оценка", "", "Причина изменения", report.override_comment)
for number, item in enumerate(report.metrics, 1):
row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; источник: {item.ref or ''}")
row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; вес: {item.weight:g}; источник: {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 ''}")
for number, item in enumerate(report.competencies, 1):
@ -138,6 +144,12 @@ def to_pdf(report: SessionReport) -> bytes:
("Сценарий", report.scenario_id),
("Режим", report.mode.value),
("Попытка", report.attempt),
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
("Допустимо ошибок", report.criteria.allowed_errors),
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
("Переопределения весов", report.criteria.score_weights or "стандартные"),
("Нарушенных метрик", report.failed_metrics),
("Результат", "зачёт" if report.passed else "не зачтено"),
("Автоматическая оценка", report.score_auto),
("Итоговая оценка", report.score_final),
):
@ -155,7 +167,7 @@ def to_pdf(report: SessionReport) -> bytes:
if not report.metrics:
story.append(p("Нет данных", muted))
for item in report.metrics:
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'}"))
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'} (вес {item.weight:g})"))
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm}. {item.ref or ''}", muted))
section("Выявленные ошибки")
@ -226,3 +238,74 @@ def to_pdf(report: SessionReport) -> bytes:
document.build(story, onFirstPage=footer, onLaterPages=footer)
return output.getvalue()
def certificate_pdf(
*, trainee_name: str, trainee_id: object, group_name: str | None,
attempts: int, average_score: float, issued_at: object,
) -> bytes:
"""Одностраничный сертификат по фактически сохранённым результатам."""
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.styles import ParagraphStyle
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
regular_path, bold_path = _font_paths()
regular_name = "LCTCertificateRegular"
bold_name = "LCTCertificateBold"
if regular_name not in pdfmetrics.getRegisteredFontNames():
regular = TTFont(regular_name, str(regular_path))
if not all(ord(char) in regular.face.charToGlyph for char in "Сертификат Ёж"):
raise RuntimeError(f"Шрифт не поддерживает кириллицу: {regular_path}")
pdfmetrics.registerFont(regular)
pdfmetrics.registerFont(TTFont(bold_name, str(bold_path or regular_path)))
title = ParagraphStyle(
"certificate-title", fontName=bold_name, fontSize=28, leading=34,
alignment=TA_CENTER, textColor=colors.HexColor("#193b56"), spaceAfter=20,
)
body = ParagraphStyle(
"certificate-body", fontName=regular_name, fontSize=14, leading=21,
alignment=TA_CENTER, textColor=colors.HexColor("#18202a"), spaceAfter=10,
)
name_style = ParagraphStyle(
"certificate-name", parent=body, fontName=bold_name, fontSize=22, leading=28,
textColor=colors.HexColor("#a24816"), spaceAfter=18,
)
small = ParagraphStyle(
"certificate-small", parent=body, fontSize=9, leading=13,
textColor=colors.HexColor("#5d6873"),
)
safe_name = escape(trainee_name)
safe_group = escape(group_name or "без группы")
safe_date = escape(str(issued_at))
story = [
Spacer(1, 34),
Paragraph("СЕРТИФИКАТ", title),
Paragraph("о прохождении учебных занятий в локальном тренажёре системы-112 и ДДС", body),
Spacer(1, 12),
Paragraph(safe_name, name_style),
Paragraph(
f"Выполнено занятий: <b>{attempts}</b> · средняя итоговая оценка: "
f"<b>{average_score:.1f} из 100</b>", body,
),
Paragraph(f"Учебная группа: {safe_group}", body),
Spacer(1, 28),
Paragraph(f"Дата формирования: {safe_date}", small),
Paragraph(f"Идентификатор курсанта: {escape(str(trainee_id))}", small),
Paragraph(
"Документ сформирован локально по сохранённым результатам и не заменяет "
"квалификационный документ установленного образца.", small,
),
]
output = io.BytesIO()
document = SimpleDocTemplate(
output, pagesize=landscape(A4), leftMargin=60, rightMargin=60,
topMargin=45, bottomMargin=45, title="Сертификат о прохождении обучения",
author="Учебный симулятор 112",
)
document.build(story)
return output.getvalue()

View file

@ -0,0 +1,102 @@
"""Проверка письменного ответа ДДС: локальные правила и необязательный Vikhr."""
import json
import re
from dataclasses import dataclass
from app.config import get_settings
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url
@dataclass(frozen=True)
class GrammarAssessment:
passed: bool
errors: tuple[str, ...]
source: str
def basic_check(text: str) -> GrammarAssessment:
"""Воспроизводимый минимум работает даже без модели."""
value = text.strip()
errors: list[str] = []
if not value:
errors.append("ответ отсутствует")
else:
first_letter = next((char for char in value if char.isalpha()), "")
if first_letter and first_letter != first_letter.upper():
errors.append("предложение начинается со строчной буквы")
if value[-1:] not in ".!?…":
errors.append("нет завершающего знака препинания")
if re.search(r"\s{2,}", value):
errors.append("повторяющиеся пробелы")
if re.search(r"[A-Za-z]", value):
errors.append("латинские буквы в русском ответе")
if re.search(r"([!?.,])\1{2,}", value):
errors.append("избыточно повторяющиеся знаки препинания")
agreement_errors = (
(r"\bсообщение\s+принят(?:ы|а)\b", "нарушено согласование: «сообщение принято»"),
(r"\bбригада\s+направлен(?:о|ы)\b", "нарушено согласование: «бригада направлена»"),
(r"\bинформация\s+передан(?:о|ы)\b", "нарушено согласование: «информация передана»"),
(r"\bработы\s+завершен(?:а|о)\b", "нарушено согласование: «работы завершены»"),
)
for pattern, message in agreement_errors:
if re.search(pattern, value, re.IGNORECASE):
errors.append(message)
return GrammarAssessment(not errors, tuple(errors), "rules")
async def assess(text: str) -> GrammarAssessment:
"""Vikhr проверяет мягкую грамматику, правила остаются страховочной сеткой."""
baseline = basic_check(text)
settings = get_settings()
if (not settings.grammar_llm_enabled or not text.strip()
or not is_loopback_url(
settings.llm_control_base_url,
allow_docker_host=settings.allow_docker_host_models,
)
or not settings.llm_model_control):
return baseline
schema = {"type": "json_object", "schema": {
"type": "object",
"properties": {
"passed": {"type": "boolean"},
"comment": {"type": "string"},
},
"required": ["passed", "comment"],
"additionalProperties": False,
}}
request = LlmRequest(
messages=[{
"role": "system",
"content": (
"Ты строгий корректор русского языка. Проверяй только грамматику, "
"орфографию и пунктуацию короткого служебного сообщения; не оценивай "
"факты и действия. Пример ошибки: «Сообщение приняты» => passed=false, "
"comment=«Нарушено согласование: сообщение принято». Пример нормы: "
"«Сообщение принято, бригада направлена.» => passed=true, comment=«». "
"Найди даже одну языковую ошибку и верни JSON по заданной схеме."
),
}, {"role": "user", "content": text}],
model=settings.llm_model_control,
temperature=0.0,
max_tokens=160,
response_format=schema,
)
client = LlmClient(base_url=settings.llm_control_base_url, timeout=5)
try:
raw = await client.complete(request, use_cache=True)
payload = json.loads(raw)
if set(payload) != {"passed", "comment"} or not isinstance(payload["passed"], bool):
raise ValueError("неверная схема")
comment = payload["comment"]
if not isinstance(comment, str) or len(comment) > 500:
raise ValueError("неверный комментарий")
errors = list(baseline.errors)
if not payload["passed"]:
errors.append(comment.strip() or "локальная модель обнаружила языковые ошибки")
return GrammarAssessment(not errors, tuple(dict.fromkeys(errors)), "vikhr")
except (LlmUnavailable, ValueError, TypeError, KeyError, json.JSONDecodeError):
return baseline
finally:
await client.aclose()

View file

@ -0,0 +1,77 @@
"""Локальный Qwen-инсайт по обезличенной агрегированной аналитике группы."""
import json
from app.config import get_settings
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url
class InsightInvalid(ValueError):
pass
async def generate_group_insight(data: dict) -> dict:
settings = get_settings()
if (not settings.llm_model_caller or not is_loopback_url(
settings.llm_base_url, allow_docker_host=settings.allow_docker_host_models
)):
raise LlmUnavailable("локальная модель аналитики не настроена")
schema = {"type": "json_object", "schema": {
"type": "object",
"properties": {
"summary": {"type": "string", "minLength": 20, "maxLength": 700},
"priorities": {
"type": "array", "minItems": 1, "maxItems": 3,
"items": {"type": "string", "minLength": 8, "maxLength": 240},
},
},
"required": ["summary", "priorities"],
"additionalProperties": False,
}}
prompt = {
"active_trainees": data["active_trainees"],
"scored_attempts": data["scored_attempts"],
"average_score": data["average_score"],
"typical_errors": [{
"code": item["code"],
"title": item["title"],
"rate_percent": item["rate_percent"],
"rule_recommendation": item["recommendation"],
} for item in data["errors"][:8]],
}
request = LlmRequest(
model=settings.llm_model_caller,
messages=[{
"role": "system",
"content": (
"Ты методист учебного центра системы-112. По обезличенной агрегированной "
"статистике сформулируй краткий русский вывод и от одной до трёх конкретных "
"тем следующего занятия. Не придумывай персональные данные, новые числа, "
"диагнозы или нормативы. Верни только JSON по заданной схеме. /no_think"
),
}, {"role": "user", "content": json.dumps(prompt, ensure_ascii=False)}],
temperature=0.0,
max_tokens=420,
response_format=schema,
strip_reasoning=True,
)
client = LlmClient(base_url=settings.llm_base_url, timeout=25)
try:
raw = await client.complete(request, use_cache=True)
finally:
await client.aclose()
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise InsightInvalid("модель вернула не JSON") from exc
if set(payload) != {"summary", "priorities"}:
raise InsightInvalid("неверный набор полей инсайта")
summary = payload["summary"]
priorities = payload["priorities"]
if not isinstance(summary, str) or not 20 <= len(summary.strip()) <= 700:
raise InsightInvalid("неверная длина вывода")
if (not isinstance(priorities, list) or not 1 <= len(priorities) <= 3
or any(not isinstance(item, str) or not 8 <= len(item.strip()) <= 240
for item in priorities)):
raise InsightInvalid("неверные приоритеты")
return {"summary": summary.strip(), "priorities": [item.strip() for item in priorities]}

View file

@ -46,6 +46,8 @@ def assess_difference(
def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
score = state.score or {}
metrics = [Metric.model_validate(item) for item in score.get("metrics", [])]
failed_metrics = sum(not item.passed and item.weight > 0 for item in metrics)
# Без модели эмбеддингов распознать *какие* факты прозвучали нельзя.
# Но если оператор не произнёс ни слова, все обязательные вопросы точно
# пропущены — это доказуемый частный случай и он нужен офлайн-разбору.
@ -78,9 +80,12 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
else scenario.id),
mode=state.mode,
attempt=state.attempt,
criteria=state.criteria,
failed_metrics=failed_metrics,
passed=failed_metrics <= state.criteria.allowed_errors,
transcript=list(state.transcript),
findings=[Finding.model_validate(item) for item in score.get("findings", [])],
metrics=[Metric.model_validate(item) for item in score.get("metrics", [])],
metrics=metrics,
card_results=[DdsCardReport.model_validate(item)
for item in score.get("card_results", [])],
competencies=[CompetencyScore.model_validate(item) for item in score.get("competencies", [])],

View file

@ -29,6 +29,7 @@ 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_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
}
#: Вес метрики в детерминированной оценке.

View file

@ -0,0 +1,358 @@
"""Переносимый JSON-снимок незавершённого занятия.
Снимок хранится в PostgreSQL после каждого подтверждённого действия. Он не
содержит сокеты, аудиобуферы или объекты моделей: после перезапуска процесса
они создаются заново, а учебные данные, таймеры и состояние ДДС восстанавливаются.
"""
import time
from datetime import UTC, datetime
from uuid import UUID
from fastapi.encoders import jsonable_encoder
from app.dialog.factory import build_caller
from app.dialog.persona import PersonaState
from app.dialog.runtime import get_embedder
from app.dialog.slots import SlotMachine
from app.domain.events import (
CallEndReason,
Exercise,
LessonCriteria,
Metric,
SessionMode,
TranscriptEntry,
)
from app.domain.kio import KIO
from app.domain.statuses import (
PhoneCallPending,
PhoneLineRecord,
PhoneReportRecord,
StatusEntry,
)
from app.domain.taxonomy import Finding
from app.domain.timers import TimerCode
from app.scenarios.schema import Scenario
from app.scoring.grammar import GrammarAssessment
from app.session.state import DdsCardRecord, DdsLiveCard, SessionState, now_utc
from app.session.timers import SessionTimers, Timer
CHECKPOINT_VERSION = 1
def _grammar(value: GrammarAssessment | None) -> dict | None:
if value is None:
return None
return {"passed": value.passed, "errors": list(value.errors), "source": value.source}
def _dump_timers(timers: SessionTimers, now: float) -> dict:
return {
"limits": {code.value: limit for code, limit in timers.limits.items()},
"items": {
code.value: {
"elapsed_ms": timer.current_ms(now),
"attempt": timer.attempt,
"stopped": timer.stopped,
}
for code, timer in timers.timers.items()
},
}
def _dump_live_card(item: DdsLiveCard, now: float) -> dict:
return {
"original_index": item.original_index,
"scenario": item.scenario.model_dump(mode="json"),
"kio": item.kio.model_dump(mode="json"),
"dispatched_card": item.dispatched_card.model_dump(mode="json"),
"dispatched_at": item.dispatched_at.isoformat(),
"timers": _dump_timers(item.timers, now),
"bounced_fields": item.bounced_fields,
"dds_log": [[action, at.isoformat(), detail] for action, at, detail in item.dds_log],
"status_log": [entry.model_dump(mode="json") for entry in item.status_log],
"crew_selected": item.crew_selected,
"crew_assignments": item.crew_assignments,
"phone_reports": [entry.model_dump(mode="json") for entry in item.phone_reports],
"phone_lines": [entry.model_dump(mode="json") for entry in item.phone_lines],
"phone_pending": item.phone_pending.model_dump(mode="json") if item.phone_pending else None,
"reply_text": item.reply_text,
"reply_grammar": _grammar(item.reply_grammar),
"reply_log": [[at.isoformat(), text] for at, text in item.reply_log],
}
def dump_state(state: SessionState) -> dict:
"""Сериализовать только данные, необходимые для точного продолжения."""
state.capture_active_dds()
now = time.monotonic()
payload = {
"version": CHECKPOINT_VERSION,
"session_id": str(state.session_id),
"scenario_id": state.scenario_id,
"scenario_title": state.scenario_title,
"level": state.level,
"mode": state.mode.value,
"owner_login": state.owner_login,
"exercise": state.exercise.value,
"handoff_to_dds": state.handoff_to_dds,
"required_fields": state.required_fields,
"trainee_name": state.trainee_name,
"trainee_id": str(state.trainee_id) if state.trainee_id else None,
"dds_service": state.dds_service,
"attempt": state.attempt,
"criteria": state.criteria.model_dump(mode="json"),
"kio": state.kio.model_dump(mode="json"),
"transcript": [entry.model_dump(mode="json") for entry in state.transcript],
"timers": _dump_timers(state.timers, now),
"hints_shown": state.hints_shown,
"hints_log": [[item, at.isoformat()] for item, at in state.hints_log],
"notes": state.notes,
"directives": state.directives,
"scenario": state.scenario.model_dump(mode="json") if state.scenario else None,
"audio_frames": state.audio_frames,
"bad_frames": state.bad_frames,
"self_assessed": state.self_assessed,
"self_assessment": state.self_assessment,
"score": state.score,
"started_at": state.started_at.isoformat() if state.started_at else None,
"ended_at": state.ended_at.isoformat() if state.ended_at else None,
"end_reason": state.end_reason.value if state.end_reason else None,
"dispatched_card": (
state.dispatched_card.model_dump(mode="json") if state.dispatched_card else None
),
"dispatched_at": state.dispatched_at.isoformat() if state.dispatched_at else None,
"bounced_fields": state.bounced_fields,
"dds_log": [
[action, at.isoformat(), detail] for action, at, detail in state.dds_log
],
"status_log": [item.model_dump(mode="json") for item in state.status_log],
"crew_selected": state.crew_selected,
"crew_assignments": state.crew_assignments,
"phone_reports": [item.model_dump(mode="json") for item in state.phone_reports],
"phone_lines": [item.model_dump(mode="json") for item in state.phone_lines],
"phone_pending": (
state.phone_pending.model_dump(mode="json") if state.phone_pending else None
),
"dds_scenarios": [item.model_dump(mode="json") for item in state.dds_scenarios],
"dds_live_cards": [_dump_live_card(item, now) for item in state.dds_live_cards],
"dds_active_card_id": (
str(state.dds_active_card_id) if state.dds_active_card_id else None
),
"dds_card_index": state.dds_card_index,
"dds_arrival_interval_seconds": state.dds_arrival_interval_seconds,
"dds_max_waiting": state.dds_max_waiting,
"dds_next_scenario_index": state.dds_next_scenario_index,
"dds_next_arrival_at": (
state.dds_next_arrival_at.isoformat() if state.dds_next_arrival_at else None
),
"dds_completed": [
{
"card_id": str(item.card_id),
"scenario_id": item.scenario_id,
"reply_text": item.reply_text,
"metrics": [metric.model_dump(mode="json") for metric in item.metrics],
"findings": [finding.model_dump(mode="json") for finding in item.findings],
"actions": item.actions,
"duration_ms": item.duration_ms,
}
for item in state.dds_completed
],
"reply_text": state.reply_text,
"reply_grammar": _grammar(state.reply_grammar),
"reply_log": [[at.isoformat(), text] for at, text in state.reply_log],
"resolved_outcome": state.resolved_outcome,
"resolve_comment": state.resolve_comment,
}
# В actions/score могут быть datetime/UUID из расчёта; JSONB должен
# получать только стандартные JSON-типы.
return jsonable_encoder(payload)
def _dt(value: str | None) -> datetime | None:
return datetime.fromisoformat(value) if value else None
def _restore_timers(payload: dict, saved_at: datetime) -> SessionTimers:
raw = payload.get("timers") or {}
limits = {
TimerCode(code): int(limit)
for code, limit in (raw.get("limits") or {}).items()
}
restored = SessionTimers(limits=limits or SessionTimers().limits)
now_mono = time.monotonic()
now_wall = datetime.now(UTC)
if saved_at.tzinfo is None:
saved_at = saved_at.replace(tzinfo=UTC)
downtime_ms = max(0, int((now_wall - saved_at).total_seconds() * 1000))
for raw_code, item in (raw.get("items") or {}).items():
code = TimerCode(raw_code)
stopped = bool(item.get("stopped"))
elapsed = max(0, int(item.get("elapsed_ms", 0)))
total = elapsed if stopped else elapsed + downtime_ms
restored.timers[code] = Timer(
code=code,
started_at=None if stopped else now_mono - total / 1000,
elapsed_ms=elapsed if stopped else 0,
attempt=max(1, int(item.get("attempt", 1))),
stopped=stopped,
)
return restored
def _restore_grammar(value: dict | None) -> GrammarAssessment | None:
if not value:
return None
return GrammarAssessment(
passed=bool(value["passed"]),
errors=tuple(value.get("errors") or []),
source=value["source"],
)
def _restore_live_card(item: dict, saved_at: datetime) -> DdsLiveCard:
return DdsLiveCard(
original_index=int(item["original_index"]),
scenario=Scenario.model_validate(item["scenario"]),
kio=KIO.model_validate(item["kio"]),
dispatched_card=KIO.model_validate(item["dispatched_card"]),
dispatched_at=datetime.fromisoformat(item["dispatched_at"]),
timers=_restore_timers({"timers": item["timers"]}, saved_at),
bounced_fields=list(item.get("bounced_fields") or []),
dds_log=[(action, datetime.fromisoformat(at), detail)
for action, at, detail in item.get("dds_log", [])],
status_log=[StatusEntry.model_validate(entry)
for entry in item.get("status_log", [])],
crew_selected=item.get("crew_selected"),
crew_assignments=dict(item.get("crew_assignments") or {}),
phone_reports=[PhoneReportRecord.model_validate(entry)
for entry in item.get("phone_reports", [])],
phone_lines=[PhoneLineRecord.model_validate(entry)
for entry in item.get("phone_lines", [])],
phone_pending=(PhoneCallPending.model_validate(item["phone_pending"])
if item.get("phone_pending") else None),
reply_text=item.get("reply_text", ""),
reply_grammar=_restore_grammar(item.get("reply_grammar")),
reply_log=[(datetime.fromisoformat(at), text)
for at, text in item.get("reply_log", [])],
)
def load_state(payload: dict, saved_at: datetime) -> SessionState:
"""Восстановить состояние; неизвестная версия отклоняется явно."""
if payload.get("version") != CHECKPOINT_VERSION:
raise ValueError("неподдерживаемая версия снимка занятия")
scenario = Scenario.model_validate(payload["scenario"]) if payload.get("scenario") else None
state = SessionState(
session_id=UUID(payload["session_id"]),
scenario_id=payload["scenario_id"],
scenario_title=payload["scenario_title"],
level=payload["level"],
mode=SessionMode(payload["mode"]),
owner_login=payload.get("owner_login"),
exercise=Exercise(payload["exercise"]),
handoff_to_dds=bool(payload.get("handoff_to_dds")),
required_fields=list(payload.get("required_fields") or []),
trainee_name=payload.get("trainee_name"),
trainee_id=UUID(payload["trainee_id"]) if payload.get("trainee_id") else None,
dds_service=payload.get("dds_service"),
attempt=int(payload.get("attempt", 1)),
criteria=LessonCriteria.model_validate(payload.get("criteria") or {}),
kio=KIO.model_validate(payload.get("kio") or {}),
transcript=[TranscriptEntry.model_validate(item) for item in payload.get("transcript", [])],
timers=_restore_timers(payload, saved_at),
hints_shown=list(payload.get("hints_shown") or []),
hints_log=[(item, datetime.fromisoformat(at))
for item, at in payload.get("hints_log", [])],
notes=list(payload.get("notes") or []),
directives=list(payload.get("directives") or []),
scenario=scenario,
audio_frames=int(payload.get("audio_frames", 0)),
bad_frames=int(payload.get("bad_frames", 0)),
self_assessed=bool(payload.get("self_assessed")),
self_assessment=payload.get("self_assessment"),
score=payload.get("score"),
started_at=_dt(payload.get("started_at")),
ended_at=_dt(payload.get("ended_at")),
end_reason=(CallEndReason(payload["end_reason"]) if payload.get("end_reason") else None),
dispatched_card=(
KIO.model_validate(payload["dispatched_card"])
if payload.get("dispatched_card") else None
),
dispatched_at=_dt(payload.get("dispatched_at")),
bounced_fields=list(payload.get("bounced_fields") or []),
dds_log=[(action, datetime.fromisoformat(at), detail)
for action, at, detail in payload.get("dds_log", [])],
status_log=[StatusEntry.model_validate(item) for item in payload.get("status_log", [])],
crew_selected=payload.get("crew_selected"),
crew_assignments=dict(payload.get("crew_assignments") or {}),
phone_reports=[PhoneReportRecord.model_validate(item)
for item in payload.get("phone_reports", [])],
phone_lines=[PhoneLineRecord.model_validate(item)
for item in payload.get("phone_lines", [])],
phone_pending=(
PhoneCallPending.model_validate(payload["phone_pending"])
if payload.get("phone_pending") else None
),
dds_scenarios=[Scenario.model_validate(item)
for item in payload.get("dds_scenarios", [])],
dds_live_cards=[_restore_live_card(item, saved_at)
for item in payload.get("dds_live_cards", [])],
dds_active_card_id=(
UUID(payload["dds_active_card_id"])
if payload.get("dds_active_card_id") else None
),
dds_card_index=int(payload.get("dds_card_index", 0)),
dds_arrival_interval_seconds=int(payload.get("dds_arrival_interval_seconds", 0)),
dds_max_waiting=int(payload.get("dds_max_waiting", 3)),
dds_next_scenario_index=int(payload.get(
"dds_next_scenario_index",
max((item["original_index"] for item in payload.get("dds_live_cards", [])), default=-1) + 1,
)),
dds_next_arrival_at=_dt(payload.get("dds_next_arrival_at")),
dds_completed=[
DdsCardRecord(
card_id=UUID(item["card_id"]),
scenario_id=item["scenario_id"],
reply_text=item.get("reply_text", ""),
metrics=[Metric.model_validate(metric) for metric in item.get("metrics", [])],
findings=[Finding.model_validate(finding)
for finding in item.get("findings", [])],
actions=list(item.get("actions") or []),
duration_ms=int(item.get("duration_ms", 0)),
)
for item in payload.get("dds_completed", [])
],
reply_text=payload.get("reply_text", ""),
reply_grammar=_restore_grammar(payload.get("reply_grammar")),
reply_log=[(datetime.fromisoformat(at), text)
for at, text in payload.get("reply_log", [])],
resolved_outcome=payload.get("resolved_outcome"),
resolve_comment=payload.get("resolve_comment", ""),
)
if state.dds_live_cards:
# Legacy snapshots had no explicit active ID; newer snapshots may
# intentionally be between cards while waiting for the next arrival.
active_id = state.dds_active_card_id
if active_id is None and "dds_active_card_id" not in payload:
active_id = state.dds_live_cards[0].card_id
if active_id is not None:
state.activate_dds_card(active_id, capture=False)
if (state.dds_scenarios
and state.dds_next_scenario_index < len(state.dds_scenarios)
and state.dds_next_arrival_at is None):
# Old checkpoints had no delivery schedule; resume any remaining
# selected scenarios immediately rather than strand the session.
state.dds_next_arrival_at = now_utc()
# Голосовые runtime-объекты не сериализуются. Их безопасно собрать заново;
# карточка и оценка при этом остаются прежними.
if state.exercise is Exercise.CALL and state.scenario is not None:
state.persona = PersonaState(state.scenario.persona)
state.caller = build_caller(
state.scenario.id,
use_pregenerated=state.scenario.tree.pregenerated,
)
embedder = get_embedder()
if embedder is not None:
state.slots = SlotMachine(state.scenario, embedder)
return state

View file

@ -1,11 +1,12 @@
"""Выдача очередной учебной карточки ДДС в рамках одного занятия."""
"""Одновременная выдача учебных карточек ДДС в рамках одного занятия."""
import re
from datetime import datetime, timedelta
from app.domain import ekp
from app.domain.kio import KIO, ResponseStatus
from app.scenarios.schema import Scenario
from app.session.state import SessionState, now_utc
from app.session.state import DdsLiveCard, SessionState, now_utc
from app.session.timers import SessionTimers
@ -18,11 +19,25 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
state.required_fields = list(scenario.required_fields)
truth = scenario.ground_truth
address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "")
caller_fact = next((fact.value for fact in scenario.facts if fact.id in {"f_caller", "f_applicant"}), "")
caller_phone = next((
match.group(0).strip()
for match in re.finditer(r"(?<!\d)(?:\+?7|8)?(?:[\s().-]*\d){10,11}(?!\d)", caller_fact)
if len(re.sub(r"\D", "", match.group(0))) in {10, 11}
), None)
caller_names = re.findall(
r"(?<![А-ЯЁа-яё])([А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ][а-яё-]+){1,2})(?![А-ЯЁа-яё])",
caller_fact,
)
# Some ticket facts name multiple witnesses; don't silently attribute the
# contact number to the first person when the source doesn't disambiguate.
caller_name = caller_names[0] if len(caller_names) == 1 else None
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
service = truth.dds.value if truth.dds else None
fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"}
state.kio = KIO(
registered_at=now_utc(), caller_number="+7 (495) 000-00-00",
registered_at=now_utc(), caller_number=caller_phone, caller_name=caller_name,
caller_contact=caller_phone,
address=truth.address or address_fact or None,
floor=floor.group(1) if floor else None,
incident_type=truth.incident_type, incident_code=truth.incident_code,
@ -41,13 +56,110 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
state.dispatched_card = state.kio.model_copy(deep=True)
state.dispatched_at = now_utc()
state.status_log.clear()
state.dds_log.clear()
state.bounced_fields.clear()
# A live DДС card keeps references to the current working collections.
# Never clear them in place when preparing the next card: that would also
# erase the previous card's history. Replace the working collections so
# every queued card retains an isolated snapshot.
state.status_log = []
state.dds_log = []
state.bounced_fields = []
state.crew_selected = None
state.crew_assignments.clear()
state.phone_reports.clear()
state.crew_assignments = {}
state.phone_reports = []
state.phone_lines = []
state.phone_pending = None
state.reply_text = ""
state.reply_log.clear()
state.timers = SessionTimers()
state.reply_grammar = None
state.reply_log = []
# Настроенный преподавателем лимит копируется в независимый таймер карточки.
state.timers = SessionTimers(limits=dict(state.timers.limits))
state.on_event("dds.dispatch")
def _append_live_card(state: SessionState, scenario: Scenario, index: int) -> DdsLiveCard:
state.dds_card_index = index
prepare_card(state, scenario)
card = DdsLiveCard(
original_index=index,
scenario=state.scenario,
kio=state.kio,
dispatched_card=state.dispatched_card,
dispatched_at=state.dispatched_at,
timers=state.timers,
bounced_fields=state.bounced_fields,
dds_log=state.dds_log,
status_log=state.status_log,
crew_selected=state.crew_selected,
crew_assignments=state.crew_assignments,
phone_reports=state.phone_reports,
phone_lines=state.phone_lines,
phone_pending=state.phone_pending,
reply_text=state.reply_text,
reply_grammar=state.reply_grammar,
reply_log=state.reply_log,
)
state.dds_live_cards.append(card)
return card
def deliver_due_cards(state: SessionState, now: datetime | None = None) -> int:
"""Выдать всё, чему настал срок и для чего есть место в ожидании.
Активная карточка не считается ожидающей. Каждый вызов prepare_card
запускает собственный 30-секундный таймер ровно при поступлении.
"""
now = now or now_utc()
if state.dds_next_arrival_at is None or state.dds_next_arrival_at > now:
return 0
delivered = 0
while state.dds_next_scenario_index < len(state.dds_scenarios):
active_id = state.dds_active_card_id if any(
item.card_id == state.dds_active_card_id for item in state.dds_live_cards
) else None
active_exists = active_id is not None
waiting_count = len(state.dds_live_cards) - int(active_exists)
if waiting_count >= state.dds_max_waiting:
break
index = state.dds_next_scenario_index
card = _append_live_card(state, state.dds_scenarios[index], index)
state.dds_next_scenario_index += 1
delivered += 1
if not active_exists:
state.activate_dds_card(card.card_id, capture=False)
else:
# prepare_card builds the new snapshot through SessionState's
# working fields; restore the trainee's active card immediately.
state.activate_dds_card(active_id, capture=False)
if state.dds_next_scenario_index >= len(state.dds_scenarios):
state.dds_next_arrival_at = None
break
if state.dds_arrival_interval_seconds:
# Pace from actual delivery time. If the cap was full, the next
# card becomes due one full interval after this release.
state.dds_next_arrival_at = card.dispatched_at + timedelta(
seconds=state.dds_arrival_interval_seconds
)
break
# Zero interval means fill active + available waiting slots now.
state.dds_next_arrival_at = now
return delivered
def prepare_queue(
state: SessionState,
scenarios: list[Scenario],
arrival_interval_seconds: int = 0,
max_waiting: int = 3,
) -> None:
"""Начать поступление очереди с первой карточки; остальные идут по темпу."""
state.dds_live_cards.clear()
state.dds_scenarios = scenarios
state.dds_arrival_interval_seconds = arrival_interval_seconds
state.dds_max_waiting = max_waiting
state.dds_next_scenario_index = 0
state.dds_next_arrival_at = now_utc()
deliver_due_cards(state)

View file

@ -7,17 +7,18 @@
событие сразу: им ждать нечего.
"""
import asyncio
import logging
from uuid import UUID
from app.domain.events import Exercise, Metric, ScoreReady
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.timers import NORMATIVES, TimerCode
from app.domain.events import Exercise, ScoreReady
from app.domain.timers import TimerCode
from app.scenarios import store
from app.scoring.competency import radar
from app.scoring.card import evaluate_card
from app.scoring.competency import radar
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
from app.scoring.gost import GostResult, evaluate
from app.scoring.report import build as build_report
from app.scoring.weights import apply_weights
from app.session.hub import hub
from app.session.state import DdsCardRecord, now_utc
@ -28,34 +29,16 @@ log = logging.getLogger(__name__)
def score_current_dds(state) -> DdsCardRecord:
"""Оценить активную карточку отдельно, до выдачи следующей."""
number = state.dds_card_index + 1
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
findings = evaluate_dispatcher(
entries=state.status_log,
services=state.notified_services(),
deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
services=state.managed_services(),
deadline_ms=decision_limit_ms,
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
)
metrics = dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms)
reply = state.reply_text.strip()
if reply or len(state.dds_scenarios) > 1:
passed = len(reply) >= 20 and len(reply.split()) >= 3
metrics.append(Metric(
key="dds_reply", title="Свободный ответ по карточке",
fact=reply or "ответ отсутствует",
norm="содержательный текст не короче 20 символов и 3 слов",
ref="ТЗ: действия с карточками, ввод текста",
passed=passed, weight=1.5,
))
if not passed:
findings.append(Finding(
code=ErrorCode.D5, source=FindingSource.DISPATCHER,
summary="Свободный ответ отсутствует или слишком краток",
fact=reply or "ответ отсутствует",
norm="не менее 20 символов и 3 слов; грамматика отдельно не проверялась",
ref="ТЗ: действия с карточками, ввод текста",
competency=Competency.COMMUNICATION,
))
metrics = dispatcher_metrics(state, decision_limit_ms)
weighted = GostResult(metrics=metrics, findings=findings)
apply_weights(weighted, state.scenario.score_weights)
apply_weights(weighted, {**state.scenario.score_weights, **state.criteria.score_weights})
actions = [
{"type": "card.status", "service": mark.service, "status": mark.status.value,
"comment": mark.comment, "at": mark.at.isoformat()}
@ -87,6 +70,9 @@ def score_current_dds(state) -> DdsCardRecord:
async def finish(session_id: UUID, state) -> None:
if state.recorder is not None:
path = await asyncio.to_thread(state.recorder.finalize)
state.recording_path = str(path) if path else None
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
scenario = state.scenario or store.get(state.scenario_id)
if scenario is None:
@ -95,22 +81,20 @@ async def finish(session_id: UUID, state) -> None:
if state.exercise is Exercise.CARD:
result = evaluate_card(scenario, state.dispatched_card or state.kio)
elif state.exercise is Exercise.DDS:
# В ДДС могут последовательно пройти несколько карточек. Уже закрытые
# сохранены отдельными результатами; активную оцениваем лишь однажды.
# Все карточки выданы одновременно: при досрочном завершении оцениваем
# каждую, включая не открытую, потому что её норматив уже шёл.
result = GostResult()
cards = list(state.dds_completed)
if state.dispatched_card and not any(
item.card_id == state.dispatched_card.card_id for item in cards
):
state.capture_active_dds()
for live in sorted(state.dds_live_cards, key=lambda item: item.original_index):
if any(item.card_id == live.card_id for item in cards):
continue
state.activate_dds_card(live.card_id, capture=False)
cards.append(score_current_dds(state))
state.dds_completed = cards
for card in cards:
result.metrics.extend(card.metrics)
result.findings.extend(card.findings)
result.unavailable.append(
"Грамматика свободного ответа: автоматическая проверка не настроена; "
"оценивались только наличие и минимальная полнота текста"
)
else:
result = evaluate(
scenario=scenario,
@ -129,16 +113,20 @@ async def finish(session_id: UUID, state) -> None:
if state.dispatched_card is not None and (
state.exercise is Exercise.CALL or state.handoff_to_dds
):
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
dispatcher_findings = evaluate_dispatcher(
entries=state.status_log,
services=state.notified_services(),
deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
services=state.managed_services(),
deadline_ms=decision_limit_ms,
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
)
result.findings.extend(dispatcher_findings)
result.metrics.extend(dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms))
result.metrics.extend(dispatcher_metrics(state, decision_limit_ms))
# DDS cards were weighted individually in score_current_dds using each
# card's scenario defaults plus the lesson override. Reapplying the first
# scenario's weights here would corrupt the other ticket cards.
if state.exercise is not Exercise.DDS:
apply_weights(result, scenario.score_weights)
apply_weights(result, {**scenario.score_weights, **state.criteria.score_weights})
# Сводка числами: по ней считается дельта между попытками в профиле.
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
@ -172,6 +160,9 @@ async def finish(session_id: UUID, state) -> None:
for card in cards
] if state.exercise is Exercise.DDS else [],
}
# Полный разбор хранится вместе с оценкой: PDF/CSV и история должны
# переживать перезапуск backend, а не зависеть от объекта в hub._sessions.
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))
if hub.journal:
@ -181,6 +172,18 @@ async def finish(session_id: UUID, state) -> None:
await release_score(session_id, state)
async def refresh_archived_report(session_id: UUID, state) -> None:
"""Дополнить сохранённый разбор самооценкой, пришедшей после hangup."""
if state.score is None:
return
scenario = state.scenario or store.get(state.scenario_id)
if scenario is None:
return
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
if hub.journal:
await hub.journal.score_snapshot(session_id, state.score)
async def release_score(session_id: UUID, state) -> None:
"""Отдать оценку курсанту, когда самооценка сдана."""
if state.score is not None and (state.self_assessed or state.exercise is Exercise.CARD):

View file

@ -13,7 +13,7 @@ from uuid import UUID
from pydantic import BaseModel
from app.domain.events import TimerTick
from app.domain.events import CardReceived, Exercise, StationState, TimerTick
from app.session.state import SessionState
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
@ -30,14 +30,20 @@ class Journal(Protocol):
async def start_lesson(
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
trainee_id: UUID | None = None,
) -> tuple[int, UUID | None]: ...
) -> tuple[int, UUID | None, str | None]: ...
async def utterance(self, session_id: UUID, entry) -> None: ...
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...
async def self_assessment(self, session_id: UUID, missed: list[str], comment: str, at) -> None: ...
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None: ...
async def score_snapshot(self, session_id: UUID, report: dict) -> None: ...
async def score_override(
self, session_id: UUID, score_final: float, author: str, comment: str,
) -> None: ...
async def session_started(self, session_id: UUID, at) -> None: ...
async def session_ended(self, session_id: UUID, at, reason: str) -> None: ...
async def checkpoint(self, state: SessionState) -> None: ...
async def restore_active(self) -> list[SessionState]: ...
class SessionHub:
@ -58,10 +64,26 @@ class SessionHub:
def get(self, session_id: UUID) -> SessionState | None:
return self._sessions.get(session_id)
def has_active_scenario(self, scenario_id: str) -> bool:
"""Архивирование контента не должно менять уже идущее занятие."""
return any(
not state.ended and (
state.scenario_id == scenario_id
or any(item.id == scenario_id for item in state.dds_scenarios)
)
for state in self._sessions.values()
)
def drop(self, session_id: UUID) -> None:
self._sessions.pop(session_id, None)
self.stop_ticker(session_id)
async def checkpoint(self, session_id: UUID) -> None:
"""Зафиксировать подтверждённое состояние, если журнал доступен."""
state = self.get(session_id)
if state is not None and self.journal is not None:
await self.journal.checkpoint(state)
# ── подписки ──
@contextlib.contextmanager
@ -138,6 +160,18 @@ class SessionHub:
state = self.get(session_id)
if state is None or state.ended:
return
if state.exercise is Exercise.DDS:
from app.session.dds import deliver_due_cards
active_before = state.dds_active_card_id
delivered = deliver_due_cards(state)
if state.dds_active_card_id != active_before and state.dds_active_card_id:
self.to_station(session_id, state.card_received_event())
if delivered:
await self.checkpoint(session_id)
# Keep the pending count and countdown live even while
# the active dispatcher card is being handled.
self.to_station(session_id, StationState(snapshot=state.station_snapshot()))
self.broadcast(session_id, TimerTick(timers=state.timers.snapshot()))
except asyncio.CancelledError:
raise

View file

@ -8,11 +8,14 @@ import logging
from datetime import datetime
from uuid import UUID
from sqlalchemy import update
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.db import repo
from app.db.models import Score, SelfAssessment, Session
from app.db.models import Score, SelfAssessment, Session, User, Utterance
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
log = logging.getLogger(__name__)
@ -32,8 +35,8 @@ class DbJournal:
async def start_lesson(
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
trainee_id: UUID | None = None,
) -> tuple[int, UUID | None]:
trainee_id: UUID | None = None, owner_login: str | None = None,
) -> tuple[int, UUID | None, str | None]:
"""Завести сессию в журнале и вернуть номер попытки и ID курсанта.
Если база недоступна, занятие всё равно идёт: номер попытки
@ -48,11 +51,75 @@ class DbJournal:
mode=mode,
trainee_name=trainee_name,
trainee_id=trainee_id,
owner_login=owner_login,
)
return row.attempt, row.trainee_id
service = None
if row.trainee_id is not None:
service = await db.scalar(
select(User.service)
.where(User.trainee_id == row.trainee_id, User.blocked.is_(False))
.limit(1)
)
return row.attempt, row.trainee_id, service
except PermissionError:
raise
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
log.exception("журнал: сессию завести не удалось")
return 1, trainee_id
return 1, trainee_id, None
async def checkpoint(self, state: SessionState) -> None:
"""Сохранить снимок после подтверждённого действия пользователя."""
async def action(db):
values = (
{"live_state": None, "checkpoint_at": None}
if state.ended
else {"live_state": dump_state(state), "checkpoint_at": now_utc()}
)
await db.execute(update(Session).where(Session.id == state.session_id).values(**values))
await db.commit()
await self._write(lambda db: action(db))
async def restore_active(self) -> list[SessionState]:
"""Восстановить только незавершённые сессии с валидным снимком."""
restored: list[SessionState] = []
async with self._sessionmaker() as db:
rows = (await db.scalars(
select(Session).where(
Session.ended_at.is_(None),
Session.live_state.is_not(None),
Session.checkpoint_at.is_not(None),
)
)).all()
for row in rows:
try:
state = load_state(row.live_state, row.checkpoint_at)
if state.session_id != row.id:
raise ValueError("ID снимка не совпадает с записью занятия")
state.owner_login = row.owner_login
# Реплики пишутся отдельно сразу после появления. Если
# процесс умер между репликой и общим снимком, отдельный
# журнал не даёт потерять последний фрагмент диалога.
utterances = (await db.scalars(
select(Utterance)
.where(Utterance.session_id == row.id)
.order_by(Utterance.at, Utterance.ref)
)).all()
if utterances:
state.transcript = [
TranscriptEntry(
ref=item.ref,
speaker=Speaker(item.speaker),
text=item.text,
at=item.at,
mood=Mood(item.mood) if item.mood else None,
)
for item in utterances
]
restored.append(state)
except Exception: # noqa: BLE001 — один снимок не блокирует весь стенд
log.exception("журнал: снимок занятия %s повреждён", row.id)
return restored
async def utterance(self, session_id: UUID, entry) -> None:
await self._write(
@ -111,6 +178,16 @@ class DbJournal:
await self._write(lambda db: action(db))
async def score_snapshot(self, session_id: UUID, report: dict) -> None:
"""Обновить полный архивный разбор после самооценки курсанта."""
async def action(db):
await db.execute(
update(Score).where(Score.session_id == session_id).values(report=report)
)
await db.commit()
await self._write(lambda db: action(db))
async def session_started(self, session_id: UUID, at: datetime) -> None:
async def action(db):
await db.execute(update(Session).where(Session.id == session_id).values(started_at=at))
@ -123,7 +200,12 @@ class DbJournal:
await db.execute(
update(Session)
.where(Session.id == session_id)
.values(ended_at=at, end_reason=reason)
.values(
ended_at=at,
end_reason=reason,
live_state=None,
checkpoint_at=None,
)
)
await db.commit()

View file

@ -5,45 +5,52 @@
пустой экран (docs/arch/STACK.md).
"""
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from app.domain.events import (
CallEndReason,
Exercise,
Mood,
SessionMode,
SessionSnapshot,
Metric,
Speaker,
TranscriptEntry,
)
from app.dialog.caller import TemplateCaller
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine
from app.scenarios.schema import Scenario
from app.domain.events import (
CallEndReason,
Exercise,
LessonCriteria,
Metric,
Mood,
SessionMode,
SessionSnapshot,
Speaker,
TranscriptEntry,
)
from app.domain.kio import KIO, ResponseStatus, apply_patch
from app.domain.statuses import (
CardStatus,
NEXT,
DdsCardSummary,
DdsQueueCard,
PhoneCallPending,
PhoneLineRecord,
PhoneReportRecord,
ServiceStatus,
StationSnapshot,
StatusEntry,
PhoneReportRecord,
DdsCardSummary,
card_status,
check,
current,
)
from app.domain.timers import NORMATIVES, TimerCode
from app.domain.kio import KIO, ResponseStatus, apply_patch
from app.session.timers import SessionTimers
from app.domain.taxonomy import Finding
from app.domain.timers import TimerCode
from app.scenarios.schema import Scenario
from app.scoring.grammar import GrammarAssessment
from app.session.timers import SessionTimers
def now_utc() -> datetime:
"""Часы серверные. Метрика, посчитанная по часам браузера, недоказуема."""
return datetime.now(timezone.utc)
return datetime.now(UTC)
@dataclass
@ -63,6 +70,33 @@ class DdsCardRecord:
return round(100 * passed / total, 1) if total else 0.0
@dataclass
class DdsLiveCard:
"""Изолированное живое состояние одной одновременно выданной карточки."""
original_index: int
scenario: Scenario
kio: KIO
dispatched_card: KIO
dispatched_at: datetime
timers: SessionTimers
bounced_fields: list[str] = field(default_factory=list)
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
status_log: list[StatusEntry] = field(default_factory=list)
crew_selected: str | None = None
crew_assignments: dict[str, str] = field(default_factory=dict)
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
phone_pending: PhoneCallPending | None = None
reply_text: str = ""
reply_grammar: GrammarAssessment | None = None
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
@property
def card_id(self) -> UUID:
return self.dispatched_card.card_id
@dataclass
class SessionState:
session_id: UUID
@ -70,6 +104,8 @@ class SessionState:
scenario_title: str
level: str
mode: SessionMode
#: Преподаватель, создавший занятие; чужой пульт не может им управлять.
owner_login: str | None = None
exercise: Exercise = Exercise.CALL
#: После заполнения КИО занятие продолжится на АРМ ДДС, а не завершится.
handoff_to_dds: bool = False
@ -78,7 +114,11 @@ class SessionState:
#: Чьё это занятие. Проставляется при запуске, когда курсант известен
#: по учётной записи: по нему разбор закрывается от чужих (lct-23).
trainee_id: UUID | None = None
#: Конкретная ДДС обучающегося. Она меняет статусы только своей службы;
#: остальные адресаты карточки показываются информационно.
dds_service: str | None = None
attempt: int = 1
criteria: LessonCriteria = field(default_factory=LessonCriteria)
kio: KIO = field(default_factory=KIO)
transcript: list[TranscriptEntry] = field(default_factory=list)
@ -100,6 +140,8 @@ class SessionState:
# Голосовой контур звонка. Нет — если голос выключен или моделей нет:
# тогда кадры микрофона только считаются.
voice: object | None = None
recorder: object | None = None
recording_path: str | None = None
# Аудио курсанта. До голосового контура (lct-06) кадры только считаются —
# этого достаточно, чтобы доказать, что звук доходит от микрофона до сервера.
@ -124,10 +166,19 @@ class SessionState:
crew_selected: str | None = None
crew_assignments: dict[str, str] = field(default_factory=dict)
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
phone_pending: PhoneCallPending | None = None
dds_scenarios: list[Scenario] = field(default_factory=list)
dds_live_cards: list[DdsLiveCard] = field(default_factory=list)
dds_active_card_id: UUID | None = None
dds_card_index: int = 0
dds_completed: list[DdsCardRecord] = field(default_factory=list)
dds_arrival_interval_seconds: int = 0
dds_max_waiting: int = 3
dds_next_scenario_index: int = 0
dds_next_arrival_at: datetime | None = None
reply_text: str = ""
reply_grammar: GrammarAssessment | None = None
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
resolved_outcome: str | None = None
@ -168,12 +219,106 @@ class SessionState:
card = self.dispatched_card or self.kio
return list(card.notify)
def _managed_services_for(self, recipients: list[str]) -> list[str]:
"""Служба, от имени которой работает этот обучающийся.
Старые занятия и demo без профиля безопасно закрепляются за первым
адресатом. Обобщённая учебная служба «ДДС района» соответствует
территориальному органу, а не всем получателям карточки сразу.
"""
if not recipients:
return []
wanted = (self.dds_service or "").strip().casefold()
if wanted:
exact = next((item for item in recipients if item.casefold() == wanted), None)
if exact:
return [exact]
contained = next(
(item for item in recipients
if wanted in item.casefold() or item.casefold() in wanted),
None,
)
if contained:
return [contained]
if "район" in wanted:
district = next(
(item for item in recipients if "территориальн" in item.casefold()), None
)
if district:
return [district]
return []
return [recipients[0]]
def managed_services(self) -> list[str]:
return self._managed_services_for(self.notified_services())
def capture_active_dds(self) -> None:
"""Сохранить изменяемые поля активной карточки перед переключением."""
if not self.dispatched_card:
return
card = next(
(item for item in self.dds_live_cards
if item.card_id == self.dispatched_card.card_id),
None,
)
if card is None:
return
card.scenario = self.scenario
card.kio = self.kio
card.dispatched_card = self.dispatched_card
card.dispatched_at = self.dispatched_at or card.dispatched_at
card.timers = self.timers
card.bounced_fields = self.bounced_fields
card.dds_log = self.dds_log
card.status_log = self.status_log
card.crew_selected = self.crew_selected
card.crew_assignments = self.crew_assignments
card.phone_reports = self.phone_reports
card.phone_lines = self.phone_lines
card.phone_pending = self.phone_pending
card.reply_text = self.reply_text
card.reply_grammar = self.reply_grammar
card.reply_log = self.reply_log
self.dds_active_card_id = card.card_id
def activate_dds_card(self, card_id: UUID, *, capture: bool = True) -> bool:
"""Переключить рабочее место на карточку, не останавливая остальные."""
if capture:
self.capture_active_dds()
card = next((item for item in self.dds_live_cards if item.card_id == card_id), None)
if card is None:
return False
scenario = card.scenario
self.scenario = scenario
self.scenario_id = scenario.id
self.scenario_title = scenario.title
self.level = scenario.level.value
self.required_fields = list(scenario.required_fields)
self.kio = card.kio
self.dispatched_card = card.dispatched_card
self.dispatched_at = card.dispatched_at
self.timers = card.timers
self.bounced_fields = card.bounced_fields
self.dds_log = card.dds_log
self.status_log = card.status_log
self.crew_selected = card.crew_selected
self.crew_assignments = card.crew_assignments
self.phone_reports = card.phone_reports
self.phone_lines = card.phone_lines
self.phone_pending = card.phone_pending
self.reply_text = card.reply_text
self.reply_grammar = card.reply_grammar
self.reply_log = card.reply_log
self.dds_card_index = card.original_index
self.dds_active_card_id = card.card_id
return True
def crew_options(self) -> list[str]:
return [f"{service} — бригада №{number}"
for service in self.notified_services() for number in (1, 2)]
for service in self.managed_services() for number in (1, 2)]
def crew_service(self, crew: str) -> str | None:
return next((service for service in self.notified_services()
return next((service for service in self.managed_services()
if crew in (f"{service} — бригада №1", f"{service} — бригада №2")), None)
def set_service_status(
@ -190,31 +335,84 @@ class SessionState:
def station_snapshot(self) -> StationSnapshot:
"""Что видит диспетчер: службы, их статусы и доступные переходы."""
services = self.notified_services()
self.capture_active_dds()
has_active_dds_card = any(
item.card_id == self.dds_active_card_id for item in self.dds_live_cards
)
recipients = self.notified_services() if has_active_dds_card or not self.dds_scenarios else []
services = self._managed_services_for(recipients)
statuses = {service: current(self.status_log, service) for service in services}
elapsed = self.timers.measured_ms(TimerCode.DDS_ACK)
limit = NORMATIVES[TimerCode.DDS_ACK].limit_ms
overdue = self.dispatched_at is not None and (
limit = self.timers.limits[TimerCode.DDS_ACK]
overdue = has_active_dds_card and self.dispatched_at is not None and (
elapsed is None
and (now_utc() - self.dispatched_at).total_seconds() * 1000 > limit
)
now_mono = time.monotonic()
queue_cards: list[DdsQueueCard] = []
for item in sorted(self.dds_live_cards, key=lambda queued: queued.original_index):
recipients_for_card = list(item.dispatched_card.notify)
managed = self._managed_services_for(recipients_for_card)
service = managed[0] if managed else None
timer = item.timers.timers.get(TimerCode.DDS_ACK)
elapsed_ms = timer.current_ms(now_mono) if timer else 0
stopped = timer.stopped if timer else False
limit_ms = item.timers.limits[TimerCode.DDS_ACK]
queue_cards.append(DdsQueueCard(
card_id=item.card_id,
scenario_id=item.scenario.id,
title=item.scenario.title,
address=item.dispatched_card.address,
description=item.dispatched_card.description,
incident_type=(item.dispatched_card.incident_type.value
if item.dispatched_card.incident_type else None),
victims_count=item.dispatched_card.victims_count,
received_at=item.dispatched_at,
managed_service=service,
service_status=(current(item.status_log, service)
if service else ServiceStatus.ADDED),
card_status=card_status(
item.status_log, managed,
notify_deadline_passed=not stopped and elapsed_ms > limit_ms,
),
elapsed_ms=elapsed_ms,
limit_ms=limit_ms,
timer_stopped=stopped,
active=item.card_id == self.dds_active_card_id,
))
return StationSnapshot(
services=services,
recipient_services=recipients,
managed_service=services[0] if services else None,
statuses=statuses,
available={service: list(NEXT[status]) for service, status in statuses.items()},
card=card_status(self.status_log, services, notify_deadline_passed=overdue),
card=(card_status(self.status_log, services, notify_deadline_passed=overdue)
if has_active_dds_card or not self.dds_scenarios else CardStatus.REGISTERED),
log=list(self.status_log),
crew_options=self.crew_options(),
crew_selected=self.crew_selected,
phone_reports=list(self.phone_reports),
card_id=self.dispatched_card.card_id if self.dispatched_card else None,
card_index=self.dds_card_index + 1,
crew_options=(self.crew_options() if has_active_dds_card or not self.dds_scenarios else []),
crew_selected=self.crew_selected if has_active_dds_card else None,
phone_reports=list(self.phone_reports) if has_active_dds_card else [],
phone_lines=list(self.phone_lines) if has_active_dds_card else [],
phone_pending=self.phone_pending if has_active_dds_card else None,
card_id=(self.dispatched_card.card_id if self.dispatched_card and has_active_dds_card else None),
card_index=(self.dds_card_index + 1 if has_active_dds_card
else min(self.dds_next_scenario_index + 1, len(self.dds_scenarios))),
card_total=len(self.dds_scenarios) or 1,
reply_text=self.reply_text,
reply_text=self.reply_text if has_active_dds_card else "",
completed_cards=[DdsCardSummary(card_id=item.card_id,
scenario_id=item.scenario_id,
score_auto=item.score_auto)
for item in self.dds_completed],
queue_cards=queue_cards,
pending_cards_count=max(
0, len(self.dds_scenarios) - len(self.dds_completed)
- (1 if has_active_dds_card else 0),
),
next_arrival_in_seconds=(
max(0, int((self.dds_next_arrival_at - now_utc()).total_seconds() + 0.999))
if self.dds_next_arrival_at is not None else None
),
max_waiting_cards=self.dds_max_waiting,
)
def card_received_event(self):
@ -244,6 +442,7 @@ class SessionState:
level=self.level,
mode=self.mode,
exercise=self.exercise,
criteria=self.criteria,
trainee_name=self.trainee_name,
started_at=self.started_at,
kio=self.kio,

View file

@ -6,14 +6,18 @@ onnxruntime и torch отпускают GIL на время вычислений
"""
import asyncio
import io
import logging
import wave
from concurrent.futures import ThreadPoolExecutor
from functools import lru_cache
from pathlib import Path
import numpy as np
import httpx
from app.config import get_settings
from app.dialog.llm import is_loopback_url
from app.voice.text import normalize
log = logging.getLogger(__name__)
@ -51,6 +55,47 @@ class Recognizer:
self.transcribe((rng.normal(0, 0.05, 16_000)).astype(np.float32))
class WhisperRecognizer:
"""Локальный whisper.cpp: GGML Whisper small, без облака и Python-зависимости STT.
VAD уже отделил реплику. Сервер держит модель в памяти, а Python отправляет
ему только 16-кГц WAV через loopback; адрес из конфигурации проверяется.
"""
def __init__(self, server_url: str, client: httpx.Client | None = None) -> None:
if not is_loopback_url(server_url):
raise ValueError("Whisper server должен слушать только loopback")
self._url = server_url.rstrip("/")
self._client = client or httpx.Client(trust_env=False, timeout=30.0)
def transcribe(self, audio: np.ndarray) -> str:
pcm = (np.clip(audio, -1, 1) * 32767).astype("<i2")
with io.BytesIO() as buffer:
with wave.open(buffer, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(16_000)
wav.writeframes(pcm.tobytes())
payload = buffer.getvalue()
response = self._client.post(
self._url + "/inference",
files={"file": ("utterance.wav", payload, "audio/wav")},
data={"language": "ru", "response_format": "json", "temperature": "0",
"temperature_inc": "0", "no_context": "true"},
)
response.raise_for_status()
result = response.json()
if not isinstance(result, dict) or not isinstance(result.get("text"), str):
raise ValueError("некорректный ответ локального Whisper server")
return result["text"].strip()
def warmup(self) -> None:
# GET / отвечает локальная страница server.cpp. Не запускаем тяжёлый
# инференс на тишине: сервер загрузил модель до готовности порта.
response = self._client.get(self._url + "/", timeout=2.0)
response.raise_for_status()
class Synthesizer:
"""Silero v5: около десятой доли длительности фразы на синтез (docs/LATENCY.md)."""
@ -80,7 +125,8 @@ class Synthesizer:
class VoiceModels:
def __init__(self, recognizer: Recognizer, synthesizer: Synthesizer, vad_path: Path) -> None:
def __init__(self, recognizer: Recognizer | WhisperRecognizer, synthesizer: Synthesizer,
vad_path: Path) -> None:
self.recognizer = recognizer
self.synthesizer = synthesizer
self.vad_path = vad_path
@ -100,8 +146,14 @@ def get_voice_models() -> VoiceModels | None:
if not settings.voice_enabled:
return None
models = ROOT / settings.models_dir
stt_path = (ROOT / settings.whisper_model_path / "ggml-small-q5_1.bin"
if settings.stt_model == "whisper-small"
else models / "gigaam-v3-onnx" / "v3_rnnt_encoder.int8.onnx")
if settings.stt_model not in {"whisper-small", "gigaam-v3-rnnt"}:
log.error("неизвестный STT_MODEL: %s", settings.stt_model)
return None
required = [
models / "gigaam-v3-onnx" / "v3_rnnt_encoder.int8.onnx",
stt_path,
models / "silero-vad" / "silero_vad.onnx",
models / "silero-tts" / "v5_ru.pt",
]
@ -110,12 +162,13 @@ def get_voice_models() -> VoiceModels | None:
log.warning("голосовой контур выключен, нет моделей: %s — make models", ", ".join(missing))
return None
try:
recognizer = Recognizer(models / "gigaam-v3-onnx")
recognizer = (WhisperRecognizer(settings.whisper_server_url) if settings.stt_model == "whisper-small"
else Recognizer(models / "gigaam-v3-onnx"))
synthesizer = Synthesizer(models / "silero-tts" / "v5_ru.pt")
# Прогрев здесь, на старте стенда, а не на первой реплике курсанта.
recognizer.warmup()
synthesizer.warmup()
return VoiceModels(recognizer, synthesizer, models / "silero-vad" / "silero_vad.onnx")
except ImportError as exc:
log.warning("голосовой контур выключен: не установлены зависимости (%s) — uv sync --extra voice", exc)
except (ImportError, httpx.HTTPError, ValueError) as exc:
log.warning("голосовой контур выключен: модели/сервер не готовы (%s)", exc)
return None

View file

@ -0,0 +1,81 @@
"""Локальная WAV-запись обеих сторон учебного голосового вызова."""
import os
import time
import wave
from dataclasses import dataclass
from pathlib import Path
from uuid import UUID
import numpy as np
from app.config import get_settings
TARGET_RATE = 16_000
def recording_path(session_id: UUID) -> Path:
return Path(get_settings().recordings_dir).resolve() / f"{session_id}.wav"
@dataclass(frozen=True)
class _Segment:
offset: int
samples: np.ndarray
class CallRecorder:
"""Смешивает PCM16 разных частот на монотаймлайн 16 кГц.
Вход курсанта приходит по 16 кГц, TTS звонящего — по 24 кГц. Метка
monotonic сохраняет паузы и взаимное расположение реплик; системные часы
и изменение времени на хосте на запись не влияют.
"""
def __init__(self, path: Path, *, clock=time.monotonic) -> None:
self.path = path
self._clock = clock
self._started = clock()
self._segments: list[_Segment] = []
self._finalized = False
def add_pcm(self, pcm: bytes, *, sample_rate: int) -> None:
if self._finalized or not pcm or sample_rate <= 0 or len(pcm) % 2:
return
source = np.frombuffer(pcm, dtype="<i2").astype(np.int32)
if source.size == 0:
return
if sample_rate != TARGET_RATE:
length = max(1, round(source.size * TARGET_RATE / sample_rate))
points = np.linspace(0, source.size - 1, length)
source = np.rint(np.interp(points, np.arange(source.size), source)).astype(np.int32)
offset = max(0, round((self._clock() - self._started) * TARGET_RATE))
self._segments.append(_Segment(offset=offset, samples=source))
def finalize(self) -> Path | None:
if self._finalized:
return self.path if self.path.is_file() else None
self._finalized = True
if not self._segments:
return None
total = max(item.offset + item.samples.size for item in self._segments)
mixed = np.zeros(total, dtype=np.int32)
for item in self._segments:
mixed[item.offset:item.offset + item.samples.size] += item.samples
pcm = np.clip(mixed, -32768, 32767).astype("<i2").tobytes()
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_suffix(".wav.tmp")
with wave.open(str(temporary), "wb") as target:
target.setnchannels(1)
target.setsampwidth(2)
target.setframerate(TARGET_RATE)
target.writeframes(pcm)
os.replace(temporary, self.path)
return self.path
def start_recording(session_id: UUID) -> CallRecorder | None:
if not get_settings().record_calls:
return None
return CallRecorder(recording_path(session_id))

View file

@ -13,3 +13,4 @@ os.environ.setdefault("VOICE_ENABLED", "false")
# ждал бы ответа провайдера и зависел от его настроения.
# Живые проверки LLM — в test_llm.py, он читает backend/.env.test.
os.environ.setdefault("LLM_API_KEY", "")
os.environ.setdefault("LLM_PROVIDER", "disabled")

View file

@ -12,6 +12,7 @@ dependencies = [
"alembic>=1.13",
"asyncpg>=0.29",
"httpx>=0.27",
"certifi>=2024",
"pyyaml>=6.0",
"reportlab>=4.0",
"structlog>=24.1",

View file

@ -8,6 +8,7 @@
0 3 * * * cd … && make backup в crontab демо-машины
"""
import os
import sys
from pathlib import Path
@ -18,7 +19,7 @@ from app.admin.backup import BackupError, create, listing # noqa: E402
#: Сколько копий держать. Место на демо-машине не бесконечно, а копия за
#: позапрошлую неделю не нужна никому.
KEEP = 14
KEEP = max(1, int(os.environ.get("BACKUP_KEEP", "14")))
def main() -> int:

View file

@ -0,0 +1,80 @@
"""Автоматическая резервная копия PostgreSQL не реже раза в сутки.
Процесс живёт отдельным Compose-сервисом. Он делает копию сразу, если свежей
нет, затем ориентируется на фактическое время последнего успешного файла.
После ошибки повторяет попытку чаще, но не удаляет существующие копии.
"""
from __future__ import annotations
import logging
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.admin.backup import BackupError, DIR, create, listing # noqa: E402
from app.config import get_settings # noqa: E402
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s backup-scheduler %(message)s",
)
log = logging.getLogger("backup-scheduler")
def seconds_until_due(now: datetime, interval: int) -> float:
copies = listing()
if not copies:
return 0.0
age = (now - copies[0]["at"]).total_seconds()
return max(0.0, interval - age)
def prune(keep: int) -> int:
old = listing()[keep:]
deleted = 0
for item in old:
try:
(DIR / item["name"]).unlink(missing_ok=True)
deleted += 1
except OSError as exc:
# A locked/readonly old file must not take down the daily scheduler.
log.error("не удалось удалить старую копию %s: %s", item["name"], exc)
return deleted
def run_forever() -> None:
settings = get_settings()
interval = max(60, settings.backup_interval_seconds)
retry = max(10, min(settings.backup_retry_seconds, interval))
keep = max(1, settings.backup_keep)
log.info("запущен: интервал=%s с, хранить=%s", interval, keep)
while True:
try:
wait = seconds_until_due(datetime.now(timezone.utc), interval)
except BackupError as exc:
log.error("не удалось проверить резервные копии: %s; повтор через %s с", exc, retry)
time.sleep(retry)
continue
if wait > 0:
time.sleep(wait)
continue
try:
made = create()
deleted = prune(keep)
log.info("готово: %s (%s байт), удалено старых=%s",
made["name"], made["size_bytes"], deleted)
except BackupError as exc:
log.error("цикл резервного копирования не завершён: %s; повтор через %s с", exc, retry)
time.sleep(retry)
continue
time.sleep(interval)
if __name__ == "__main__":
run_forever()

View file

@ -0,0 +1,131 @@
"""Скачать ровно проверенные файлы трёх локальных моделей.
Файлы лежат в backend/models/ (не в Git). Скрипт одинаково работает на
Windows, macOS и Linux; при повторном запуске продолжает неполный файл.
После переноса каталога models/ интернет для занятия не нужен.
"""
import hashlib
import argparse
import shutil
import ssl
import sys
import urllib.request
from dataclasses import dataclass
from pathlib import Path
import certifi
ROOT = Path(__file__).resolve().parents[1]
MODELS = ROOT / "models"
@dataclass(frozen=True)
class Artifact:
repo: str
name: str
target: str
size: int
sha256: str
ARTIFACTS = (
Artifact("Qwen/Qwen3-1.7B-GGUF", "Qwen3-1.7B-Q8_0.gguf",
"qwen3-1.7b/Qwen3-1.7B-Q8_0.gguf", 1834426016,
"061b54daade076b5d3362dac252678d17da8c68f07560be70818cace6590cb1a"),
Artifact("Vikhrmodels/Vikhr-Llama-3.2-1B-instruct-GGUF",
"Vikhr-Llama-3.2-1B-Q4_K_M.gguf",
"vikhr-1b/Vikhr-Llama-3.2-1B-Q4_K_M.gguf", 807694432,
"a85c0ef4b6476ff5fcf2c961226234ab7008d17087c2152416e61a9b7e74b32d"),
Artifact("ggerganov/whisper.cpp", "ggml-small-q5_1.bin",
"whisper-small/ggml-small-q5_1.bin", 190085487,
"ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb"),
)
def digest(path: Path) -> str:
sha = hashlib.sha256()
with path.open("rb") as stream:
while chunk := stream.read(1 << 20):
sha.update(chunk)
return sha.hexdigest()
def verify(item: Artifact) -> None:
"""Проверить перенесённый вес без сети и без изменения файла."""
target = MODELS / item.target
if not target.is_file():
raise RuntimeError(f"нет локального файла: {target}")
if target.stat().st_size != item.size:
raise RuntimeError(
f"неверный размер {item.target}: {target.stat().st_size}, ожидался {item.size} байт"
)
actual = digest(target)
if actual != item.sha256:
raise RuntimeError(f"SHA-256 не совпал для {item.target}: {actual}")
print(f"проверено SHA-256: {item.target}", flush=True)
def download(item: Artifact) -> None:
target = MODELS / item.target
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
try:
verify(item)
except RuntimeError:
pass
else:
print(f"готово: {item.target}", flush=True)
return
partial = target.with_name(target.name + ".part")
offset = partial.stat().st_size if partial.exists() else 0
if offset > item.size:
raise RuntimeError(f"некорректный частичный файл: {partial}")
free = shutil.disk_usage(MODELS if MODELS.exists() else ROOT).free
if free < item.size - offset + 256 * 1024 * 1024:
raise RuntimeError(f"мало свободного места для {item.target}: осталось {free // (1 << 20)} МиБ")
url = f"https://huggingface.co/{item.repo}/resolve/main/{item.name}"
request = urllib.request.Request(url, headers={"Range": f"bytes={offset}-"} if offset else {})
print(f"скачиваю {item.target} ({item.size // (1 << 20)} МиБ)", flush=True)
with urllib.request.urlopen(
request, timeout=120, context=ssl.create_default_context(cafile=certifi.where())
) as response:
# Сервер может проигнорировать Range: тогда начинаем заново, не
# приписываем полный файл к частичному.
resumed = offset > 0 and response.status == 206
with partial.open("ab" if resumed else "wb") as out:
count = offset if resumed else 0
while chunk := response.read(1 << 20):
out.write(chunk)
count += len(chunk)
if count % (64 << 20) < len(chunk) or count == item.size:
print(f" {count // (1 << 20)}/{item.size // (1 << 20)} МиБ", flush=True)
if partial.stat().st_size != item.size or digest(partial) != item.sha256:
raise RuntimeError(f"контроль размера/SHA-256 не пройден: {partial}")
partial.replace(target)
print(f"проверено SHA-256: {item.target}", flush=True)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--verify-only", action="store_true",
help="проверить уже загруженные/перенесённые веса без сети и без изменения файлов",
)
args = parser.parse_args()
try:
for item in ARTIFACTS:
verify(item) if args.verify_only else download(item)
except (OSError, RuntimeError) as exc:
action = "проверка прервана" if args.verify_only else "загрузка прервана"
print(f"{action}: {exc}", file=sys.stderr)
return 1
if args.verify_only:
print("Все три локальные модели прошли офлайн-проверку; файлы не изменялись.")
else:
print("Все три локальные модели проверены. Для голоса нужны также whisper-server, VAD и TTS.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,76 +1,43 @@
"""make llm-check: один запрос к LLM, чтобы убедиться, что ключ и адрес рабочие.
"""Проверка активного диалогового профиля через тот же клиент, что и занятие.
Ключ берётся из backend/.env и никуда не печатается. Провайдер отвечает с машины
разработки напрямую; таймаут почти всегда означает не отказ провайдера, а окружение —
VPN-туннель или песочница, через которые российские адреса не проходят. Поэтому
проверять отсюда, а не из обёрток, и с той машины, на которой пойдёт занятие.
В локальном режиме ключ не нужен; OFFLINE=true запрещает запрос наружу.
"""
import asyncio
import sys
import time
from pathlib import Path
import httpx
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.config import get_settings # noqa: E402
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable # noqa: E402
def main() -> int:
async def check() -> int:
settings = get_settings()
if not settings.llm_api_key:
print("в backend/.env нет LLM_API_KEY")
return 1
print(f"адрес: {settings.llm_base_url}")
print(f"модель: {settings.llm_model_caller}")
body = {
"model": settings.llm_model_caller,
"messages": [
{"role": "system", "content": "Ты звонящий в службу 112, у тебя горит балкон. "
"Ответь одной короткой фразой, в панике."},
{"role": "user", "content": "Служба 112, что у вас случилось?"},
],
"max_tokens": 60,
"temperature": 0.8,
}
control = settings.dialogue_model_mode == "russian_control"
model = settings.llm_model_control if control else settings.llm_model_caller
address = settings.llm_control_base_url if control else settings.llm_base_url
print(f"адрес: {address}\nмодель: {model}")
client = LlmClient(base_url=address, timeout=30)
started = time.monotonic()
try:
response = httpx.post(
f"{settings.llm_base_url}/chat/completions",
headers={"Authorization": f"Bearer {settings.llm_api_key}"},
json=body,
timeout=60,
)
except httpx.HTTPError as exc:
print(f"\nсеть: {type(exc).__name__} — до провайдера не достучались.")
print("Это чаще про окружение, чем про провайдера: проверь, что российские")
print("адреса идут мимо VPN, и повтори с самой машины стенда.")
return 2
elapsed = time.monotonic() - started
print(f"\nHTTP {response.status_code}, {elapsed:.2f} с")
if response.status_code == 200:
data = response.json()
print("ответ модели:", data["choices"][0]["message"]["content"].strip())
usage = data.get("usage", {})
print("токены:", usage.get("prompt_tokens"), "→", usage.get("completion_tokens"))
print("\nКЛЮЧ РАБОТАЕТ")
return 0
print("ответ сервера:", response.text[:400])
if response.status_code in (401, 403):
print("\nСеть в порядке, но ключ не принят: возможно, его нужно менять "
"на временный токен — сверься с консолью Cloud.ru.")
elif response.status_code == 404:
print("\nКлюч принят, но такой модели нет: проверь LLM_MODEL_CALLER.")
return 3
reply = await client.complete(LlmRequest(
model=model,
messages=[{"role": "user", "content": "Ответь одним словом по-русски: работает /no_think"}],
temperature=0,
max_tokens=32,
), use_cache=False)
except LlmUnavailable as exc:
print(f"модель недоступна: {exc}")
return 1
finally:
await client.aclose()
print(f"ответ за {time.monotonic() - started:.2f} с: {reply}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(asyncio.run(check()))

View file

@ -0,0 +1,118 @@
"""Запуск двух локальных GGUF-серверов через llama.cpp на Windows/macOS/Linux.
Никаких загрузок при старте: GGUF предварительно кладутся в models/ через
download_local_models.py. Путь к llama-server задаётся LLAMA_SERVER_BIN либо
берётся из PATH. Ctrl+C останавливает дочерние процессы.
"""
import argparse
import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PROFILES = {
"dialogue": ("qwen3-1.7b/Qwen3-1.7B-Q8_0.gguf", "Qwen3-1.7B", 18080),
"russian_control": ("vikhr-1b/Vikhr-Llama-3.2-1B-Q4_K_M.gguf", "Vikhr-1B", 18081),
}
def binary_path() -> str:
bundled_mac = ROOT / "models" / "bin" / "llama-b10934" / "llama-server"
binary = (os.environ.get("LLAMA_SERVER_BIN") or shutil.which("llama-server")
or shutil.which("llama-server.exe")
# The checked-out helper is Mach-O arm64. Do not select it on a
# Windows machine merely because the whole models/ folder was
# copied there for its platform-neutral GGUF weights.
or (str(bundled_mac) if sys.platform == "darwin" and bundled_mac.is_file() else None))
if not binary:
raise RuntimeError("нет llama-server; установите бинарник llama.cpp и задайте LLAMA_SERVER_BIN")
if not Path(binary).is_file():
raise RuntimeError(f"llama-server не найден: {binary}")
return binary
def command(binary: str, profile: str, threads: int) -> list[str]:
relative, alias, port = PROFILES[profile]
model = ROOT / "models" / relative
if not model.is_file():
raise RuntimeError(f"нет модели: {model} — запустите make local-models")
result = [binary, "-m", str(model), "--alias", alias,
"--host", "127.0.0.1", "--port", str(port),
"--ctx-size", "2048", "--threads", str(threads), "--parallel", "1",
"--cors-origins", "localhost"]
if profile == "dialogue":
result += ["--reasoning-budget", "0"]
return result
def ready(port: int) -> bool:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=1) as response:
return response.status == 200
except (OSError, urllib.error.HTTPError):
return False
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--mode", choices=["dialogue", "russian_control", "both"], default="dialogue")
parser.add_argument("--threads", type=int, default=4)
args = parser.parse_args()
if args.threads < 1:
parser.error("--threads должен быть положительным")
selected = list(PROFILES) if args.mode == "both" else [args.mode]
try:
binary = binary_path()
commands = [command(binary, profile, args.threads) for profile in selected]
except RuntimeError as exc:
print(exc, file=sys.stderr)
return 2
processes: list[subprocess.Popen] = []
try:
for profile, argv in zip(selected, commands):
port = PROFILES[profile][2]
if ready(port):
raise RuntimeError(f"порт {port} уже занят сервером — не запускаю дубликат")
print(f"запускаю {profile} на 127.0.0.1:{port}", flush=True)
# Ctrl+C должен достаться управляющему процессу один раз: он сам
# остановит дочерний сервер. Иначе llama.cpp получает двойной
# SIGINT и на Metal иногда падает во время освобождения памяти.
flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
processes.append(subprocess.Popen(
argv, cwd=ROOT, creationflags=flags, start_new_session=os.name != "nt"
))
deadline = time.monotonic() + 120
while time.monotonic() < deadline:
if any(process.poll() is not None for process in processes):
raise RuntimeError("один из серверов модели завершился до готовности")
if all(ready(PROFILES[profile][2]) for profile in selected):
print("локальные модели готовы; Ctrl+C остановит их", flush=True)
while all(process.poll() is None for process in processes):
time.sleep(0.5)
raise RuntimeError("один из серверов модели неожиданно завершился")
time.sleep(0.5)
raise RuntimeError("модели не стали готовы за 120 секунд")
except KeyboardInterrupt:
return 0
except RuntimeError as exc:
print(exc, file=sys.stderr)
return 1
finally:
for process in processes:
if process.poll() is None:
process.terminate()
for process in processes:
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,84 @@
"""Локальный whisper.cpp server для Whisper small (macOS / Windows / Linux).
Сначала скачайте GGML-вес через download_local_models.py. Исполняемый файл
задаётся WHISPER_SERVER_BIN или ищется в PATH / локальной сборке. Сеть не нужна.
"""
import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MODEL = ROOT / "models" / "whisper-small" / "ggml-small-q5_1.bin"
PORT = 18082
def binary_path() -> str:
built = ROOT / "models" / "bin" / "whisper.cpp-1.9.4" / "build" / "bin" / "whisper-server"
binary = (os.environ.get("WHISPER_SERVER_BIN") or shutil.which("whisper-server")
or shutil.which("whisper-server.exe")
# The in-tree build is macOS arm64, not a portable executable.
or (str(built) if sys.platform == "darwin" and built.is_file() else None))
if not binary or not Path(binary).is_file():
raise RuntimeError("нет whisper-server; установите бинарник whisper.cpp и задайте WHISPER_SERVER_BIN")
return binary
def command(binary: str, threads: int = 4) -> list[str]:
if not MODEL.is_file():
raise RuntimeError(f"нет модели: {MODEL} — запустите make local-models")
return [binary, "-m", str(MODEL), "--host", "127.0.0.1", "--port", str(PORT),
"--language", "ru", "--threads", str(threads), "--no-fallback"]
def ready() -> bool:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1) as response:
return response.status == 200
except (OSError, urllib.error.HTTPError):
return False
def main() -> int:
try:
if ready():
raise RuntimeError(f"порт {PORT} уже занят сервером")
argv = command(binary_path())
except RuntimeError as exc:
print(exc, file=sys.stderr)
return 2
flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
process = subprocess.Popen(argv, cwd=ROOT, creationflags=flags, start_new_session=os.name != "nt")
try:
deadline = time.monotonic() + 120
while time.monotonic() < deadline:
if process.poll() is not None:
raise RuntimeError("whisper-server завершился до готовности")
if ready():
print(f"Whisper small готов на 127.0.0.1:{PORT}; Ctrl+C остановит сервер", flush=True)
while process.poll() is None:
time.sleep(0.5)
raise RuntimeError("whisper-server неожиданно завершился")
time.sleep(0.5)
raise RuntimeError("Whisper small не стал готов за 120 секунд")
except KeyboardInterrupt:
return 0
except RuntimeError as exc:
print(exc, file=sys.stderr)
return 1
finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,15 +1,21 @@
"""make models: веса моделей в backend/models/.
"""make models: вспомогательные веса голоса в backend/models/.
Эмбеддинги, распознавание (GigaAM v3, int8) и VAD качаются с Hugging Face.
Эмбеддинги и VAD качаются с Hugging Face. Whisper small, Qwen3 и Vikhr
скачиваются отдельным `make local-models` с проверкой SHA-256. Старый GigaAM
доступен через `--legacy-gigaam`, но в выбранный стек больше не входит.
Синтез (Silero TTS v5) лежит на models.silero.ai — российском хосте, который
не отвечает из-под VPN: его скрипт не качает, а проверяет и говорит, что делать.
Докачка продолжается с места обрыва: сеть на стенде бывает медленной.
"""
import argparse
import ssl
import sys
import urllib.request
from pathlib import Path
import certifi
ROOT = Path(__file__).resolve().parents[1]
MODELS = ROOT / "models"
@ -20,16 +26,16 @@ FILES = {
"e5-small/config.json": E5 + "config.json",
"e5-small/tokenizer.json": E5 + "tokenizer.json",
"e5-small/model_quantized.onnx": E5 + "onnx/model_quantized.onnx",
# RNNT — основная модель, CTC — запасная на случай, если RNNT не загрузится
# (по скорости CTC не выигрывает — docs/LATENCY.md).
"silero-vad/config.json": VAD + "config.json",
"silero-vad/silero_vad.onnx": VAD + "silero_vad.onnx",
}
LEGACY_GIGAAM_FILES = {
"gigaam-v3-onnx/config.json": GIGAAM + "config.json",
"gigaam-v3-onnx/v3_vocab.txt": GIGAAM + "v3_vocab.txt",
"gigaam-v3-onnx/v3_rnnt_encoder.int8.onnx": GIGAAM + "v3_rnnt_encoder.int8.onnx",
"gigaam-v3-onnx/v3_rnnt_decoder.int8.onnx": GIGAAM + "v3_rnnt_decoder.int8.onnx",
"gigaam-v3-onnx/v3_rnnt_joint.int8.onnx": GIGAAM + "v3_rnnt_joint.int8.onnx",
"gigaam-v3-onnx/v3_ctc.int8.onnx": GIGAAM + "v3_ctc.int8.onnx",
"silero-vad/config.json": VAD + "config.json",
"silero-vad/silero_vad.onnx": VAD + "silero_vad.onnx",
}
SILERO_TTS = "silero-tts/v5_ru.pt"
@ -40,19 +46,27 @@ def fetch(url: str, target: Path) -> None:
partial = target.with_suffix(target.suffix + ".part")
offset = partial.stat().st_size if partial.exists() else 0
request = urllib.request.Request(url, headers={"Range": f"bytes={offset}-"} if offset else {})
with urllib.request.urlopen(request, timeout=60) as response, partial.open("ab") as out:
total = offset + int(response.headers.get("Content-Length", 0))
while chunk := response.read(1 << 20):
out.write(chunk)
done = out.tell()
if total:
print(f"\r {target.name}: {done // (1 << 20)} из {total // (1 << 20)} МБ", end="", flush=True)
with urllib.request.urlopen(
request, timeout=60, context=ssl.create_default_context(cafile=certifi.where())
) as response:
resumed = offset > 0 and response.status == 206
with partial.open("ab" if resumed else "wb") as out:
total = (offset if resumed else 0) + int(response.headers.get("Content-Length", 0))
while chunk := response.read(1 << 20):
out.write(chunk)
done = out.tell()
if total:
print(f"\r {target.name}: {done // (1 << 20)} из {total // (1 << 20)} МБ", end="", flush=True)
partial.rename(target)
print()
def main() -> None:
for relative, url in FILES.items():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--legacy-gigaam", action="store_true")
args = parser.parse_args()
files = {**FILES, **(LEGACY_GIGAAM_FILES if args.legacy_gigaam else {})}
for relative, url in files.items():
target = MODELS / relative
if target.exists():
print(f" {relative}: уже есть")

View file

@ -54,7 +54,10 @@ async def main() -> None:
slots = SlotMachine(scenario, embedder)
persona = PersonaState(scenario.persona)
caller = build_caller(scenario.id)
caller = build_caller(
scenario.id,
use_pregenerated=scenario.tree.pregenerated,
)
print(f"── {scenario.title} ({scenario.level.value}) ──")
print("Вы — оператор 112. Команды: /подсказка /факты /итог /выход\n")

View file

@ -0,0 +1,40 @@
"""Проверка локального TTS → Whisper small без микрофона и внешней сети.
Это не замер сквозной задержки звонка: TTS создаёт синтетический образец, а
загрузка моделей оплачивается отдельно. На Windows выполнить ту же команду.
"""
import time
import numpy as np
from scipy.signal import resample_poly
from app.voice.models import Synthesizer, WhisperRecognizer
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PHRASE = "Пожар на улице Ленина, дом четырнадцать. Нужна помощь."
def main() -> None:
started = time.monotonic()
tts = Synthesizer(ROOT / "models" / "silero-tts" / "v5_ru.pt")
print(f"TTS загружен за {time.monotonic() - started:.2f} с", flush=True)
started = time.monotonic()
pcm = tts.synthesize(PHRASE)
audio = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768
audio = resample_poly(audio, 2, 3).astype(np.float32)
print(f"TTS: {time.monotonic() - started:.2f} с, звук {len(audio) / 16000:.2f} с", flush=True)
started = time.monotonic()
stt = WhisperRecognizer("http://127.0.0.1:18082")
stt.warmup()
print(f"Whisper server готов за {time.monotonic() - started:.2f} с", flush=True)
started = time.monotonic()
answer = stt.transcribe(audio)
print(f"Whisper: {time.monotonic() - started:.2f} с", flush=True)
print(f"ожидалось: {PHRASE}\nполучено: {answer}", flush=True)
if __name__ == "__main__":
main()

View file

@ -30,7 +30,7 @@ from app.domain.roles import Role # noqa: E402
PEOPLE = [
("admin", "Администратор стенда", Role.ADMIN, None),
("teacher", "Преподаватель", Role.INSTRUCTOR, None),
("trainee", "Курсант", Role.TRAINEE, "ДДС района"),
("trainee", "Курсант", Role.TRAINEE, "Служба 101"),
]

View file

@ -5,6 +5,7 @@
"""
import uuid
from xml.etree import ElementTree as ET
import pytest
from fastapi.testclient import TestClient
@ -61,12 +62,29 @@ def test_instructor_cannot_open_admin(as_instructor):
assert as_instructor.get("/api/admin/users").status_code == 403
assert as_instructor.get("/api/admin/audit").status_code == 403
assert as_instructor.get("/api/admin/status").status_code == 403
assert as_instructor.get("/api/admin/diagnostics").status_code == 403
assert as_instructor.get("/api/admin/config.xml").status_code == 403
def test_anonymous_cannot_open_admin(client):
assert client.get("/api/admin/users").status_code == 401
def test_admin_downloads_safe_xml_configuration(as_admin):
response = as_admin.get("/api/admin/config.xml")
assert response.status_code == 200
assert response.headers["content-type"].startswith("application/xml")
assert "attachment" in response.headers["content-disposition"]
root = ET.fromstring(response.content)
assert root.tag == "lctConfiguration"
assert root.find("./workstations/workstation[@role='admin']") is not None
assert root.find("./timerLimits/timer[@code='dds_ack']") is not None
lowered = response.content.lower()
assert b"session_secret" not in lowered
assert b"llm_api_key" not in lowered
assert b"database_url" not in lowered
def test_admin_has_no_way_to_change_a_score():
"""Запрет выражен отсутствием точки, а не проверкой внутри неё: в модуле
администратора нет ни одного маршрута, трогающего оценки."""
@ -90,6 +108,39 @@ def test_admin_cannot_write_to_the_audit_log():
]
def test_audit_api_applies_actor_action_and_offset_filters(as_admin):
"""The admin UI must be able to reach older records, not just page zero."""
from app.api.http import admin as admin_module
class FakeDb:
statement = None
async def scalars(self, statement):
self.statement = statement
return []
fake_db = FakeDb()
async def fake_session():
yield fake_db
app.dependency_overrides[admin_module.get_session] = fake_session
try:
response = as_admin.get(
"/api/admin/audit",
params={"action": "login.failed", "actor": "alice", "limit": 201, "offset": 400},
)
finally:
app.dependency_overrides.pop(admin_module.get_session, None)
assert response.status_code == 200, response.text
statement = fake_db.statement
assert statement._limit_clause.value == 201
assert statement._offset_clause.value == 400
assert "audit_log.action" in str(statement.whereclause)
assert "audit_log.actor" in str(statement.whereclause)
# ── учётные записи ──
@ -170,7 +221,39 @@ def test_status_names_every_component(as_admin):
names = {item["name"] for item in as_admin.get("/api/admin/status").json()}
assert {"База данных", "Модели речи", "Эмбеддинги", "Провайдер LLM",
"Классификатор ЕКП", "Секрет сессии"} <= names
"Классификатор ЕКП", "Резервное копирование", "Секрет сессии",
"Нагрузка backend"} <= names
def test_admin_gets_live_metrics_and_downloadable_failure_report(as_admin):
if not db_alive(as_admin):
pytest.skip("нет базы")
response = as_admin.get("/api/admin/diagnostics")
assert response.status_code == 200, response.text
body = response.json()
assert body["metrics"]["cpu_cores"] >= 1
assert body["metrics"]["disk_total_bytes"] > body["metrics"]["disk_free_bytes"]
assert body["metrics"]["active_sessions"] >= 0
assert isinstance(body["recent_system_events"], list)
assert isinstance(body["failed_actions_24h"], list)
download = as_admin.get("/api/admin/diagnostics.json")
assert download.status_code == 200
assert "attachment" in download.headers["content-disposition"]
def test_diagnostic_journal_redacts_credentials():
import logging
from app.monitoring import recent_events
logging.getLogger("app.redaction-test").warning(
"failure postgresql://user:secret@db/lct SESSION_SECRET=never-show"
)
event = recent_events(limit=1)[0]
assert "user:secret@" not in event["message"]
assert "never-show" not in event["message"]
def test_default_session_secret_is_reported_as_a_problem(as_admin):
@ -191,6 +274,32 @@ def test_backup_listing_is_open_to_admin(as_admin):
assert as_admin.get("/api/admin/backups").status_code == 200
def test_backup_listing_failure_does_not_break_admin_status(as_admin, monkeypatch):
from app.admin import backup as backup_service
def broken():
raise backup_service.BackupError("каталог временно недоступен")
monkeypatch.setattr(backup_service, "listing", broken)
response = as_admin.get("/api/admin/status")
assert response.status_code == 200, response.text
backup = next(item for item in response.json() if item["name"] == "Резервное копирование")
assert not backup["ok"]
assert "каталог временно недоступен" in backup["detail"]
def test_backup_list_returns_503_on_storage_error(as_admin, monkeypatch):
from app.admin import backup as backup_service
def broken():
raise backup_service.BackupError("каталог временно недоступен")
monkeypatch.setattr(backup_service, "listing", broken)
response = as_admin.get("/api/admin/backups")
assert response.status_code == 503
assert "каталог временно недоступен" in response.json()["detail"]
def test_backup_failure_explains_what_is_missing(as_admin, monkeypatch):
"""Кнопка не должна молча ничего не делать: если снять копию нечем,
администратор видит, чего именно не хватает."""
@ -203,3 +312,126 @@ def test_backup_failure_explains_what_is_missing(as_admin, monkeypatch):
response = as_admin.post("/api/admin/backups")
assert response.status_code == 503
assert "pg_dump" in response.json()["detail"]
def test_pg17_transaction_timeout_is_removed_from_pg16_backup(monkeypatch, tmp_path):
"""Образ backend может содержать клиент новее сервера из Compose."""
from app.admin import backup as backup_service
monkeypatch.setattr(backup_service, "DIR", tmp_path)
monkeypatch.setattr(backup_service, "_dsn", lambda: ("u", "p", "db", "5432", "lct"))
monkeypatch.setattr(backup_service.shutil, "which", lambda name: "/usr/bin/pg_dump")
class Result:
returncode = 0
stderr = b""
stdout = (b"--\n-- PostgreSQL database dump\n--\n\n"
b"SET transaction_timeout = 0;\nCREATE TABLE demo(id integer);\n")
monkeypatch.setattr(backup_service.subprocess, "run", lambda *args, **kwargs: Result())
made = backup_service.create()
content = (tmp_path / made["name"]).read_bytes()
assert b"transaction_timeout" not in content
assert b"CREATE TABLE demo" in content
def test_docker_backup_does_not_put_database_password_in_process_arguments(monkeypatch, tmp_path):
import base64
from types import SimpleNamespace
from app.admin import backup as backup_service
password = "secret p@ss:word"
monkeypatch.setattr(backup_service, "DIR", tmp_path)
monkeypatch.setattr(backup_service, "_dsn", lambda: ("backup", password, "db", "5432", "lct"))
monkeypatch.setattr(
backup_service.shutil, "which", lambda name: "/usr/bin/docker" if name == "docker" else None
)
captured = {}
def fake_run(command, **kwargs):
captured["command"] = command
captured["input"] = kwargs["input"]
return SimpleNamespace(
returncode=0,
stderr=b"",
stdout=b"--\n-- PostgreSQL database dump\nCREATE TABLE demo(id integer);\n",
)
monkeypatch.setattr(backup_service.subprocess, "run", fake_run)
backup_service.create()
assert password not in " ".join(captured["command"])
assert captured["input"] == base64.b64encode(password.encode()) + b"\n"
def test_backup_dsn_decodes_escaped_credentials_without_exposing_them(monkeypatch):
from types import SimpleNamespace
from app.admin import backup as backup_service
monkeypatch.setattr(
backup_service, "get_settings",
lambda: SimpleNamespace(database_url="postgresql://backup:p%40ss%3Aword@db.example:5433/lct"),
)
assert backup_service._dsn() == ("backup", "p@ss:word", "db.example", "5433", "lct")
monkeypatch.setattr(
backup_service, "get_settings",
lambda: SimpleNamespace(database_url="not-a-valid-url-with-secret"),
)
try:
backup_service._dsn()
except backup_service.BackupError as exc:
assert "secret" not in str(exc)
else:
raise AssertionError("invalid DATABASE_URL must be rejected")
def test_backup_directory_failure_is_retryable_backup_error(monkeypatch, tmp_path):
from app.admin import backup as backup_service
blocker = tmp_path / "not-a-directory"
blocker.write_text("occupied")
monkeypatch.setattr(backup_service, "DIR", blocker / "backups")
monkeypatch.setattr(backup_service, "_dsn", lambda: ("u", "p", "db", "5432", "lct"))
with pytest.raises(backup_service.BackupError, match="каталог резервных копий"):
backup_service.create()
def test_backup_listing_io_failure_is_retryable_error(monkeypatch, tmp_path):
from app.admin import backup as backup_service
monkeypatch.setattr(backup_service, "DIR", tmp_path)
def denied(_path):
raise PermissionError("access denied")
monkeypatch.setattr(backup_service.os, "scandir", denied)
with pytest.raises(backup_service.BackupError, match="прочитать каталог"):
backup_service.listing()
def test_windows_backup_permissions_use_explicit_private_acl(monkeypatch, tmp_path):
from types import SimpleNamespace
from app.admin import backup as backup_service
monkeypatch.setattr(backup_service, "getuser", lambda: "operator")
monkeypatch.setenv("USERDOMAIN", "TRAINING")
monkeypatch.setenv("SystemRoot", str(tmp_path))
called = {}
def fake_run(command, **kwargs):
called["command"] = command
called["kwargs"] = kwargs
return SimpleNamespace(returncode=0)
monkeypatch.setattr(backup_service.subprocess, "run", fake_run)
backup_service._restrict_file_permissions(-1, tmp_path / "copy.sql", platform_name="nt")
command = called["command"]
assert command[0].endswith("icacls.exe")
assert "/inheritance:r" in command
assert "TRAINING\\operator:(F)" in command
assert "*S-1-5-18:(F)" in command
assert called["kwargs"]["timeout"] == 15

View file

@ -56,6 +56,17 @@ def test_unknown_login_and_wrong_password_look_the_same(client):
assert first.json()["detail"] == "bad_credentials"
def test_login_rejects_values_outside_database_and_hash_bounds(client):
too_long_login = client.post(
"/api/auth/login", json={"login": "a" * 81, "password": "not-used"}
)
too_long_password = client.post(
"/api/auth/login", json={"login": "operator", "password": "x" * 1025}
)
assert too_long_login.status_code == 422
assert too_long_password.status_code == 422
def test_me_requires_authentication(client):
assert client.get("/api/auth/me").status_code == 401

View file

@ -18,6 +18,10 @@ from app.session.hub import hub
@pytest.fixture
def client():
# Each TestClient represents a fresh backend process. In particular,
# revocations exercised by a previous test must not leak into the next
# process' in-memory cache.
auth.prime_generations({})
with TestClient(app) as test_client:
hub.journal = None
yield test_client
@ -49,6 +53,12 @@ def test_cookie_from_previous_process_is_rejected(client, monkeypatch):
assert client.get("/api/auth/me").status_code == 401
def test_cookie_survives_generation_cache_reload_when_account_is_unchanged(client):
assert client.post("/api/auth/dev-token").status_code == 200
auth.prime_generations({"dev": 0})
assert client.get("/api/auth/me").status_code == 200
class FakeDb:
def __init__(self, user):
self.user = user
@ -74,7 +84,7 @@ def fake_user(login="victim", role="instructor"):
return SimpleNamespace(
id=uuid4(), login=login, full_name="Проверка", role=role,
service=None, trainee_id=None, blocked=False,
password_hash="old", created_at=datetime.now(timezone.utc),
password_hash="old", auth_version=0, created_at=datetime.now(timezone.utc),
)
@ -86,7 +96,10 @@ async def test_admin_patch_revokes_cookie_after_commit(monkeypatch):
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
login="admin", full_name="Администратор", role=Role.ADMIN,
))
monkeypatch.setattr(admin, "invalidate_login", lambda login: calls.append((login, db.commits)))
monkeypatch.setattr(
admin, "invalidate_login",
lambda login, version=None: calls.append((login, version, db.commits)),
)
async def no_audit(*_args, **_kwargs):
return None
@ -94,7 +107,7 @@ async def test_admin_patch_revokes_cookie_after_commit(monkeypatch):
monkeypatch.setattr(admin, "audit", no_audit)
await admin.patch_user(user.id, admin.UserPatch(blocked=True), object(), db)
assert user.blocked is True
assert calls == [("victim", 1)]
assert calls == [("victim", 1, 1)]
@pytest.mark.asyncio

View file

@ -0,0 +1,20 @@
from datetime import datetime, timedelta, timezone
from scripts import backup_loop
def test_no_backup_is_due_immediately(monkeypatch):
monkeypatch.setattr(backup_loop, "listing", lambda: [])
assert backup_loop.seconds_until_due(datetime.now(timezone.utc), 86_400) == 0
def test_fresh_backup_waits_only_for_remaining_interval(monkeypatch):
now = datetime.now(timezone.utc)
monkeypatch.setattr(backup_loop, "listing", lambda: [{"at": now - timedelta(hours=6)}])
assert backup_loop.seconds_until_due(now, 86_400) == 18 * 3600
def test_overdue_backup_is_due_immediately(monkeypatch):
now = datetime.now(timezone.utc)
monkeypatch.setattr(backup_loop, "listing", lambda: [{"at": now - timedelta(days=2)}])
assert backup_loop.seconds_until_due(now, 86_400) == 0

View file

@ -160,6 +160,11 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
station.send_json({"type": "phone.dial"})
assert read_until(station, "phone.line")["speaker"] == "crew"
read_until(station, "station.state")
station.send_json({"type": "phone.brief", "address": "улица Ленина, 14",
"incident": "Пожар в квартире",
"request": "Прошу подтвердить выезд и доложить о прибытии"})
assert read_until(station, "phone.report")["phase"] == "dispatched"
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": service, "status": "responding"})
@ -170,7 +175,8 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
score = wait_for(lambda: hub.get(session_id).score)
assert hub.get(session_id).ended
keys = {item["key"] for item in score["metrics"]}
assert {"address", "incident_signs", "dds_primary", "dds_contact"} <= keys
assert {"address", "incident_signs", "dds_primary", "dds_progress"} <= keys
assert "dds_contact" not in keys
assert score["score_auto"] < 100
finally:
control.__exit__(None, None, None)

View file

@ -1,13 +1,14 @@
"""Готовая карточка → учебный звонок бригаде → числовая оценка ДДС."""
import time
from datetime import datetime
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.domain.timers import TimerCode
from app.main import app
from app.session.hub import hub
@ -37,16 +38,39 @@ def read_until(socket, wanted):
raise AssertionError(f"событие {wanted} не пришло")
def start(client, exercise="dds"):
def start(client, exercise="dds", criteria=None, dds_service=None, scenario_id="fire-apartment-l2"):
session_id = uuid4()
context = client.websocket_connect(f"/ws/control/{session_id}")
control = context.__enter__()
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов", "mode": "training", "exercise": exercise})
payload = {"type": "scenario.start", "scenario_id": scenario_id,
"trainee": "Иванов", "mode": "training", "exercise": exercise}
if criteria is not None:
payload["criteria"] = criteria
if dds_service is not None:
payload["dds_service"] = dds_service
control.send_json(payload)
wait_for(lambda: hub.get(session_id))
return session_id, context
def complete_phone_call(station, state, expected_phase):
station.send_json({"type": "phone.dial"})
greeting = read_until(station, "phone.line")
assert greeting["speaker"] == "crew"
read_until(station, "station.state")
if expected_phase == "dispatched":
station.send_json({"type": "phone.brief", "address": state.dispatched_card.address,
"incident": state.scenario_title,
"request": "Прошу подтвердить выезд и сообщить о прибытии"})
else:
station.send_json({"type": "phone.check", "text": "Сообщите текущую обстановку по карточке"})
assert read_until(station, "phone.line")["speaker"] == "dispatcher"
assert read_until(station, "phone.line")["speaker"] == "crew"
report = read_until(station, "phone.report")
assert report["phase"] == expected_phase
return report
def test_dds_starts_with_prepared_card_without_call(client):
session_id, control = start(client)
try:
@ -62,10 +86,125 @@ def test_dds_starts_with_prepared_card_without_call(client):
assert card["card"]["address"] == "улица Ленина, 14"
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["crew_options"]
assert len(snapshot["services"]) == 1
assert snapshot["managed_service"] == snapshot["services"][0]
assert set(snapshot["services"]) < set(snapshot["recipient_services"])
finally:
control.__exit__(None, None, None)
def test_dds_call_reports_gate_status_and_affect_numeric_score(client):
def test_ticket_dds_card_uses_source_caller_identity_and_phone(client):
session_id, control = start(client, scenario_id="t01-1-fire-container")
try:
state = hub.get(session_id)
assert state.kio.caller_name == "Сидоров Иван Сергеевич"
assert state.kio.caller_number == "916-126-34-71"
assert state.kio.caller_contact == "916-126-34-71"
with client.websocket_connect(f"/ws/station/{session_id}") as station:
card = read_until(station, "card.received")["card"]
assert card["caller_name"] == "Сидоров Иван Сергеевич"
assert card["caller_number"] == "916-126-34-71"
assert card["caller_contact"] == "916-126-34-71"
finally:
control.__exit__(None, None, None)
def test_ticket_with_multiple_witnesses_does_not_guess_caller_name(client):
session_id, control = start(client, scenario_id="t20-1-three-fighting")
try:
state = hub.get(session_id)
assert state.kio.caller_name is None
assert state.kio.caller_number == "916-123-98-78"
assert state.kio.caller_contact == "916-123-98-78"
finally:
control.__exit__(None, None, None)
def test_dds_can_change_only_its_own_service_status(client):
session_id, control = start(client, dds_service="МВД")
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["services"] == ["МВД"]
assert "Служба 101" in snapshot["recipient_services"]
station.send_json({
"type": "card.status", "service": "Служба 101", "status": "accepted",
})
assert "только своей ДДС" in read_until(station, "error")["message"]
station.send_json({
"type": "card.status", "service": "МВД", "status": "accepted",
})
assert read_until(station, "station.state")["snapshot"]["statuses"]["МВД"] == "accepted"
finally:
control.__exit__(None, None, None)
def test_instructor_criteria_change_timer_and_success_threshold(client):
session_id, control = start(client, criteria={
"decision_time_limit_seconds": 45,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
})
try:
state = hub.get(session_id)
assert state.criteria.decision_time_limit_seconds == 45
assert state.timers.limits[TimerCode.DDS_ACK] == 45_000
with client.websocket_connect(f"/ws/station/{session_id}") as station:
card = read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "card.reply", "card_id": card["card"]["card_id"],
"text": "Сообщение приняты, бригада направлено."})
read_until(station, "station.state")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
ack = next(item for item in score["metrics"] if item["key"] == "dds_ack")
assert ack["norm"] == "≤ 45 с"
assert ack["weight"] == 3.5
assert not any(item["key"] in {"dds_reply", "dds_grammar"} for item in score["metrics"])
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["criteria"] == {
"decision_time_limit_seconds": 45,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
}
assert report["failed_metrics"] > 0
assert report["passed"] is True
finally:
control.__exit__(None, None, None)
def test_dds_statuses_do_not_require_phone_reports(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": service, "status": "responding"})
assert read_until(station, "station.state")["snapshot"]["statuses"][service] == "responding"
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
keys = {metric["key"] for metric in score["metrics"]}
assert "dds_primary" in keys and "dds_progress" in keys
assert "dds_contact" not in keys and "dds_crew" not in keys
assert "answer_time" not in keys and "interview_time" not in keys
assert 0 < score["score_auto"] < 100
assert all(not finding["code"].startswith("E") for finding in score["findings"])
finally:
control.__exit__(None, None, None)
def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
@ -75,25 +214,35 @@ def test_dds_call_reports_gate_status_and_affect_numeric_score(client):
crew = next(option for option in snapshot["crew_options"] if option.startswith(service + " — "))
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": service, "status": "responding"})
assert "доклада" in read_until(station, "error")["message"]
station.send_json({"type": "crew.select", "crew": crew})
selected = read_until(station, "station.state")["snapshot"]
assert selected["crew_selected"] == crew
station.send_json({"type": "phone.dial"})
report = read_until(station, "phone.report")
assert report["phase"] == "dispatched" and report["crew"] == crew
assert read_until(station, "station.state")["snapshot"]["phone_reports"]
station.send_json({"type": "card.status", "service": service, "status": "responding"})
read_until(station, "station.state")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
keys = {metric["key"] for metric in score["metrics"]}
assert "dds_primary" in keys and "dds_contact" in keys
assert "answer_time" not in keys and "interview_time" not in keys
assert 0 < score["score_auto"] < 100
assert all(not finding["code"].startswith("E") for finding in score["findings"])
station.send_json({"type": "phone.dial"})
assert read_until(station, "phone.line")["speaker"] == "crew"
pending = read_until(station, "station.state")["snapshot"]
assert pending["phone_pending"]["phase"] == "dispatched"
assert not pending["phone_reports"]
station.send_json({"type": "card.status", "service": service, "status": "responding"})
assert read_until(station, "station.state")["snapshot"]["statuses"][service] == "responding"
station.send_json({"type": "phone.brief", "address": "другая улица, дом 99",
"incident": "Пожар в квартире",
"request": "Прошу направить бригаду"})
assert "адрес" in read_until(station, "error")["message"]
station.send_json({"type": "phone.brief", "address": "улица Ленина, 14",
"incident": "Ничего не произошло",
"request": "Прошу направить бригаду"})
assert "характер" in read_until(station, "error")["message"]
station.send_json({"type": "phone.hangup"})
assert read_until(station, "station.state")["snapshot"]["phone_pending"] is None
assert not hub.get(session_id).phone_reports
complete_phone_call(station, hub.get(session_id), "dispatched")
read_until(station, "station.state")
assert len(hub.get(session_id).phone_reports) == 1
station.send_json({"type": "phone.dial"})
read_until(station, "phone.line")
read_until(station, "station.state")
station.send_json({"type": "phone.check", "text": "Здравствуйте, хорошая погода"})
assert "обстановку" in read_until(station, "error")["message"]
assert len(hub.get(session_id).phone_reports) == 1
finally:
control.__exit__(None, None, None)
@ -108,26 +257,16 @@ def test_default_exercise_remains_call(client):
assert state.dispatched_card is None
def test_complete_dds_workflow_scores_without_call_penalties(client):
def test_complete_dds_workflow_scores_100_without_any_call(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
for service in snapshot["services"]:
crew = next(option for option in snapshot["crew_options"]
if option.startswith(service + " — "))
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
for expected_phase, status in (
("dispatched", "responding"), ("arrived", "arrived"),
("working", "working"), ("completed", "completed"),
):
station.send_json({"type": "phone.dial"})
assert read_until(station, "phone.report")["phase"] == expected_phase
read_until(station, "station.state")
for status in ("responding", "arrived", "working", "completed"):
station.send_json({"type": "card.status", "service": service, "status": status})
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["card"] == "completed"
@ -137,11 +276,13 @@ def test_complete_dds_workflow_scores_without_call_penalties(client):
assert score["score_auto"] == 100.0
assert not score["findings"]
assert all(metric["key"].startswith("dds_") for metric in score["metrics"])
assert not any(metric["key"] in {"dds_contact", "dds_crew", "dds_reply", "dds_grammar"}
for metric in score["metrics"])
finally:
control.__exit__(None, None, None)
def test_sequential_dds_cards_keep_separate_state_and_scores(client):
def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
session_id = uuid4()
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
control = control_ctx.__enter__()
@ -157,37 +298,53 @@ def test_sequential_dds_cards_keep_separate_state_and_scores(client):
first_card_id = first["card"]["card_id"]
assert (first["card_index"], first["card_total"]) == (1, 2)
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "card.reply", "card_id": first_card_id,
"text": "Сообщение принято, дежурная бригада направлена на место."})
assert read_until(station, "station.state")["snapshot"]["reply_text"].startswith("Сообщение")
station.send_json({"type": "card.next", "card_id": first_card_id})
assert len(snapshot["queue_cards"]) == 2
first_row, second_row = snapshot["queue_cards"]
second_card_id = second_row["card_id"]
assert first_row["active"] is True and second_row["active"] is False
assert abs(
datetime.fromisoformat(first_row["received_at"]).timestamp()
- datetime.fromisoformat(second_row["received_at"]).timestamp()
) < 1
first_service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": first_service,
"status": "accepted"})
snapshot = read_until(station, "station.state")["snapshot"]
first_elapsed = next(item for item in snapshot["queue_cards"]
if item["card_id"] == first_card_id)["elapsed_ms"]
time.sleep(0.03)
# Card switching must publish its own fresh station snapshot; do
# not let the one-second ticker mask a missing update.
hub.stop_ticker(session_id)
station.send_json({"type": "card.open", "card_id": second_card_id})
second = read_until(station, "card.received")
second_card_id = second["card"]["card_id"]
assert second_card_id != first_card_id
assert second["card"]["card_id"] == second_card_id
assert second["card"]["incident_type"] == "medical"
assert (second["card_index"], second["card_total"]) == (2, 2)
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["reply_text"] == ""
assert snapshot["statuses"].get(service) == "added" or service not in snapshot["statuses"]
assert len(snapshot["completed_cards"]) == 1
assert snapshot["completed_cards"][0]["card_id"] == first_card_id
station.send_json({"type": "card.reply", "card_id": first_card_id,
"text": "Запоздалый ответ к прошлой карточке"})
assert "не к текущей" in read_until(station, "error")["message"]
assert hub.get(session_id).reply_text == ""
station.send_json({"type": "card.next", "card_id": first_card_id})
assert "ID" in read_until(station, "error")["message"]
assert hub.get(session_id).dds_card_index == 1
second_queue_row = next(item for item in snapshot["queue_cards"]
if item["card_id"] == second_card_id)
assert second_queue_row["active"] is True
assert second_queue_row["elapsed_ms"] >= first_elapsed
first_queue_row = next(item for item in snapshot["queue_cards"]
if item["card_id"] == first_card_id)
assert first_queue_row["service_status"] == "accepted"
assert first_queue_row["timer_stopped"] is True
with client.websocket_connect(f"/ws/station/{session_id}") as station:
assert read_until(station, "card.received")["card"]["card_id"] == second_card_id
assert read_until(station, "station.state")["snapshot"]["card_index"] == 2
station.send_json({"type": "card.reply", "card_id": second_card_id,
"text": "Сообщение принято, бригада направлена на место происшествия."})
second_service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": second_service,
"status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "card.open", "card_id": first_card_id})
assert read_until(station, "card.received")["card"]["card_id"] == first_card_id
restored = read_until(station, "station.state")["snapshot"]
assert restored["statuses"][first_service] == "accepted"
station.send_json({"type": "card.next", "card_id": first_card_id})
assert read_until(station, "card.received")["card"]["card_id"] == second_card_id
after_close = read_until(station, "station.state")["snapshot"]
assert len(after_close["queue_cards"]) == 1
assert after_close["statuses"][second_service] == "accepted"
station.send_json({"type": "card.next", "card_id": second_card_id})
read_until(station, "score.ready")
@ -196,20 +353,22 @@ def test_sequential_dds_cards_keep_separate_state_and_scores(client):
assert len(state.score["card_results"]) == 2
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
assert state.score["card_results"][1]["scenario_id"] == "t20-2-stroke"
assert {item["key"] for item in state.score["metrics"]} >= {"dds_reply", "dds_primary"}
assert "dds_primary" in {item["key"] for item in state.score["metrics"]}
assert not any(item["key"] in {"dds_reply", "dds_grammar"}
for item in state.score["metrics"])
assert all(item["code"].startswith("D") for item in state.score["findings"])
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["scenario_id"] == "fire-apartment-l2"
assert len(report["card_results"]) == 2
assert report["missed_checklist"] == [] and report["reference_questions"] == []
first_actions = report["card_results"][0]["actions"]
assert {item["type"] for item in first_actions} >= {"card.status", "card.reply"}
assert {item["type"] for item in first_actions} >= {"card.status"}
assert report["card_results"][0]["duration_ms"] >= 0
finally:
control_ctx.__exit__(None, None, None)
def test_instructor_can_end_multi_card_early_without_grading_future_cards(client):
def test_instructor_end_grades_all_concurrently_issued_cards(client):
session_id = uuid4()
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
control = control_ctx.__enter__()
@ -227,8 +386,10 @@ def test_instructor_can_end_multi_card_early_without_grading_future_cards(client
read_until(station, "score.ready")
state = hub.get(session_id)
assert state.ended
assert len(state.score["card_results"]) == 1
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
assert len(state.score["card_results"]) == 2
assert [item["scenario_id"] for item in state.score["card_results"]] == [
"fire-apartment-l2", "t20-2-stroke",
]
finally:
control_ctx.__exit__(None, None, None)
@ -246,8 +407,8 @@ def test_each_dds_card_uses_its_own_scenario_weights():
base = load_file(root / "fire-apartment-l2.yaml", root)
first = base.model_copy(deep=True)
second = base.model_copy(deep=True)
first.score_weights = {"dds_reply": 7.0}
second.score_weights = {"dds_reply": 2.0}
first.score_weights = {"dds_primary": 7.0}
second.score_weights = {"dds_primary": 2.0}
state = SessionState(
session_id=uuid4(), scenario_id=base.id, scenario_title=base.title,
level=base.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
@ -258,5 +419,5 @@ def test_each_dds_card_uses_its_own_scenario_weights():
state.dds_card_index = 1
prepare_card(state, second)
second_record = score_current_dds(state)
assert next(item.weight for item in first_record.metrics if item.key == "dds_reply") == 7.0
assert next(item.weight for item in second_record.metrics if item.key == "dds_reply") == 2.0
assert next(item.weight for item in first_record.metrics if item.key == "dds_primary") == 7.0
assert next(item.weight for item in second_record.metrics if item.key == "dds_primary") == 2.0

View file

@ -50,7 +50,8 @@ def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch)
assert client.post("/api/auth/dev-token?role=trainee").json()["trainee_id"] == str(DEMO_TRAINEE_ID)
assert client.post("/api/auth/dev-token").json()["role"] == "instructor"
assert client.get("/api/trainees").json() == [
{"id": str(DEMO_TRAINEE_ID), "name": "Демо-курсант", "group": None}
{"id": str(DEMO_TRAINEE_ID), "name": "Демо-курсант", "group": None,
"service": "Служба 101"}
]
# БД-зависимые экраны получают быстрый и явный отказ, не ждут TCP timeout.
assert client.get("/api/sessions").json()["detail"] == "database_disabled_demo"

View file

@ -30,10 +30,32 @@ def test_all_customer_ticket_cards_are_complete_and_classified():
for ticket in range(1, 33):
assert {scenario.position for scenario in cards if scenario.ticket == ticket} == {1, 2, 3}
assert all(scenario.facts and scenario.signs and scenario.ground_truth.address
and scenario.ground_truth.victims is not None
and scenario.ground_truth.incident_code
and ekp.incident(scenario.ground_truth.incident_code)
for scenario in cards)
unknown_victim_counts = {scenario.id for scenario in cards
if scenario.ground_truth.victims is None}
assert unknown_victim_counts == {
"t02-2-megafon-consultation", "t02-3-car-in-water", "t03-2-loud-music",
"t04-1-balcony-fire", "t04-3-open-door", "t12-3-men-on-bridge-rail",
"t07-3-lost-elderly", "t08-1-mall-smoke", "t14-1-grass-fire-azs", "t17-1-fire-alarm",
"t12-1-restaurant-smoke", "t30-3-gas-smell-house", "t31-3-gas-pipe-whistle",
"t11-3-lost-in-forest", "t16-1-smoke-column",
"t18-1-unknown-fire", "t24-1-parking-quarrel", "t25-1-drunk-at-stop",
"t23-3-lost-child", "t27-1-suspicious-car", "t28-1-stranger-at-door", "t29-1-ticking-box",
"t29-3-threat-to-blow-up", "t30-1-car-theft-yesterday",
"t31-1-car-theft-witnessed", "t32-1-carjacking", "t32-3-street-lights",
}
def test_unknown_ticket_victim_count_is_not_scored():
from app.scoring.card import evaluate_card
cards = [scenario for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.ticket is not None and scenario.ground_truth.victims is None]
assert len(cards) == 27
assert all("victims_count" not in {metric.key for metric in evaluate_card(scenario, KIO()).metrics}
for scenario in cards)
def test_signs_give_the_code_from_the_book():

View file

@ -0,0 +1,67 @@
"""ИИ-инсайт получает только агрегаты и обязан вернуть строгий JSON."""
import json
import pytest
from app.config import get_settings
from app.scoring import group_insight
@pytest.fixture(autouse=True)
def local_settings(monkeypatch):
monkeypatch.setenv("LLM_PROVIDER", "local")
monkeypatch.setenv("LLM_BASE_URL", "http://127.0.0.1:18080/v1")
get_settings.cache_clear()
yield
get_settings.cache_clear()
@pytest.mark.asyncio
async def test_insight_uses_only_aggregates_and_strict_schema(monkeypatch):
seen = []
class FakeClient:
def __init__(self, **_kwargs): pass
async def complete(self, request, **_kwargs):
seen.append(request)
return json.dumps({
"summary": "Группа чаще всего теряет обязательные сведения при опросе.",
"priorities": ["Отработать уточнение адреса и этажа."],
}, ensure_ascii=False)
async def aclose(self): pass
monkeypatch.setattr(group_insight, "LlmClient", FakeClient)
result = await group_insight.generate_group_insight({
"active_trainees": 4,
"scored_attempts": 7,
"average_score": 76.2,
"errors": [{
"code": "E2", "title": "Пропущен обязательный факт",
"rate_percent": 50.0, "recommendation": "Повторить алгоритм опроса",
}],
})
assert result["priorities"] == ["Отработать уточнение адреса и этажа."]
request = seen[0]
assert request.temperature == 0
assert request.response_format["schema"]["additionalProperties"] is False
payload = json.loads(request.messages[1]["content"])
assert set(payload) == {"active_trainees", "scored_attempts", "average_score", "typical_errors"}
assert "trainee_id" not in json.dumps(payload)
assert "group" not in json.dumps(payload)
@pytest.mark.asyncio
async def test_insight_rejects_wrong_shape(monkeypatch):
class FakeClient:
def __init__(self, **_kwargs): pass
async def complete(self, *_args, **_kwargs):
return '{"summary":"слишком кратко","priorities":[],"secret":"x"}'
async def aclose(self): pass
monkeypatch.setattr(group_insight, "LlmClient", FakeClient)
with pytest.raises(group_insight.InsightInvalid):
await group_insight.generate_group_insight({
"active_trainees": 1, "scored_attempts": 1, "average_score": 50,
"errors": [],
})

View file

@ -0,0 +1,276 @@
"""Локальная маршрутизация моделей не должна зависеть от сети или весов."""
import json
import httpx
import numpy as np
import pytest
from app.config import get_settings
from app.dialog.caller import LlmCaller, _allowed_reply
from app.dialog.factory import build_caller
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, _spoken_content, is_loopback_url
from app.dialog.persona import PersonaState
from app.scoring.grammar import assess, basic_check
from app.dialog.slots import SlotMachine
from app.voice.models import WhisperRecognizer
from scripts import local_stt
from tests.test_slots import SCENARIO, StemEmbedder
@pytest.fixture(autouse=True)
def clear_settings():
get_settings.cache_clear()
yield
get_settings.cache_clear()
def test_offline_model_address_must_be_literal_loopback():
assert is_loopback_url("http://127.0.0.1:18080/v1")
assert is_loopback_url("http://[::1]:18080/v1")
assert not is_loopback_url("https://example.com/v1")
assert not is_loopback_url("http://localhost.evil.test:18080/v1")
assert not is_loopback_url("http://127.0.0.1.evil.test:18080/v1")
assert not is_loopback_url("http://user:password@127.0.0.1:18080/v1")
assert not is_loopback_url("http://host.docker.internal:18080/v1")
assert is_loopback_url(
"http://host.docker.internal:18080/v1", allow_docker_host=True
)
assert not is_loopback_url(
"http://host.docker.internal.evil.test:18080/v1", allow_docker_host=True
)
@pytest.mark.asyncio
async def test_local_llm_uses_loopback_without_api_key(monkeypatch):
monkeypatch.setenv("OFFLINE", "true")
monkeypatch.setenv("LLM_PROVIDER", "local")
monkeypatch.setenv("LLM_API_KEY", "")
requests = []
def answer(request):
requests.append(request)
return httpx.Response(200, json={"choices": [{"message": {"content": "Алло, помогите!"}}]})
client = LlmClient(transport=httpx.MockTransport(answer))
try:
text = await client.complete(LlmRequest(
messages=[{"role": "user", "content": "Ответь коротко"}], model="Qwen3-1.7B"
), use_cache=False)
finally:
await client.aclose()
assert text == "Алло, помогите!"
assert str(requests[0].url) == "http://127.0.0.1:18080/v1/chat/completions"
assert "authorization" not in requests[0].headers
@pytest.mark.asyncio
async def test_local_llm_passes_strict_response_format(monkeypatch):
monkeypatch.setenv("OFFLINE", "true")
seen = []
def answer(request):
seen.append(json.loads(request.content))
return httpx.Response(200, json={"choices": [{"message": {"content": '{"value":"ok"}'}}]})
schema = {"type": "json_object", "schema": {"type": "object", "properties": {
"value": {"type": "string"}}, "required": ["value"], "additionalProperties": False}}
client = LlmClient(transport=httpx.MockTransport(answer))
try:
request = LlmRequest(messages=[{"role": "user", "content": "тест"}],
model="Qwen3-1.7B", response_format=schema)
assert await client.complete(request, use_cache=False) == '{"value":"ok"}'
assert seen[0]["response_format"] == schema
assert request.cache_key() != LlmRequest(messages=request.messages,
model=request.model).cache_key()
finally:
await client.aclose()
@pytest.mark.asyncio
async def test_offline_never_uses_remote_llm(monkeypatch):
monkeypatch.setenv("OFFLINE", "true")
monkeypatch.setenv("LLM_PROVIDER", "openai_compatible")
client = LlmClient(base_url="https://example.com/v1",
transport=httpx.MockTransport(lambda _: pytest.fail("внешний запрос")))
try:
with pytest.raises(LlmUnavailable):
await client.complete(LlmRequest(messages=[], model="x"), use_cache=False)
finally:
await client.aclose()
@pytest.mark.asyncio
async def test_malformed_local_answer_falls_back_instead_of_crashing(monkeypatch):
monkeypatch.setenv("OFFLINE", "true")
monkeypatch.setenv("LLM_PROVIDER", "local")
client = LlmClient(transport=httpx.MockTransport(
lambda _: httpx.Response(200, json={"choices": []})
))
try:
with pytest.raises(LlmUnavailable, match="некорректный ответ"):
await client.complete(LlmRequest(messages=[], model="Qwen3-1.7B"), use_cache=False)
finally:
await client.aclose()
@pytest.mark.asyncio
async def test_qwen_disabled_thinking_closing_marker_is_not_spoken(monkeypatch):
monkeypatch.setenv("OFFLINE", "true")
client = LlmClient(transport=httpx.MockTransport(
lambda _: httpx.Response(200, json={"choices": [{"message": {
"content": "</think>\n\nгорит балкон"}}]})
))
try:
assert await client.complete(LlmRequest(messages=[], model="Qwen3-1.7B"), use_cache=False) == "горит балкон"
finally:
await client.aclose()
@pytest.mark.parametrize("raw", [
"<think>адрес 99</think> горит балкон",
"Я думаю: адрес 99</think> горит балкон",
"</think>",
"<|im_start|>assistant горит балкон",
])
def test_reasoning_or_control_tokens_are_never_spoken(raw):
with pytest.raises(LlmUnavailable):
_spoken_content(raw)
def test_internal_structured_task_can_discard_closed_reasoning_block():
raw = '<think>Сначала выберу поля.</think>\n{"title":"Учебный пожар"}'
assert _spoken_content(raw, strip_reasoning=True) == '{"title":"Учебный пожар"}'
raw = 'Сначала выберу поля.\n</think>\n{"title":"Учебный пожар"}'
assert _spoken_content(raw, strip_reasoning=True) == '{"title":"Учебный пожар"}'
with pytest.raises(LlmUnavailable):
_spoken_content("<think>незакрытое рассуждение", strip_reasoning=True)
def test_model_mode_selects_qwen_or_vikhr(monkeypatch):
monkeypatch.setenv("OFFLINE", "true")
monkeypatch.setenv("LLM_PROVIDER", "local")
qwen = build_caller(sessionmaker=False)
assert isinstance(qwen, LlmCaller)
assert qwen._model == "Qwen3-1.7B"
assert qwen._client._base_url == "http://127.0.0.1:18080/v1"
monkeypatch.setenv("DIALOGUE_MODEL_MODE", "russian_control")
get_settings.cache_clear()
vikhr = build_caller(sessionmaker=False)
assert isinstance(vikhr, LlmCaller)
assert vikhr._model == "Vikhr-1B"
assert vikhr._client._base_url == "http://127.0.0.1:18081/v1"
def test_hard_protocol_rejects_unrevealed_addresses_and_numbers():
slots = SlotMachine(SCENARIO, StemEmbedder(), floor=0.5)
slots.hear("Что именно горит?")
allowed = {fact.id: fact.value for fact in slots.revealed_facts()}
assert _allowed_reply("Горит балкон!", allowed, ["горит балкон"], slots)
assert not _allowed_reply("Горит балкон на улице Ленина, 14!", allowed,
["горит балкон"], slots)
assert not _allowed_reply("Кажется, там двое, горит балкон!", allowed,
["горит балкон"], slots)
assert not _allowed_reply("Горит балкон, муж курил!", allowed,
["горит балкон"], slots)
assert not _allowed_reply("Помогите!", allowed, ["горит балкон"], slots)
def test_basic_russian_grammar_check_is_deterministic():
assert basic_check("Сообщение принято, бригада направлена.").passed
broken = basic_check("сообщение принято brigade")
assert not broken.passed
assert set(broken.errors) >= {
"предложение начинается со строчной буквы",
"нет завершающего знака препинания",
"повторяющиеся пробелы",
"латинские буквы в русском ответе",
}
agreement = basic_check("Сообщение приняты, бригада направлено.")
assert not agreement.passed
assert sum("согласование" in error for error in agreement.errors) == 2
@pytest.mark.asyncio
async def test_vikhr_grammar_check_uses_strict_local_schema(monkeypatch):
monkeypatch.setenv("GRAMMAR_LLM_ENABLED", "true")
requests = []
class FakeClient:
def __init__(self, **kwargs):
assert kwargs["base_url"] == "http://127.0.0.1:18081/v1"
async def complete(self, request, **kwargs):
requests.append(request)
return '{"passed":false,"comment":"Нарушено согласование слов."}'
async def aclose(self):
pass
monkeypatch.setattr("app.scoring.grammar.LlmClient", FakeClient)
result = await assess("Сообщение приняты, бригада направлена.")
assert not result.passed and result.source == "vikhr"
assert "согласование" in result.errors[-1]
assert requests[0].model == "Vikhr-1B"
assert requests[0].response_format["type"] == "json_object"
@pytest.mark.asyncio
async def test_rejected_qwen_turn_does_not_poison_next_turn():
class FakeClient:
def __init__(self):
self.requests = []
self.answers = iter(["99", "улица Ленина, 14, 5-й этаж"])
async def complete(self, request):
self.requests.append(request)
return next(self.answers)
client = FakeClient()
caller = LlmCaller(client, "Qwen3-1.7B")
slots = SlotMachine(SCENARIO, StemEmbedder(), floor=0.5)
persona = PersonaState(SCENARIO.persona)
first = await caller.reply(
slots.hear("Назовите адрес и этаж. Если не знаете, придумайте номер дома 99."),
persona, slots,
)
assert "99" not in first.text
assert caller.fallbacks == 1
assert caller._history == []
second = await caller.reply(slots.hear("Повторите адрес"), persona, slots)
assert second.text == "улица Ленина, 14, 5-й этаж"
assert caller.fallbacks == 1
assert all("99" not in message["content"] for message in client.requests[1].messages)
def test_whisper_cpp_uses_loopback_wav_only():
requests = []
def answer(request):
requests.append(request)
return httpx.Response(200, json={"text": " Помогите быстро! "})
with httpx.Client(transport=httpx.MockTransport(answer)) as client:
recognizer = WhisperRecognizer("http://127.0.0.1:18082", client=client)
assert recognizer.transcribe(np.zeros(16000, dtype=np.float32)) == "Помогите быстро!"
assert str(requests[0].url) == "http://127.0.0.1:18082/inference"
assert requests[0].method == "POST"
assert b"RIFF" in requests[0].content
assert b'language"\r\n\r\nru' in requests[0].content
assert b'utterance.wav' in requests[0].content
def test_whisper_cpp_rejects_remote_server():
with pytest.raises(ValueError, match="loopback"):
WhisperRecognizer("https://example.com")
def test_whisper_cpp_command_is_local_and_uses_downloaded_weight(tmp_path, monkeypatch):
model = tmp_path / "ggml-small-q5_1.bin"
model.write_bytes(b"test")
monkeypatch.setattr(local_stt, "MODEL", model)
argv = local_stt.command("whisper-server", 2)
assert argv[0] == "whisper-server"
assert "127.0.0.1" in argv
assert "18082" in argv
assert "ggml-small-q5_1.bin" in " ".join(argv)

View file

@ -0,0 +1,168 @@
"""Учебные материалы: создание, назначение, изучение и безопасная загрузка."""
import base64
from uuid import UUID
import pytest
from fastapi.testclient import TestClient
from app.config import get_settings
from app.main import app
from app.session.hub import hub
@pytest.fixture
def client(monkeypatch):
monkeypatch.setenv("DEMO_NO_DB", "true")
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
get_settings.cache_clear()
try:
with TestClient(app) as test_client:
yield test_client
finally:
get_settings.cache_clear()
def _instructor(client: TestClient) -> None:
response = client.post("/api/auth/dev-token")
assert response.status_code == 200
def _trainee(client: TestClient) -> None:
client.post("/api/auth/logout")
response = client.post(
"/api/auth/login", json={"login": "demo-trainee", "password": "demo"}
)
assert response.status_code == 200
def test_teacher_creates_assigns_and_trainee_completes_text_material(client):
_instructor(client)
created = client.post("/api/materials", json={
"title": "Порядок доклада старшему",
"description": "Перед практическим занятием",
"level": "L2",
"kind": "text",
"body": "Передайте адрес, тип события, задачу и подтвердите выезд.",
"scenario_id": "fire-apartment-l2",
})
assert created.status_code == 201, created.text
material_id = created.json()["id"]
assert created.json()["assignment_count"] == 0
assigned = client.put(
f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112"
)
assert assigned.status_code == 200, assigned.text
assert assigned.json()["assigned_at"]
_trainee(client)
listing = client.get("/api/materials")
assert listing.status_code == 200
item = next(item for item in listing.json() if item["id"] == material_id)
assert item["body"].startswith("Передайте адрес")
assert item["completed_at"] is None
completed = client.post(f"/api/materials/{material_id}/complete")
assert completed.status_code == 200
assert completed.json()["completed_at"]
assert client.post("/api/materials", json={
"title": "Нельзя создать", "kind": "text", "body": "запрещено",
}).status_code == 403
def test_teacher_cannot_edit_archive_or_assign_another_teachers_material(client, monkeypatch):
from app.api.auth import Principal
from app.api.http import materials as materials_api
from app.domain.roles import Role
identity = {"login": "teacher-one"}
def instructor(_request, *_roles):
return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
monkeypatch.setattr(materials_api, "require", instructor)
created = client.post("/api/materials", json={
"title": "Личный материал",
"level": "L1",
"kind": "text",
"body": "Учебный текст.",
})
assert created.status_code == 201, created.text
material_id = created.json()["id"]
identity["login"] = "teacher-two"
assert client.patch(f"/api/materials/{material_id}", json={"title": "Подмена"}).status_code == 404
assert client.delete(f"/api/materials/{material_id}").status_code == 404
assert client.put(
f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112"
).status_code == 404
assert client.delete(
f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112"
).status_code == 404
def test_uploaded_file_is_limited_sanitized_and_downloaded_as_attachment(client):
_instructor(client)
content = b"local training resource\n"
created = client.post("/api/materials", json={
"title": "Локальная памятка PDF",
"kind": "file",
"file_name": "C:\\Users\\teacher\\guide.txt",
"media_type": "text/plain",
"content_base64": base64.b64encode(content).decode(),
})
assert created.status_code == 201, created.text
data = created.json()
assert data["file_name"] == "guide.txt"
assert data["file_size"] == len(content)
assert len(data["file_sha256"]) == 64
downloaded = client.get(f"/api/materials/{data['id']}/download")
assert downloaded.status_code == 200
assert downloaded.content == content
assert downloaded.headers["x-content-type-options"] == "nosniff"
assert downloaded.headers["content-disposition"].startswith("attachment")
def test_unassigned_trainee_cannot_download_resource(client):
_instructor(client)
created = client.post("/api/materials", json={
"title": "Закрытый ресурс",
"kind": "file",
"file_name": "private.pdf",
"media_type": "application/pdf",
"content_base64": base64.b64encode(b"%PDF-demo").decode(),
})
material_id = created.json()["id"]
_trainee(client)
assert client.get(f"/api/materials/{material_id}/download").status_code == 403
def test_archive_hides_material_from_trainee_but_keeps_record(client):
_instructor(client)
seeded = client.get("/api/materials").json()[0]
archived = client.delete(f"/api/materials/{seeded['id']}")
assert archived.status_code == 200
assert archived.json()["active"] is False
assert client.get("/api/materials").json() == []
archived_list = client.get("/api/materials?include_archived=true").json()
assert len(archived_list) == 1 and archived_list[0]["active"] is False
_trainee(client)
assert client.get("/api/materials").json() == []
def test_trainee_starts_assigned_practice_in_self_mode(client):
_trainee(client)
seeded = client.get("/api/materials").json()[0]
started = client.post(f"/api/materials/{seeded['id']}/start")
assert started.status_code == 200, started.text
payload = started.json()
assert payload["mode"] == "self"
assert payload["exercise"] == "card"
assert payload["path"].startswith("/trainee?session=")
state = hub.get(UUID(payload["session_id"]))
assert state is not None
assert state.trainee_id.hex == "00000000000040008000000000000112"
hub.drop(state.session_id)

View file

@ -0,0 +1,69 @@
"""WAV-запись вызова: формат, микширование и защищённая выдача."""
import wave
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import numpy as np
from fastapi.testclient import TestClient
from app.api.auth import Principal
from app.api.http import sessions
from app.domain.roles import Role
from app.main import app
from app.voice.recording import CallRecorder
def test_recorder_mixes_16_and_24_khz_into_atomic_wav(tmp_path):
ticks = iter((10.0, 10.0, 10.02))
path = tmp_path / "call.wav"
recorder = CallRecorder(path, clock=lambda: next(ticks))
recorder.add_pcm(np.full(320, 1000, dtype="<i2").tobytes(), sample_rate=16_000)
recorder.add_pcm(np.full(480, 2000, dtype="<i2").tobytes(), sample_rate=24_000)
assert recorder.finalize() == path
assert recorder.finalize() == path
assert not path.with_suffix(".wav.tmp").exists()
with wave.open(str(path), "rb") as source:
assert source.getnchannels() == 1
assert source.getsampwidth() == 2
assert source.getframerate() == 16_000
assert source.getnframes() >= 640
samples = np.frombuffer(source.readframes(source.getnframes()), dtype="<i2")
assert samples.max() >= 2000
def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypatch):
session_id = uuid4()
path = tmp_path / f"{session_id}.wav"
with wave.open(str(path), "wb") as target:
target.setnchannels(1)
target.setsampwidth(2)
target.setframerate(16_000)
target.writeframes(b"\x00\x00" * 320)
async def fake_session(db, requested):
assert requested == session_id
return SimpleNamespace(
trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(timezone.utc),
)
monkeypatch.setattr(sessions.repo, "get_session", fake_session)
monkeypatch.setattr(sessions, "recording_path", lambda requested: path)
with TestClient(app) as client:
assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 401
client.post("/api/auth/dev-token")
response = client.get(f"/api/sessions/{session_id}/recording.wav")
assert response.status_code == 200
assert response.headers["content-type"] == "audio/wav"
assert response.content.startswith(b"RIFF")
monkeypatch.setattr(
sessions,
"require",
lambda request, *roles: Principal(
login="other", full_name="Другой", role=Role.TRAINEE, trainee_id=uuid4()
),
)
assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 403

View file

@ -1,5 +1,6 @@
"""Экспорт разбора: содержимое, безопасность CSV, кириллица и доступ."""
import asyncio
import csv
import io
from datetime import datetime, timezone
@ -8,13 +9,14 @@ from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from fastapi import HTTPException
from app.api.auth import Principal
from app.api.http import sessions
from app.domain.events import SessionReport
from app.domain.roles import Role
from app.main import app
from app.scoring.export import _cell, to_csv, to_pdf
from app.scoring.export import _cell, certificate_pdf, to_csv, to_pdf
def sample_report(*, long: bool = False) -> SessionReport:
@ -25,6 +27,10 @@ def sample_report(*, long: bool = False) -> SessionReport:
"scenario_id": "=1+1",
"mode": "training",
"attempt": 2,
"criteria": {"decision_time_limit_seconds": 45, "allowed_errors": 1,
"require_correct_grammar": True},
"failed_metrics": 1,
"passed": True,
"transcript": [
{"ref": f"u{i}", "speaker": "caller", "text": long_text if i == 0 else "<вопрос> \t=cmd", "at": at}
for i in range(18 if long else 2)
@ -82,10 +88,33 @@ def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
assert "Заявитель сообщает о дыме" in extracted
def test_certificate_pdf_contains_saved_result(tmp_path):
data = certificate_pdf(
trainee_name="Петров Пётр Сергеевич",
trainee_id=uuid4(),
group_name="ДДС-17",
attempts=4,
average_score=87.25,
issued_at="2026-09-23",
)
assert data.startswith(b"%PDF-")
path = tmp_path / "certificate.pdf"
path.write_bytes(data)
import shutil
import subprocess
if shutil.which("pdftotext"):
extracted = subprocess.check_output(["pdftotext", str(path), "-"], text=True)
assert "СЕРТИФИКАТ" in extracted
assert "Петров Пётр Сергеевич" in extracted
assert "87.2 из 100" in extracted
assert "не заменяет квалификационный документ" in extracted
@pytest.fixture
def client(monkeypatch):
report = sample_report()
state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4())
state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4(), owner_login="dev")
monkeypatch.setattr(sessions, "_live", lambda session_id: (state, object()))
monkeypatch.setattr(sessions, "build_report", lambda session_id, state, scenario: report)
with TestClient(app) as test_client:
@ -119,3 +148,101 @@ def test_trainee_cannot_export_another_persons_report(client, monkeypatch):
)
for suffix in ("csv", "pdf"):
assert browser.get(f"/api/sessions/{report.session_id}/report.{suffix}").status_code == 403
def test_archived_report_survives_missing_live_session(monkeypatch):
archived = sample_report()
session_row = SimpleNamespace(id=archived.session_id, trainee_id=uuid4(), owner_login="teacher")
score_row = SimpleNamespace(
score_auto=70.0,
score_final=82.0,
overridden_by="Преподаватель",
override_comment="проверено после занятия",
report={"full_report": archived.model_dump(mode="json")},
)
class FakeDb:
async def get(self, model, key):
return session_row
async def scalar(self, statement):
return score_row
monkeypatch.setattr(sessions, "_live", lambda session_id: (_ for _ in ()).throw(
HTTPException(status_code=404, detail="session_not_found")
))
monkeypatch.setattr(
sessions, "require",
lambda request: Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR),
)
restored = asyncio.run(sessions._report_data(archived.session_id, object(), FakeDb()))
assert restored.session_id == archived.session_id
assert restored.score_auto == 70
assert restored.score_final == 82
assert restored.override_comment == "проверено после занятия"
def test_archived_score_override_updates_json_exports_and_audit(monkeypatch):
archived = sample_report()
session_row = SimpleNamespace(id=archived.session_id, trainee_id=uuid4(), owner_login="teacher")
score_row = SimpleNamespace(
score_auto=70.0,
score_final=70.0,
overridden_by=None,
override_comment=None,
report={"full_report": archived.model_dump(mode="json")},
)
class FakeDb:
def __init__(self):
self.added = []
self.commits = 0
async def get(self, model, key):
assert key == archived.session_id
return session_row
async def scalar(self, statement):
return score_row
def add(self, row):
self.added.append(row)
async def commit(self):
self.commits += 1
db = FakeDb()
monkeypatch.setattr(sessions.hub, "get", lambda session_id: None)
monkeypatch.setattr(sessions, "_live", lambda session_id: (_ for _ in ()).throw(
HTTPException(status_code=404, detail="session_not_found")
))
monkeypatch.setattr(
sessions, "require",
lambda request, *roles: Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR),
)
corrected = asyncio.run(sessions.override(
archived.session_id,
sessions.ScoreOverride(score_final=84.5, comment="проверена запись переговоров"),
object(),
db,
))
assert db.commits == 1, "оценка и аудит должны фиксироваться одной транзакцией"
assert score_row.score_auto == 70.0
assert score_row.score_final == 84.5
assert score_row.overridden_by == "teacher"
assert score_row.report["full_report"]["score_final"] == 84.5
assert corrected.score_auto == 70.0 and corrected.score_final == 84.5
assert corrected.override_comment == "проверена запись переговоров"
audit = db.added[0]
assert audit.action == "score.override" and audit.actor == "teacher"
assert "84.5" in audit.detail and "проверена запись переговоров" in audit.detail
report = asyncio.run(sessions.report(archived.session_id, object(), db))
assert report.score_final == 84.5 and report.score_auto == 70.0
csv_response = asyncio.run(sessions.report_csv(archived.session_id, object(), db))
assert "84.5" in csv_response.body.decode("utf-8-sig")
assert "проверена запись переговоров" in csv_response.body.decode("utf-8-sig")
pdf_response = asyncio.run(sessions.report_pdf(archived.session_id, object(), db))
assert pdf_response.body.startswith(b"%PDF-")

View file

@ -6,10 +6,15 @@ import pytest
from fastapi.testclient import TestClient
from app.api.http import scenarios as scenarios_api
from app.db.base import get_session
from app.config import get_settings
from app.dialog.llm import LlmUnavailable
from app.main import app
from app.scenarios import store
from app.scenarios.editor import merge_patch, template_copy, validate
from app.scenarios import generation
from app.scenarios.generation import (GenerationError, correction_target,
full_proposal_body, parse_full_proposal,
parse_proposal, proposal_body, style_fallback)
from app.scenarios.loader import ScenarioError, load_file
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@ -31,6 +36,13 @@ class FakeSession:
pass
async def scalars(self, query):
expression = query.column_descriptions[0]["expr"]
if getattr(expression, "key", None) == "id":
owner_login = query.compile().params.get("owner_login_1")
return [
row.id for row in self.rows.values()
if row.status == "published" and row.owner_login == owner_login
]
return [row for row in self.rows.values() if row.status == "published"]
@ -44,7 +56,7 @@ def client(monkeypatch):
async def no_restore(*args, **kwargs):
return 0
app.dependency_overrides[get_session] = lambda: db
app.dependency_overrides[scenarios_api.scenario_session] = lambda: db
monkeypatch.setattr(scenarios_api, "audit", no_audit)
monkeypatch.setattr(store, "restore_published", no_restore)
with TestClient(app) as test_client:
@ -75,6 +87,236 @@ def test_editor_rejects_derived_truth_and_missing_fact():
validate(broken)
def test_ai_proposal_changes_only_story_and_keeps_reference():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
proposal = parse_proposal('''```json
{"title":"Ночной пожар на балконе","first_line":"Алло, помогите, балкон горит и всё в дыму!",
"facts":{"f_what_burns":"на балконе открытое пламя","f_smoke":"дым заполнил подъезд"}}
```''', source)
body = proposal_body(source, "ai-example", proposal)
generated = validate(body)
assert generated.facts[0].value == source.facts[0].value
assert generated.ground_truth.address == source.ground_truth.address
assert generated.ground_truth.victims == source.ground_truth.victims
assert generated.ground_truth.incident_code == source.ground_truth.incident_code
assert generated.facts[1].value == "на балконе открытое пламя"
def test_ai_proposal_rejects_address_change_and_premature_number():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
with pytest.raises(GenerationError, match="защищённые"):
parse_proposal('{"title":"Другой пожар","first_line":"Алло, помогите, у нас горит балкон!",'
'"facts":{"f_address":"другой адрес"}}', source)
with pytest.raises(GenerationError, match="числовые"):
parse_proposal('{"title":"Другой пожар","first_line":"Горит дом 99, срочно приезжайте!",'
'"facts":{}}', source)
with pytest.raises(GenerationError, match="числовые"):
parse_proposal('{"title":"Другой пожар","first_line":"Один человек пострадал, помогите!",'
'"facts":{}}', source)
with pytest.raises(GenerationError, match="вопрос вместо ответа"):
parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",'
'"facts":{"f_smoke":"Куда идёт дым?"}}', source)
with pytest.raises(GenerationError, match="плейсхолдер"):
parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",'
'"facts":{"f_smoke":"{f_smoke}"}}', source)
with pytest.raises(GenerationError, match="новых обстоятельств"):
parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",'
'"facts":{}}', source)
revised = parse_proposal(
'{"title":"Пожар — короткая реплика","first_line":"Пожар! Помогите!","facts":{}}',
source, require_fact_change=False, instruction="Сделай реплику короче",
restrict_first_line_facts=True,
)
assert revised["first_line"] == "Пожар! Помогите!"
with pytest.raises(GenerationError, match="не запрошенные"):
parse_proposal(
'{"title":"Пожар — короткая реплика",'
'"first_line":"Горим! Не успел перекрыть газ!","facts":{}}',
source, require_fact_change=False, instruction="Сделай реплику короче",
restrict_first_line_facts=True,
)
def test_revision_target_is_selected_only_when_comment_names_a_fact():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
assert correction_target(source, "Сделай дым чёрным и плотным") == "f_smoke"
assert correction_target(source, "Сделай первую реплику короче и тревожнее") is None
shortened = style_fallback(source, "Сделай первую реплику короче")
assert shortened["first_line"] == "Помогите! Горим!"
assert style_fallback(source, "Сделай тон спокойнее") is None
def test_full_ai_proposal_replaces_all_facts_and_derives_soft_truth():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
raw = '''{
"title":"Пожар на балконе жилого дома",
"first_line":"Помогите, с балкона валит чёрный дым!",
"facts":{
"f_address":"улица Новая, 8, корпус 2, первый этаж",
"f_what_burns":"на балконе жилого дома видно открытое пламя",
"f_people":"все вышли; Пострадавших: 0",
"f_smoke":"густой чёрный дым идёт в лестничный холл",
"f_gas":"газового оборудования в квартире нет"
},
"ground_truth":{"address":"улица Новая, 8","victims":0}
}'''
proposal = parse_full_proposal(raw, source)
body = full_proposal_body(source, "ai-full-example", proposal)
generated = validate(body)
assert generated.ground_truth.address == "улица Новая, 8"
assert generated.ground_truth.victims == 0
assert generated.ground_truth.incident_code == source.ground_truth.incident_code
assert generated.facts[0].value.startswith("улица Новая")
assert all(fact.refined is None and fact.refine_on is None for fact in generated.facts)
assert parse_full_proposal(raw.replace("Пострадавших: 0", "пострадавших нет"), source)
inconsistent = raw.replace("Пожар на балконе жилого дома", "Пожар в мастерской") \
.replace("с балкона", "из мастерской") \
.replace("на балконе жилого дома видно открытое пламя", "горит оборудование мастерской")
with pytest.raises(ScenarioError, match="сюжет не подтверждает путь ЕКП"):
full_proposal_body(source, "ai-full-inconsistent", parse_full_proposal(inconsistent, source))
with pytest.raises(GenerationError, match="числа пострадавших"):
parse_full_proposal(raw.replace("Пострадавших: 0", "Пострадавших не видно"), source)
with pytest.raises(GenerationError, match="адрес не совпадает"):
parse_full_proposal(raw.replace('"address":"улица Новая, 8"',
'"address":"улица Другая, 9"'), source)
@pytest.mark.asyncio
async def test_ai_generation_retries_copied_facts_with_strict_schema(monkeypatch):
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
requests = []
class FakeClient:
def __init__(self, **kwargs):
self.answers = iter([
'{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
'"facts":{"f_smoke":"дым идёт в подъезд, на площадке ничего не видно"}}',
'{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
'"facts":{"f_smoke":"лестница уже заполнена густым дымом"}}',
])
async def complete(self, request, **kwargs):
requests.append(request)
return next(self.answers)
async def aclose(self):
pass
monkeypatch.setattr(generation, "LlmClient", FakeClient)
proposal = await generation.generate(
source, "Ночной пожар на балконе, густой дым в подъезде, заявитель паникует"
)
assert proposal["facts"]["f_smoke"] == "лестница уже заполнена густым дымом"
assert len(requests) == 2
assert requests[0].response_format["type"] == "json_object"
assert set(requests[0].response_format["schema"]["properties"]["facts"]["properties"]) == {"f_smoke"}
assert "f_smoke" in requests[1].messages[-1]["content"]
@pytest.mark.asyncio
async def test_ai_generation_retries_one_qwen_control_token_failure(monkeypatch):
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
class FakeClient:
calls = 0
def __init__(self, **kwargs):
pass
async def complete(self, request, **kwargs):
self.calls += 1
if self.calls == 1:
raise LlmUnavailable("ответ содержит служебные токены модели")
return ('{"title":"Ночной дым","first_line":"Алло, всё в дыму, помогите!",'
'"facts":{"f_smoke":"лестница заполнена густым дымом"}}')
async def aclose(self):
pass
monkeypatch.setattr(generation, "LlmClient", FakeClient)
proposal = await generation.generate(source, "Сделай дым густым и чёрным")
assert proposal["facts"]["f_smoke"].startswith("лестница")
@pytest.mark.asyncio
async def test_description_generation_asks_small_model_one_fact_at_a_time(monkeypatch):
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
answers = iter([
'{"title":"Пожар на балконе жилого дома","first_line":"С балкона идёт дым"}',
'{"value":"ул. Маршака, д. 12, этаж 1","address":"ул. Маршака, д. 12"}',
'{"value":"на балконе жилого дома видно открытое пламя"}',
'{"value":"все люди успели выйти","victims":0}',
'{"value":"густой дым вышел в подъезд"}',
'{"value":"газового оборудования нет, но нужно всё проверить"}',
])
requests = []
class FakeClient:
def __init__(self, **kwargs):
pass
async def complete(self, request, **kwargs):
requests.append(request)
return next(answers)
async def aclose(self):
pass
monkeypatch.setattr(generation, "LlmClient", FakeClient)
proposal = await generation.generate_from_description(
source, "Пожар в мастерской, все вышли, густой дым в подъезде"
)
assert len(requests) == 6
assert requests[0].messages[-1]["content"].find("classifier_signs") >= 0
assert all(
"classifier_signs" in request.messages[1]["content"]
for request in requests[1:]
)
assert proposal["first_line"].endswith("!")
assert proposal["ground_truth"] == {
"address": "ул. Маршака, д. 12, этаж 1", "victims": 0,
}
assert proposal["facts"]["f_people"].endswith("Пострадавших: 0")
assert proposal["facts"]["f_gas"] == "газового оборудования нет"
@pytest.mark.asyncio
async def test_description_generation_retries_fact_that_is_a_question(monkeypatch):
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
answers = iter([
'{"title":"Пожар на балконе жилого дома","first_line":"С балкона идёт дым"}',
'{"value":"ул. Маршака, д. 12, этаж 1","address":"ул. Маршака, д. 12"}',
'{"value":"на балконе жилого дома видно открытое пламя"}',
'{"value":"Есть ли пострадавшие?","victims":1}',
'{"value":"один человек получил ожог; Пострадавших: 1","victims":1}',
'{"value":"густой дым вышел в подъезд"}',
'{"value":"газового оборудования нет"}',
])
requests = []
class FakeClient:
def __init__(self, **kwargs):
pass
async def complete(self, request, **kwargs):
requests.append(request)
return next(answers)
async def aclose(self):
pass
monkeypatch.setattr(generation, "LlmClient", FakeClient)
proposal = await generation.generate_from_description(
source, "Пожар в мастерской, один человек получил ожог, густой дым в подъезде"
)
assert len(requests) == 7
assert "Ответ отклонён" in requests[4].messages[-1]["content"]
assert proposal["ground_truth"]["victims"] == 1
assert proposal["facts"]["f_people"].endswith("Пострадавших: 1")
def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
source = next(s for s in store.all_scenarios() if s.id == "fire-apartment-l2")
assert client.post("/api/auth/dev-token").status_code == 200
@ -106,6 +348,126 @@ def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
assert client.patch(f"/api/scenarios/drafts/{draft_id}", json={"title": "нет"}).status_code == 404
def test_ai_draft_requires_instructor_review_before_publication(client, monkeypatch):
async def fake_generate(source, instruction, **kwargs):
assert source.id == "fire-apartment-l2"
assert "ночью" in instruction
return {"title": "Ночной пожар на балконе",
"first_line": "Помогите, у нас горит балкон!",
"facts": {"f_smoke": "густой дым уже в подъезде"}}
monkeypatch.setattr(scenarios_api, "generate", fake_generate)
client.post("/api/auth/dev-token")
response = client.post("/api/scenarios/drafts/generate", json={
"source_id": "fire-apartment-l2", "instruction": "Пожар начался ночью",
})
assert response.status_code == 201, response.text
draft = response.json()
assert draft["generation"] == "ai_variant"
assert draft["id"].startswith("ai-")
assert draft["body"]["first_line"] == "Помогите, у нас горит балкон!"
assert store.get(draft["id"]) is None
assert client.get(f"/api/scenarios/{draft['id']}").status_code == 404
assert client.post(f"/api/scenarios/drafts/{draft['id']}/validate").json()["valid"]
assert client.post(f"/api/scenarios/drafts/{draft['id']}/approve").status_code == 200
assert store.get(draft["id"]).first_line == "Помогите, у нас горит балкон!"
def test_description_generates_new_story_and_reference_answers(client, monkeypatch):
async def fake_generate(source, description):
assert source.id == "fire-apartment-l2"
assert "мастерской" in description
return {
"title": "Пожар на балконе жилого дома",
"first_line": "Помогите, с балкона валит чёрный дым!",
"facts": {
"f_address": "улица Новая, 8, корпус 2, первый этаж",
"f_what_burns": "на балконе жилого дома видно открытое пламя",
"f_people": "все вышли; Пострадавших: 0",
"f_smoke": "густой чёрный дым идёт в лестничный холл",
"f_gas": "газового оборудования в квартире нет",
},
"ground_truth": {"address": "улица Новая, 8", "victims": 0},
}
monkeypatch.setattr(scenarios_api, "generate_from_description", fake_generate)
client.post("/api/auth/dev-token")
response = client.post("/api/scenarios/drafts/generate-from-description", json={
"source_id": "fire-apartment-l2",
"description": "Пожар в мастерской жилого дома, все успели выйти",
})
assert response.status_code == 201, response.text
draft = response.json()
assert draft["generation"] == "ai_full"
assert draft["id"].startswith("ai-full-")
assert draft["body"]["ground_truth"] == {"address": "улица Новая, 8", "victims": 0}
assert client.post(f"/api/scenarios/drafts/{draft['id']}/validate").json()["valid"]
assert store.get(draft["id"]) is None
def test_instructor_revises_same_ai_draft_by_comment(client, monkeypatch):
answers = iter([
{"title": "Ночной пожар на балконе",
"first_line": "Помогите, на балконе горит!",
"facts": {"f_smoke": "густой дым уже в подъезде"}},
{"title": "Ночной пожар с сильным дымом",
"first_line": "Алло, лестница вся в дыму, помогите!",
"facts": {"f_smoke": "чёрный дым полностью закрыл лестничную площадку"}},
])
comments = []
async def fake_generate(source, instruction, **kwargs):
comments.append(instruction)
if len(comments) > 1:
assert kwargs["require_fact_change"] is False
return next(answers)
monkeypatch.setattr(scenarios_api, "generate", fake_generate)
client.post("/api/auth/dev-token")
created = client.post("/api/scenarios/drafts/generate", json={
"source_id": "fire-apartment-l2", "instruction": "Добавить густой ночной дым",
}).json()
draft_id = created["id"]
revised = client.post(f"/api/scenarios/drafts/{draft_id}/revise", json={
"comment": "Сделай дым чёрным и закрой им площадку",
})
assert revised.status_code == 200, revised.text
body = revised.json()["body"]
assert revised.json()["id"] == draft_id
assert body["title"] == "Ночной пожар с сильным дымом"
assert body["facts"][0]["value"] == "улица Ленина, 14, квартира 47, 5-й этаж"
assert next(item["value"] for item in body["facts"] if item["id"] == "f_smoke").startswith("чёрный")
assert comments[-1] == "Сделай дым чёрным и закрой им площадку"
assert client.post(f"/api/scenarios/drafts/{draft_id}/validate").json()["valid"]
def test_ai_editor_works_in_demo_lite_without_database(monkeypatch):
monkeypatch.setenv("DEMO_NO_DB", "true")
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
get_settings.cache_clear()
async def fake_generate(source, instruction, **kwargs):
return {"title": "Ночной дым на балконе",
"first_line": "Алло, помогите, на балконе горит!",
"facts": {"f_smoke": "лестница уже заполнена густым дымом"}}
monkeypatch.setattr(scenarios_api, "generate", fake_generate)
try:
with TestClient(app) as demo:
assert demo.post("/api/auth/dev-token").status_code == 200
created = demo.post("/api/scenarios/drafts/generate", json={
"source_id": "fire-apartment-l2", "instruction": "Добавить густой ночной дым",
})
assert created.status_code == 201, created.text
draft_id = created.json()["id"]
assert demo.get(f"/api/scenarios/drafts/{draft_id}").status_code == 200
assert demo.post(f"/api/scenarios/drafts/{draft_id}/validate").json()["valid"]
assert demo.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 200
assert demo.get(f"/api/scenarios/{draft_id}").status_code == 200
finally:
get_settings.cache_clear()
def test_invalid_draft_cannot_be_approved(client):
client.post("/api/auth/dev-token")
response = client.post(
@ -120,6 +482,24 @@ def test_invalid_draft_cannot_be_approved(client):
assert store.get(draft_id) is None
def test_unresolved_fact_placeholder_cannot_be_validated_or_approved(client):
client.post("/api/auth/dev-token")
created = client.post(
"/api/scenarios/drafts/from-template",
json={"source_id": "fire-apartment-l2"},
).json()
draft_id = created["id"]
facts = created["body"]["facts"]
facts[1]["value"] = "{f_what_burns}"
assert client.patch(
f"/api/scenarios/drafts/{draft_id}", json={"facts": facts}
).status_code == 200
check = client.post(f"/api/scenarios/drafts/{draft_id}/validate")
assert check.json()["valid"] is False
assert "плейсхолдер" in check.json()["errors"][0]
assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 422
def test_unauthenticated_user_cannot_manage_drafts(client):
response = client.post(
"/api/scenarios/drafts/from-template", json={"source_id": "fire-apartment-l2"}
@ -127,6 +507,94 @@ def test_unauthenticated_user_cannot_manage_drafts(client):
assert response.status_code == 401
def test_instructor_archives_and_restores_scenario_without_deleting_history(client):
client.post("/api/auth/dev-token")
created = client.post(
"/api/scenarios/drafts/from-template",
json={"source_id": "fire-apartment-l2", "title": "Сценарий для проверки архива"},
)
assert created.status_code == 201, created.text
scenario_id = created.json()["id"]
approved = client.post(f"/api/scenarios/drafts/{scenario_id}/approve")
assert approved.status_code == 200, approved.text
original = store.get(scenario_id)
assert original is not None
archived = client.delete(f"/api/scenarios/{scenario_id}")
assert archived.status_code == 200, archived.text
assert archived.json()["status"] == "archived"
assert store.get(scenario_id) is None
assert scenario_id not in {item["id"] for item in client.get("/api/scenarios").json()}
assert client.get(f"/api/scenarios/{scenario_id}").status_code == 404
restored = client.post(f"/api/scenarios/{scenario_id}/restore")
assert restored.status_code == 200, restored.text
assert restored.json()["status"] == "published"
assert store.get(scenario_id).title == original.title
assert scenario_id in {item["id"] for item in client.get("/api/scenarios").json()}
def test_instructor_cannot_read_or_edit_another_instructors_draft(client, monkeypatch):
from app.api.auth import Principal
from app.domain.roles import Role
identity = {"login": "teacher-one"}
def instructor(_request, *_roles):
return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
monkeypatch.setattr(scenarios_api, "require", instructor)
monkeypatch.setattr(scenarios_api, "current", instructor)
created = client.post(
"/api/scenarios/drafts/from-template",
json={"source_id": "fire-apartment-l2", "title": "Личный черновик"},
)
assert created.status_code == 201, created.text
scenario_id = created.json()["id"]
identity["login"] = "teacher-two"
assert client.get(f"/api/scenarios/drafts/{scenario_id}").status_code == 404
assert client.patch(
f"/api/scenarios/drafts/{scenario_id}", json={"title": "Чужая правка"}
).status_code == 404
assert client.post(f"/api/scenarios/drafts/{scenario_id}/approve").status_code == 404
def test_instructor_cannot_archive_another_instructors_published_scenario(client, monkeypatch):
from app.api.auth import Principal
from app.domain.roles import Role
identity = {"login": "teacher-one"}
def instructor(_request, *_roles):
return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
monkeypatch.setattr(scenarios_api, "require", instructor)
monkeypatch.setattr(scenarios_api, "current", instructor)
created = client.post(
"/api/scenarios/drafts/from-template",
json={"source_id": "fire-apartment-l2", "title": "Публикация автора"},
)
assert created.status_code == 201, created.text
scenario_id = created.json()["id"]
assert client.post(f"/api/scenarios/drafts/{scenario_id}/approve").status_code == 200
scenario = next(item for item in client.get("/api/scenarios").json() if item["id"] == scenario_id)
assert scenario["can_manage"] is True
identity["login"] = "teacher-two"
scenario = next(item for item in client.get("/api/scenarios").json() if item["id"] == scenario_id)
assert scenario["can_manage"] is False
assert client.delete(f"/api/scenarios/{scenario_id}").status_code == 404
def test_scenario_in_active_lesson_cannot_be_archived(client, monkeypatch):
client.post("/api/auth/dev-token")
monkeypatch.setattr(scenarios_api.hub, "has_active_scenario", lambda _scenario_id: True)
response = client.delete("/api/scenarios/fire-apartment-l2")
assert response.status_code == 409
assert response.json()["detail"] == "scenario_is_used_by_active_session"
@pytest.mark.asyncio
async def test_approved_scenario_restores_from_local_database():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)

View file

@ -5,7 +5,7 @@ from pathlib import Path
import pytest
from pydantic import ValidationError
from app.domain.events import Metric
from app.domain.events import LessonCriteria, Metric
from app.scenarios.loader import load_file
from app.scenarios.schema import Scenario
from app.scoring.gost import GostResult
@ -37,3 +37,12 @@ def test_scenario_accepts_only_known_finite_weights():
body["score_weights"] = {"address": 11.0}
with pytest.raises(ValidationError, match="от 0 до 10"):
Scenario.model_validate(body)
def test_lesson_criteria_validate_metric_weight_overrides():
criteria = LessonCriteria(score_weights={"address": 3.5, "dds_ack": 0})
assert criteria.score_weights == {"address": 3.5, "dds_ack": 0}
with pytest.raises(ValidationError, match="неизвестные метрики"):
LessonCriteria(score_weights={"adress": 2})
with pytest.raises(ValidationError, match="от 0 до 10"):
LessonCriteria(score_weights={"address": 11})

View file

@ -30,6 +30,35 @@ async def test_trainee_cannot_read_foreign_session(monkeypatch):
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_instructor_cannot_read_foreign_session(monkeypatch):
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _: who)
async def row(_db, _session_id):
return SimpleNamespace(owner_login="teacher-b")
monkeypatch.setattr(sessions.repo, "get_session", row)
with pytest.raises(HTTPException) as error:
await sessions.read(uuid4(), request(), db=object())
assert error.value.status_code == 404
@pytest.mark.asyncio
async def test_instructor_history_is_scoped_to_owner(monkeypatch):
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _: who)
seen = {}
async def history(_db, **filters):
seen.update(filters)
return []
monkeypatch.setattr(sessions.repo, "history", history)
await sessions.listing(request(), db=object())
assert seen["owner_login"] == "teacher-a"
@pytest.mark.asyncio
async def test_trainee_cannot_read_foreign_checklist(monkeypatch):
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())

View file

@ -0,0 +1,167 @@
"""Промежуточное состояние занятия переживает смену backend-процесса."""
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
import pytest
from app.domain.events import Exercise, LessonCriteria, SessionMode
from app.domain.statuses import PhoneCallPending, ServiceStatus
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
from app.scoring.grammar import basic_check
from app.session.checkpoint import dump_state, load_state
from app.session.dds import deliver_due_cards, prepare_queue
from app.session.state import SessionState, now_utc
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
def dds_state() -> SessionState:
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
state = SessionState(
session_id=uuid4(),
scenario_id=scenario.id,
scenario_title=scenario.title,
level=scenario.level.value,
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
scenario=scenario.model_copy(deep=True),
dds_scenarios=[scenario.model_copy(deep=True)],
required_fields=list(scenario.required_fields),
trainee_name="Курсант для восстановления",
attempt=2,
criteria=LessonCriteria(
decision_time_limit_seconds=45,
allowed_errors=1,
require_correct_grammar=True,
),
)
state.timers.limits[TimerCode.DDS_ACK] = 45_000
prepare_queue(state, state.dds_scenarios)
service = state.notified_services()[0]
state.set_service_status(service, ServiceStatus.ACCEPTED, author="диспетчер")
state.crew_selected = state.crew_options()[0]
state.crew_assignments[service] = state.crew_selected
state.phone_pending = PhoneCallPending(
service=service, crew=state.crew_selected, phase="dispatched"
)
state.reply_text = "Сообщение принято, бригада направлена."
state.reply_grammar = basic_check(state.reply_text)
state.reply_log.append((now_utc(), state.reply_text))
state.dds_log.append(("crew.select", now_utc(), state.crew_selected))
return state
def test_active_dds_session_round_trips_without_losing_work():
before = dds_state()
payload = dump_state(before)
restored = load_state(
payload,
datetime.now(UTC) - timedelta(seconds=2),
)
assert restored.session_id == before.session_id
assert restored.exercise is Exercise.DDS
assert restored.criteria.decision_time_limit_seconds == 45
assert restored.dispatched_card == before.dispatched_card
assert restored.status_log == before.status_log
assert restored.crew_selected == before.crew_selected
assert restored.crew_assignments == before.crew_assignments
assert restored.phone_pending == before.phone_pending
assert restored.reply_text == before.reply_text
assert restored.reply_grammar == before.reply_grammar
assert restored.dds_scenarios[0].id == before.scenario_id
# Время простоя backend входит в норматив, а не обнуляет таймер.
timer = next(item for item in restored.timers.snapshot() if item.code is TimerCode.DDS_ACK)
assert timer.elapsed_ms >= 1_900
assert timer.limit_ms == 45_000
def test_checkpoint_rejects_unknown_format_version():
payload = dump_state(dds_state())
payload["version"] = 999
with pytest.raises(ValueError, match="версия"):
load_state(payload, datetime.now(UTC))
def test_concurrent_dds_queue_round_trips_with_each_timer_and_status():
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
second = first.model_copy(
deep=True,
update={"id": "checkpoint-second", "title": "Вторая карточка восстановления"},
)
state = SessionState(
session_id=uuid4(),
scenario_id=first.id,
scenario_title=first.title,
level=first.level.value,
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
dds_scenarios=[first, second],
)
prepare_queue(state, state.dds_scenarios)
first_id = state.dispatched_card.card_id
first_service = state.managed_services()[0]
state.set_service_status(first_service, ServiceStatus.ACCEPTED)
state.on_event("card.ack")
second_id = state.dds_live_cards[1].card_id
assert state.activate_dds_card(second_id)
restored = load_state(
dump_state(state),
datetime.now(UTC) - timedelta(seconds=2),
)
assert restored.dds_active_card_id == second_id
assert restored.dispatched_card.card_id == second_id
assert len(restored.dds_live_cards) == 2
first_restored = next(item for item in restored.dds_live_cards if item.card_id == first_id)
second_restored = next(item for item in restored.dds_live_cards if item.card_id == second_id)
assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED
assert first_restored.timers.measured_ms(TimerCode.DDS_ACK) is not None
second_timer = next(
item for item in second_restored.timers.snapshot() if item.code is TimerCode.DDS_ACK
)
assert second_timer.elapsed_ms >= 1_900
assert second_timer.stopped is False
def test_delivering_next_dds_card_does_not_clear_previous_card_state():
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
scenarios = [
first.model_copy(
deep=True,
update={"id": f"scheduled-checkpoint-{index}", "title": f"Карточка {index}"},
)
for index in range(3)
]
state = SessionState(
session_id=uuid4(),
scenario_id=scenarios[0].id,
scenario_title=scenarios[0].title,
level=scenarios[0].level.value,
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
dds_scenarios=scenarios,
)
prepare_queue(state, scenarios, arrival_interval_seconds=60, max_waiting=1)
first_id = state.dds_live_cards[0].card_id
service = state.managed_services()[0]
state.set_service_status(service, ServiceStatus.ACCEPTED)
state.capture_active_dds()
assert deliver_due_cards(state, now_utc() + timedelta(seconds=61)) == 1
first = next(card for card in state.dds_live_cards if card.card_id == first_id)
assert first.status_log[-1].status is ServiceStatus.ACCEPTED
second = next(card for card in state.dds_live_cards if card.original_index == 1)
assert state.activate_dds_card(second.card_id)
restored = load_state(dump_state(state), now_utc())
first_restored = next(card for card in restored.dds_live_cards if card.card_id == first_id)
assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED
assert restored.dds_active_card_id == second.card_id
assert len(restored.dds_live_cards) == 2
assert restored.dds_next_scenario_index == 2
assert restored.dds_next_arrival_at is not None

View file

@ -104,9 +104,8 @@ def test_acknowledgement_stops_the_four_second_norm(client):
control.__exit__(None, None, None)
def test_bounced_card_becomes_e6_with_the_reason(client):
"""Неполнота КИО перестаёт быть процентом в отчёте и становится
сорванным выездом с конкретной причиной."""
def test_dds_cannot_return_card_for_112_quality_control(client):
"""ДДС ставит статусы своей службы, а качество КИО контролирует 112."""
session_id, control, _ = lesson(client)
try:
state = hub.get(session_id)
@ -117,14 +116,15 @@ def test_bounced_card_becomes_e6_with_the_reason(client):
read_until(station, "card.received")
station.send_json({"type": "card.bounce", "missing_fields": ["floor", "victims_count"],
"comment": "куда ехать без этажа"})
wait_for(lambda: state.bounced_fields)
error = read_until(station, "error")
assert "служба контроля 112" in error["message"]
assert state.bounced_fields == []
trainee.send_json({"type": "call.hangup"})
wait_for(lambda: state.score is not None)
response = client.get(f"/api/sessions/{session_id}/report").json()
chain = [finding for finding in response["findings"] if finding["code"] == "E6"]
assert chain, "возврат карточки должен попасть в разбор"
assert "floor" in chain[0]["summary"] and "выезд сорван" in chain[0]["summary"]
assert chain == []
finally:
control.__exit__(None, None, None)
@ -160,8 +160,9 @@ def test_station_sees_the_notify_list_from_the_classifier(client):
session_id, station, contexts = dispatched(client)
try:
state = read_until(station, "station.state")
assert "Служба 101" in state["snapshot"]["services"]
assert len(state["snapshot"]["services"]) > 1
assert state["snapshot"]["services"] == ["Служба 101"]
assert "Служба 101" in state["snapshot"]["recipient_services"]
assert len(state["snapshot"]["recipient_services"]) > 1
assert state["snapshot"]["statuses"]["Служба 101"] == "added"
finally:
close_all(contexts)

View file

@ -6,6 +6,7 @@
import contextlib
import time
import wave
from uuid import uuid4
import pytest
@ -256,6 +257,32 @@ def test_events_still_work_between_audio_frames(client):
assert state.audio_frames == 2
def test_answered_call_is_saved_as_wav_on_hangup(client, monkeypatch, tmp_path):
from app.api.ws import call as call_api
from app.voice.recording import CallRecorder
monkeypatch.setattr(
call_api, "start_recording",
lambda session_id: CallRecorder(tmp_path / f"{session_id}.wav"),
)
with lesson(client) as (session_id, _):
state = hub.get(session_id)
path = tmp_path / f"{session_id}.wav"
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "call.incoming")
trainee.send_json({"type": "call.answer"})
read_until(trainee, "call.started")
trainee.send_bytes((1000).to_bytes(2, "little", signed=True) * 320)
trainee.send_json({"type": "call.hangup"})
read_until(trainee, "call.ended")
wait_for(path.is_file)
assert state.recording_path == str(path)
with wave.open(str(path), "rb") as source:
assert source.getframerate() == 16_000
assert source.getnframes() >= 320
def test_score_waits_for_self_assessment(client):
"""Курсант сначала сверяет своё ощущение с объективной картиной:
расхождение самооценки с автооценкой — отдельный материал для преподавателя."""

2
backend/uv.lock generated
View file

@ -397,6 +397,7 @@ dependencies = [
{ name = "alembic" },
{ name = "argon2-cffi" },
{ name = "asyncpg" },
{ name = "certifi" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "itsdangerous" },
@ -430,6 +431,7 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.13" },
{ name = "argon2-cffi", specifier = ">=23.1" },
{ name = "asyncpg", specifier = ">=0.29" },
{ name = "certifi", specifier = ">=2024" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "itsdangerous", specifier = ">=2.1" },

28
docker-compose.sip.yml Normal file
View file

@ -0,0 +1,28 @@
services:
sip:
build: ./sip
image: lct-hack-sip:local
restart: unless-stopped
environment:
SIP_6001_PASSWORD: ${SIP_6001_PASSWORD:-}
SIP_6002_PASSWORD: ${SIP_6002_PASSWORD:-}
SIP_6003_PASSWORD: ${SIP_6003_PASSWORD:-}
SIP_6101_PASSWORD: ${SIP_6101_PASSWORD:-}
SIP_6102_PASSWORD: ${SIP_6102_PASSWORD:-}
SIP_EXTERNAL_MEDIA_ADDRESS: ${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1}
ports:
- "${BIND_HOST:-127.0.0.1}:${SIP_PORT:-5060}:5060/udp"
- "${BIND_HOST:-127.0.0.1}:${SIP_PORT:-5060}:5060/tcp"
- "${BIND_HOST:-127.0.0.1}:${SIPS_PORT:-5061}:5061/tcp"
- "${BIND_HOST:-127.0.0.1}:${SIP_WS_PORT:-8088}:8088/tcp"
- "${BIND_HOST:-127.0.0.1}:${RTP_PORT_START:-10000}-${RTP_PORT_END:-10099}:10000-10099/udp"
volumes:
- sipdata:/var/lib/lct-sip
- siprecordings:/recordings
- type: bind
source: ${SIP_TLS_DIR:-./.local/sip-tls}
target: /tls
volumes:
sipdata:
siprecordings:

16
docker-compose.tls.yml Normal file
View file

@ -0,0 +1,16 @@
services:
backend:
environment:
SECURE_COOKIES: "true"
frontend:
environment:
NGINX_ENVSUBST_TEMPLATE_DIR: /etc/nginx/templates-tls
PUBLIC_TLS_PORT: ${TLS_PORT:-5443}
TLS_BOOTSTRAP: "true"
ports:
- "${BIND_HOST:-127.0.0.1}:${TLS_PORT:-5443}:5443"
volumes:
- type: bind
source: ${TLS_CERT_DIR:-./.local/tls}
target: /etc/nginx/tls

View file

@ -1,11 +1,14 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: lct
POSTGRES_PASSWORD: lct
POSTGRES_DB: lct
ports: ["5432:5432"]
# БД и backend не публикуются в класс: с рабочих мест доступен только
# TLS-терминатор frontend. Loopback-порты нужны для администрирования хоста.
ports: ["127.0.0.1:${POSTGRES_PORT:-5432}:5432"]
volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U lct"]
@ -16,17 +19,67 @@ services:
backend:
build: ./backend
image: lct-hack-backend:local
restart: unless-stopped
# Один воркер принципиально: состояние живой сессии и реестр наблюдателей
# живут в памяти процесса (docs/arch/STACK.md).
command: sh -c "alembic upgrade head && python scripts/seed.py && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1"
command: >-
sh -c '
if [ -z "$${SESSION_SECRET:-}" ]; then
if [ ! -s /run/lct/session-secret ]; then
python -c "import secrets; print(secrets.token_urlsafe(48))" > /run/lct/session-secret;
chmod 600 /run/lct/session-secret;
fi;
export SESSION_SECRET="$$(cat /run/lct/session-secret)";
fi;
alembic upgrade head && python scripts/seed.py &&
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1'
environment:
DATABASE_URL: postgresql+asyncpg://lct:lct@postgres:5432/lct
OFFLINE: "true"
VOICE_ENABLED: "false"
RECORD_CALLS: "true"
RECORDINGS_DIR: /recordings
LLM_PROVIDER: local
LLM_BASE_URL: ${DOCKER_LLM_BASE_URL:-http://host.docker.internal:18080/v1}
LLM_MODEL_CALLER: ${LLM_MODEL_CALLER:-Qwen3-1.7B}
LLM_CONTROL_BASE_URL: ${DOCKER_LLM_CONTROL_BASE_URL:-http://host.docker.internal:18081/v1}
LLM_MODEL_CONTROL: ${LLM_MODEL_CONTROL:-Vikhr-1B}
GRAMMAR_LLM_ENABLED: ${GRAMMAR_LLM_ENABLED:-false}
ALLOW_DOCKER_HOST_MODELS: "true"
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
BACKUP_KEEP: ${BACKUP_KEEP:-14}
volumes:
- ./backend:/app
- ./scenarios:/scenarios:ro
ports: ["8000:8000"]
- securitydata:/run/lct
- recordings:/recordings
- backups:/app/backups
ports: ["127.0.0.1:${BACKEND_PORT:-8000}:8000"]
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=2).read()"]
interval: 5s
timeout: 3s
retries: 12
start_period: 10s
backup:
build: ./backend
image: lct-hack-backend:local
restart: unless-stopped
command: python scripts/backup_loop.py
environment:
DATABASE_URL: postgresql+asyncpg://lct:lct@postgres:5432/lct
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
BACKUP_RETRY_SECONDS: ${BACKUP_RETRY_SECONDS:-300}
BACKUP_KEEP: ${BACKUP_KEEP:-14}
volumes:
- ./backend:/app
- backups:/app/backups
depends_on:
postgres:
condition: service_healthy
@ -34,15 +87,22 @@ services:
frontend:
build: ./frontend
image: lct-hack-frontend:local
command: npm run dev -- --host 0.0.0.0
volumes:
- ./frontend:/app
- /app/node_modules
restart: unless-stopped
environment:
VITE_BACKEND_URL: http://backend:8000
VITE_WS_BACKEND_URL: ws://backend:8000
ports: ["5173:5173"]
depends_on: [backend]
BACKEND_HOST: backend
BACKEND_PORT: 8000
ports: ["${BIND_HOST:-127.0.0.1}:${FRONTEND_PORT:-5173}:5173"]
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 10s
timeout: 3s
retries: 3
volumes:
pgdata:
securitydata:
recordings:
backups:

View file

@ -1,9 +1,21 @@
FROM node:20-slim
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json* .npmrc ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
RUN apk add --no-cache openssl
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
COPY nginx.tls.conf.template /etc/nginx/templates-tls/default.conf.template
COPY docker-entrypoint.d/15-local-certificate.sh /docker-entrypoint.d/15-local-certificate.sh
COPY --from=build /app/dist /usr/share/nginx/html
ENV BACKEND_HOST=backend
ENV BACKEND_PORT=8000
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
EXPOSE 5443

View file

@ -0,0 +1,26 @@
#!/bin/sh
set -eu
if [ "${TLS_BOOTSTRAP:-false}" != "true" ]; then
exit 0
fi
certificate_dir=/etc/nginx/tls
certificate_file="$certificate_dir/tls.crt"
private_key_file="$certificate_dir/tls.key"
mkdir -p "$certificate_dir"
if [ -s "$certificate_file" ] && [ -s "$private_key_file" ]; then
exit 0
fi
openssl req -x509 -nodes -newkey rsa:3072 -sha256 -days 365 \
-keyout "$private_key_file" \
-out "$certificate_file" \
-subj "/CN=localhost/O=LCT local training stand" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
-addext "keyUsage=digitalSignature,keyEncipherment" \
-addext "extendedKeyUsage=serverAuth" 2>/dev/null
chmod 600 "$private_key_file"
chmod 644 "$certificate_file"
echo "TLS: создан локальный bootstrap-сертификат $certificate_file" >&2

View file

@ -0,0 +1,38 @@
server {
listen 5173;
server_name _;
root /usr/share/nginx/html;
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types application/javascript application/json image/svg+xml text/css text/plain;
location /api/ {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws/ {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 75s;
}
location ~* \.(?:css|js|woff2?|png|svg)$ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}

View file

@ -0,0 +1,71 @@
server {
listen 5173;
server_name _;
return 308 https://$host:${PUBLIC_TLS_PORT}$request_uri;
}
server {
listen 5443 ssl;
server_name _;
root /usr/share/nginx/html;
ssl_certificate /etc/nginx/tls/tls.crt;
ssl_certificate_key /etc/nginx/tls/tls.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:LCT_TLS:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "same-origin" always;
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types application/javascript application/json image/svg+xml text/css text/plain;
location /api/ {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
location /ws/ {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 75s;
}
# SIP over WebSocket для встроенного WebRTC-телефона. Переменная и
# Docker DNS позволяют обычному TLS-стенду стартовать даже до sip-сервиса.
location = /sip-ws {
resolver 127.0.0.11 ipv6=off valid=10s;
set $sip_backend sip;
proxy_pass http://$sip_backend:8088/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location ~* \.(?:css|js|woff2?|png|svg)$ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}

View file

@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@tanstack/react-query": "^5.103.1",
"jssip": "^3.13.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
@ -1340,7 +1341,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -1410,6 +1410,15 @@
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -1467,6 +1476,17 @@
"node": ">=6"
}
},
"node_modules/jssip": {
"version": "3.13.8",
"resolved": "https://registry.npmjs.org/jssip/-/jssip-3.13.8.tgz",
"integrity": "sha512-ErxEdy13vXR5izGo42fpXOU64UmC2VF7Shg4oYG7R2NzZF0FTPeYikffkIIQa9w+/11YvTlgezG1nSaycWYXPA==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.1",
"events": "^3.3.0",
"sdp-transform": "^2.14.1"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -1493,7 +1513,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@ -1683,6 +1702,15 @@
"loose-envify": "^1.1.0"
}
},
"node_modules/sdp-transform": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",

View file

@ -12,6 +12,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.103.1",
"jssip": "^3.13.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"

View file

@ -1,22 +1,30 @@
import { lazy, Suspense } from "react";
import { createBrowserRouter, Navigate } from "react-router-dom";
import { Call } from "@/pages/trainee/Call";
import { Instructor } from "@/pages/instructor/Instructor";
import { Dds } from "@/pages/dds/Dds";
import { Admin } from "@/pages/admin/Admin";
import { Profile } from "@/pages/profile/Profile";
import { Groups } from "@/pages/groups/Groups";
import { Wall } from "@/pages/wall/Wall";
import { RequireAuth } from "@/features/auth/Login";
import { DEMO_MODE } from "@/features/auth/useAuth";
const Call = lazy(() => import("@/pages/trainee/Call").then((module) => ({ default: module.Call })));
const Instructor = lazy(() => import("@/pages/instructor/Instructor").then((module) => ({ default: module.Instructor })));
const Dds = lazy(() => import("@/pages/dds/Dds").then((module) => ({ default: module.Dds })));
const Admin = lazy(() => import("@/pages/admin/Admin").then((module) => ({ default: module.Admin })));
const Profile = lazy(() => import("@/pages/profile/Profile").then((module) => ({ default: module.Profile })));
const Groups = lazy(() => import("@/pages/groups/Groups").then((module) => ({ default: module.Groups })));
const Wall = lazy(() => import("@/pages/wall/Wall").then((module) => ({ default: module.Wall })));
const Phone = lazy(() => import("@/pages/phone/Phone").then((module) => ({ default: module.Phone })));
const Materials = lazy(() => import("@/pages/materials/Materials").then((module) => ({ default: module.Materials })));
// Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md).
// session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой.
// Каждый экран за проверкой роли: курсант не открывает пульт даже по прямой
// ссылке. Сервер проверяет то же самое на сокетах — здесь только чтобы человек
// увидел понятное объяснение вместо пустого экрана (lct-23).
const guarded = (screen: string, element: JSX.Element) => (
<RequireAuth screen={screen}>{element}</RequireAuth>
<RequireAuth screen={screen}>
<Suspense fallback={<main className="page"><p>Загружаем рабочее место…</p></main>}>
{element}
</Suspense>
</RequireAuth>
);
export const router = createBrowserRouter([
@ -27,5 +35,7 @@ export const router = createBrowserRouter([
{ path: "/profile", element: guarded("/profile", <Profile />) },
{ path: "/groups", element: guarded("/groups", <Groups />) },
{ path: "/dds", element: guarded("/dds", <Dds />) },
{ path: "/phone", element: guarded("/phone", <Phone />) },
{ path: "/materials", element: guarded("/materials", <Materials />) },
{ path: "/admin", element: guarded("/admin", <Admin />) },
]);

View file

@ -23,6 +23,7 @@ function DemoLinks() {
<a href="/instructor" onClick={(event) => openScreen(event, "/instructor")}>Преподаватель</a>
<a href="/trainee" onClick={(event) => openScreen(event, "/trainee")}>Оператор 112</a>
<a href="/dds" onClick={(event) => openScreen(event, "/dds")}>ДДС</a>
<a href="/materials" onClick={(event) => openScreen(event, "/materials")}>Материалы</a>
</div>
</details>
</nav>;

View file

@ -73,7 +73,7 @@ export function useLogout() {
/** Какие экраны открыты роли. Повторяет domain/roles.py: список короткий
* и меняется редко, а держать его на сервере ради двух строк — лишний запрос. */
export const SCREENS: Record<Role, string[]> = {
admin: ["/admin", "/profile", "/groups"],
instructor: ["/instructor", "/wall", "/profile", "/dds", "/groups"],
trainee: ["/trainee", "/dds", "/profile"],
admin: ["/admin", "/profile", "/groups", "/materials"],
instructor: ["/instructor", "/wall", "/profile", "/dds", "/phone", "/groups", "/materials"],
trainee: ["/trainee", "/dds", "/phone", "/profile", "/materials"],
};

View file

@ -69,7 +69,20 @@ export function useCall(sessionId: string | null) {
useEffect(() => {
if (!sessionId) return;
const ch = callChannel(sessionId, {
onStatus: setStatus,
onStatus: (next) => {
setStatus(next);
if (next === "open") {
// Браузер не подтверждает доставку WebSocket-кадра. Всё, что ещё
// отмечено pending и не пришло эхом от сервера, повторяем после
// reconnect: kio.patch идемпотентен и не создаёт двойных действий.
queueMicrotask(() => setCard((current) => {
if (Object.keys(current.pending).length) {
channel.current?.send({ type: "kio.patch", fields: current.pending });
}
return current;
}));
}
},
onBinary: (frame) => audio.current?.playback.enqueue(frame),
onEvent: (event: ServerToTrainee) => {
if (event.type === "card.briefing") {

View file

@ -27,6 +27,16 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
Скачать отчёт: <a href={`/api/sessions/${report.session_id}/report.csv`}>CSV</a>
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>PDF</a>
</p>
<p className={report.passed ? "state-ok" : "state-violated"}>
{report.passed ? "Зачёт" : "Не зачтено"}: нарушено {report.failed_metrics} метрик,
допустимо {report.criteria.allowed_errors}. Лимит решения ДДС —
{` ${report.criteria.decision_time_limit_seconds} с`}; грамматика
{report.criteria.require_correct_grammar ? " входит" : " не входит"} в оценку.
</p>
{Object.keys(report.criteria.score_weights ?? {}).length > 0 && <p className="ref">
Преподаватель переопределил веса: {Object.entries(report.criteria.score_weights ?? {})
.map(([key, weight]) => `${key} — ${weight}`).join("; ")}.
</p>}
<div className="columns">
<section>
@ -38,6 +48,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
<th>{metric.title}</th>
<td className={metric.passed ? "state-ok" : "state-violated"}>
{metric.fact} · норматив {metric.norm}
{` · вес ${metric.weight ?? 1}`}
{metric.ref && <span className="ref"> ({metric.ref})</span>}
</td>
</tr>

View file

@ -12,7 +12,7 @@ export function StandStatus() {
if (!health.data) return null;
if (health.data.demo_no_db) {
return <div className="stand stand-warn">Локальная демка: карточки 112 и ДДС работают без БД и голоса; история занятий не сохраняется.</div>;
return <div className="stand stand-warn">Локальная демка: карточки 112, ДДС и редактор работают без БД и голоса; история и черновики исчезнут после перезапуска.</div>;
}
const troubles: string[] = [];

View file

@ -61,7 +61,7 @@ export function Director({
рвёт звук немедленно.
</p>
<p>
<input type="text" value={free} placeholder="своя реплика — нужна сеть и LLM"
<input type="text" value={free} placeholder="своя реплика — нужна локальная модель"
onChange={(event) => setFree(event.target.value)} />{" "}
<button type="button" disabled={disabled || !free.trim()}
onClick={() => { fire(`своя реплика: ${free.trim()}`, free.trim()); setFree(""); }}>

View file

@ -3,7 +3,12 @@ import { useState } from "react";
import type { ScenarioSummary } from "@/shared/api/http";
type JsonObject = Record<string, unknown>;
type Draft = { id: string; status: "draft"; generation: "template_copy"; body: JsonObject };
type Draft = {
id: string;
status: "draft";
generation: "template_copy" | "ai_variant" | "ai_full";
body: JsonObject;
};
type Validation = { valid: boolean; errors: string[]; ground_truth?: JsonObject };
async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
@ -48,15 +53,31 @@ function pretty(body: JsonObject): string {
return JSON.stringify(body, null, 2);
}
function stringField(body: JsonObject, key: string): string {
return typeof body[key] === "string" ? body[key] : "";
}
function generationLabel(generation: Draft["generation"]): string {
if (generation === "ai_full") return "новый сюжет и эталон локальной модели";
if (generation === "ai_variant") return "вариант локальной модели";
return "копия шаблона";
}
export function ScenarioEditor({
scenarios,
selectedScenarioId,
onApproved,
onLibraryChanged,
}: {
scenarios: ScenarioSummary[];
selectedScenarioId: string;
onApproved: (id: string) => void | Promise<void>;
onLibraryChanged: () => void | Promise<void>;
}) {
const [sourceId, setSourceId] = useState("");
const [title, setTitle] = useState("");
const [instruction, setInstruction] = useState("");
const [revisionComment, setRevisionComment] = useState("");
const [draftId, setDraftId] = useState("");
const [draft, setDraft] = useState<Draft | null>(null);
const [bodyText, setBodyText] = useState("");
@ -64,6 +85,10 @@ export function ScenarioEditor({
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [archiveCandidate, setArchiveCandidate] = useState("");
const [lastArchived, setLastArchived] = useState<{ id: string; title: string } | null>(null);
const effectiveSourceId = sourceId || selectedScenarioId || scenarios[0]?.id || "";
const effectiveSource = scenarios.find((scenario) => scenario.id === effectiveSourceId);
function openDraft(value: Draft) {
setDraft(value);
@ -89,7 +114,7 @@ export function ScenarioEditor({
async function create() {
await perform(async () => {
const created = await api<Draft>("/api/scenarios/drafts/from-template", "POST", {
source_id: sourceId || scenarios[0]?.id,
source_id: effectiveSourceId,
...(title.trim() ? { title: title.trim() } : {}),
});
openDraft(created);
@ -97,6 +122,30 @@ export function ScenarioEditor({
});
}
async function generate() {
await perform(async () => {
setNotice("Qwen формирует вариацию… Обычно это занимает несколько секунд.");
const created = await api<Draft>("/api/scenarios/drafts/generate", "POST", {
source_id: effectiveSourceId,
instruction: instruction.trim(),
});
openDraft(created);
setNotice("Локальная модель создала вариацию. Проверьте сюжет, факты и эталон перед утверждением.");
});
}
async function generateFull() {
await perform(async () => {
setNotice("Qwen формирует новый сюжет по полям… Обычно это занимает 10–30 секунд.");
const created = await api<Draft>("/api/scenarios/drafts/generate-from-description", "POST", {
source_id: effectiveSourceId,
description: instruction.trim(),
});
openDraft(created);
setNotice("Локальная модель создала новый сюжет, все ответы и мягкий эталон. Класс ЕКП и структура опроса взяты из выбранного каркаса; проверьте результат.");
});
}
async function reopen() {
if (!draftId.trim()) return;
await perform(async () => {
@ -132,6 +181,20 @@ export function ScenarioEditor({
});
}
async function revise() {
await perform(async () => {
setNotice("Qwen перерабатывает черновик по комментарию…");
const current = await save();
const updated = await api<Draft>(
`/api/scenarios/drafts/${encodeURIComponent(current.id)}/revise`, "POST",
{ comment: revisionComment.trim() },
);
openDraft(updated);
setRevisionComment("");
setNotice("Локальная модель переработала этот же черновик по комментарию. Проверьте изменения заново.");
});
}
async function approve() {
await perform(async () => {
const current = await save();
@ -144,30 +207,107 @@ export function ScenarioEditor({
});
}
async function archiveSelected() {
await perform(async () => {
const selected = scenarios.find((item) => item.id === effectiveSourceId);
if (!selected) throw new Error("Выберите сценарий для архивирования");
await api(`/api/scenarios/${encodeURIComponent(selected.id)}`, "DELETE");
setLastArchived({ id: selected.id, title: selected.title });
setArchiveCandidate("");
setSourceId("");
setNotice(`Сценарий «${selected.title}» перемещён в архив и больше не назначается.`);
await onLibraryChanged();
});
}
async function restoreLast() {
if (!lastArchived) return;
await perform(async () => {
await api(`/api/scenarios/${encodeURIComponent(lastArchived.id)}/restore`, "POST");
const restored = lastArchived;
setLastArchived(null);
setSourceId(restored.id);
setNotice(`Сценарий «${restored.title}» восстановлен в учебной библиотеке.`);
await onLibraryChanged();
});
}
return (
<section className="scenario-editor">
<h2>Редактор учебных сценариев</h2>
<p className="ref">Создание работает локально: копия утверждённого шаблона, затем правка и проверка преподавателем.</p>
<section className="scenario-editor" id="ai-scenario-editor" aria-busy={busy}>
<h2>ИИ-формирователь и редактор сценариев</h2>
<p className="ref">Можно сделать безопасную вариацию либо новый сюжет из описания. Выбранный утверждённый сценарий задаёт класс ЕКП и структуру опроса; при новом сюжете Qwen заново создаёт все ответы, адрес и число пострадавших. Публикация — только после вашей проверки.</p>
<div className="scenario-editor-create">
<label>Шаблон
<select value={sourceId || scenarios[0]?.id || ""} onChange={(event) => setSourceId(event.target.value)}>
<label>Каркас категории и опроса
<select value={effectiveSourceId} onChange={(event) => setSourceId(event.target.value)}>
{scenarios.map((scenario) => <option key={scenario.id} value={scenario.id}>{scenario.title} · {scenario.level}</option>)}
</select>
{effectiveSource && <small className="ref">
Фиксированный путь ЕКП: {effectiveSource.signs?.length
? effectiveSource.signs.join(" → ") : "без размеченных признаков"}
{effectiveSource.incident_code ? ` · код ${effectiveSource.incident_code}` : ""}
</small>}
</label>
<label>Название нового сценария
<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="необязательно" maxLength={200} />
</label>
<button type="button" onClick={create} disabled={busy || !scenarios.length}>Создать черновик</button>
</div>
<div className="scenario-editor-create">
<label>Указание для локальной модели
<input value={instruction} onChange={(event) => setInstruction(event.target.value)}
placeholder="Например: ночное возгорание, плотный дым, заявитель паникует"
maxLength={1000} />
<small className="ref">Описание меняет обстоятельства, но не показанный выше путь ЕКП.</small>
</label>
<button type="button" onClick={generate} disabled={busy || !scenarios.length || instruction.trim().length < 10}>
Создать вариант ИИ
</button>
<button type="button" onClick={generateFull}
disabled={busy || !scenarios.length || instruction.trim().length < 20}>
Создать новый сюжет и эталон
</button>
</div>
<div className="scenario-editor-open">
<label>Номер черновика
<input value={draftId} onChange={(event) => setDraftId(event.target.value)} placeholder="draft-…" />
</label>
<button type="button" onClick={reopen} disabled={busy || !draftId.trim()}>Открыть</button>
</div>
<div className="scenario-library-actions">
{!archiveCandidate ? <button type="button" disabled={busy || !effectiveSourceId || !effectiveSource?.can_manage}
onClick={() => setArchiveCandidate(effectiveSourceId)}>Архивировать выбранный сценарий</button> : <>
<span>Сценарий исчезнет из назначения, но история занятий сохранится.</span>
<button type="button" className="danger" disabled={busy}
onClick={archiveSelected}>Подтвердить архивирование</button>
<button type="button" disabled={busy} onClick={() => setArchiveCandidate("")}>Отмена</button>
</>}
{effectiveSource && !effectiveSource.can_manage && <small className="ref">
Базовые сценарии и сценарии других преподавателей доступны только для чтения.
</small>}
{lastArchived && <button type="button" disabled={busy} onClick={restoreLast}>
Вернуть «{lastArchived.title}» из архива
</button>}
</div>
{draft && (
<>
<p className="ref">Черновик {draft.id}. Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.</p>
<p className="ref">Черновик {draft.id} ({generationLabel(draft.generation)}). Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.</p>
<div className="scenario-editor-result">
<strong>Предпросмотр: {stringField(draft.body, "title")}</strong>
<p>Первая реплика: «{stringField(draft.body, "first_line")}»</p>
<p className="ref">Сюжетные факты — проверьте, что они согласуются друг с другом и с эталоном:</p>
<ul>{Array.isArray(draft.body.facts) && draft.body.facts.map((item, index) =>
isObject(item) ? <li key={`${index}-${String(item.id)}`}>{String(item.id)}: {String(item.value ?? "")}</li> : null
)}</ul>
</div>
<div className="scenario-editor-create">
<label>Комментарий для переработки
<input value={revisionComment} onChange={(event) => setRevisionComment(event.target.value)}
placeholder="Например: убери упоминание газа и сделай дым плотнее"
maxLength={1000} />
</label>
<button type="button" onClick={revise}
disabled={busy || revisionComment.trim().length < 10}>Переработать с ИИ</button>
</div>
<label className="scenario-editor-json">JSON тела сценария
<textarea value={bodyText} onChange={(event) => { setBodyText(event.target.value); setValidation(null); }} spellCheck={false} />
</label>

Some files were not shown because too many files have changed in this diff Show more